diff --git a/.bun-version b/.bun-version new file mode 100644 index 00000000000..f0bb29e7638 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.0 diff --git a/.github/actions/build-package/action.yml b/.github/actions/build-package/action.yml new file mode 100644 index 00000000000..618c78c1c9a --- /dev/null +++ b/.github/actions/build-package/action.yml @@ -0,0 +1,33 @@ +name: Build and package (Bun -> single zip) +description: Build with Bun (Turbo) and package a single distributable archive +outputs: + archive_path: + description: Absolute path to the archive + value: ${{ steps.pkg.outputs.archive_path }} +runs: + using: composite + steps: + - name: Setup Bun (from .bun-version) + uses: ./.github/actions/setup-bun + + - name: Build (Turbo) + shell: bash + run: bunx turbo run build + + - name: Ensure zip is available + shell: bash + run: sudo apt-get update -y && sudo apt-get install -y zip + + - name: Package single file + id: pkg + shell: bash + run: | + set -e + mkdir -p bundle + if [ -d dist ]; then SRC=dist; elif [ -d build ]; then SRC=build; else SRC=.; fi + if [ "$SRC" = "." ]; then + zip -r bundle/opencode.zip . -x '.git/*' '.github/*' 'node_modules/*' + else + (cd "$SRC" && zip -r ../bundle/opencode.zip .) + fi + echo "archive_path=$(pwd)/bundle/opencode.zip" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 108a53df2b8..a6c274c6e54 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -1,20 +1,43 @@ -name: "Setup Bun" -description: "Setup Bun with caching and install dependencies" +name: setup-bun +description: Setup Bun from .bun-version (or input) and install workspace deps +inputs: + bun-version: + description: Fallback Bun version if .bun-version is absent + required: false + default: '1.3.0' +outputs: + resolved-version: + description: The Bun version that was installed + value: ${{ steps.ver.outputs.version }} runs: - using: "composite" + using: composite steps: - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - name: Resolve Bun version (prefer .bun-version) + id: ver + shell: bash + run: | + if [ -f .bun-version ]; then + ver=$(tr -d '[:space:]' < .bun-version) + else + ver='${{ inputs.bun-version }}' + fi + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "Resolved Bun version: $ver" - - name: Cache ~/.bun - id: cache-bun - uses: actions/cache@v4 + - name: Setup Bun (no tool-cache, exact version) + uses: oven-sh/setup-bun@v2 with: - path: ~/.bun - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb', 'bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + bun-version: ${{ steps.ver.outputs.version }} + no-cache: true + + - name: Verify Bun version + shell: bash + run: | + set -e + echo "bun version: $(bun --version)" + test "$(bun --version | awk '{print $1}')" = "${{ steps.ver.outputs.version }}" - - name: Install dependencies - run: bun install + # Historical behavior: run bun install during setup + - name: Install workspace dependencies shell: bash + run: bun install --frozen-lockfile || bun install diff --git a/.github/workflows/bun-audit.yml b/.github/workflows/bun-audit.yml new file mode 100644 index 00000000000..c056cd3a601 --- /dev/null +++ b/.github/workflows/bun-audit.yml @@ -0,0 +1,35 @@ +name: Bun Audit + +on: + push: + pull_request: + workflow_call: + +jobs: + bun-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run bun audit + run: bun audit --audit-level=high --json > bun-audit.json + + - name: Count advisories + run: jq '.advisories | length' bun-audit.json + + - name: Fail on findings + run: test "$(jq '.advisories | length' bun-audit.json)" = "0" + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bun-audit-report + path: bun-audit.json diff --git a/.github/workflows/clam-av.yml b/.github/workflows/clam-av.yml new file mode 100644 index 00000000000..37b3373f760 --- /dev/null +++ b/.github/workflows/clam-av.yml @@ -0,0 +1,59 @@ +name: av-clamav +on: + pull_request: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + clamav: + runs-on: ubuntu-latest + steps: + # Checkout the right ref + - name: Checkout (release tag) + if: github.event_name == 'release' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + - name: Checkout (PR/default) + if: github.event_name != 'release' + uses: actions/checkout@v4 + + # Single source-of-truth build -> one file + - name: Build and package + id: build + uses: ./.github/actions/build-package + + # Install fresh ClamAV DB + - name: Install & update ClamAV DB + run: | + set -e + sudo apt-get update + sudo apt-get install -y clamav clamav-freshclam unzip + sudo systemctl stop clamav-freshclam || true + sudo mkdir -p /var/lib/clamav + sudo chown -R clamav:clamav /var/lib/clamav + sudo freshclam --verbose + ls -lh /var/lib/clamav + + # Scan extracted bundle so counts reflect actual files + - name: Extract bundle and scan + run: | + set -e + rm -rf scan && mkdir -p scan + unzip -q bundle/opencode.zip -d scan + echo "File count in payload: $(find scan -type f | wc -l)" + clamscan -ri --scan-archive=yes scan | tee clamav.log + ! grep -q 'Infected files: [1-9]' clamav.log + + - name: Upload scan results + uses: actions/upload-artifact@v4 + with: + name: clamav-scan-results + path: | + clamav.log + bundle/opencode.zip diff --git a/.github/workflows/odc.yml b/.github/workflows/odc.yml new file mode 100644 index 00000000000..3ec4f38d833 --- /dev/null +++ b/.github/workflows/odc.yml @@ -0,0 +1,33 @@ +name: OWASP Dependency-Check + +on: + push: + pull_request: + workflow_call: + +jobs: + odc: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Generate temporary npm lockfile + run: | + corepack enable + npm install --package-lock-only + + - name: Run Dependency-Check + uses: dependency-check/Dependency-Check_Action@main + with: + project: ${{ github.repository }} + path: package-lock.json + format: 'HTML,JSON' + out: reports + + - name: Upload Dependency-Check reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: odc-reports + path: reports diff --git a/.github/workflows/osv-scan.yml b/.github/workflows/osv-scan.yml new file mode 100644 index 00000000000..31894624abe --- /dev/null +++ b/.github/workflows/osv-scan.yml @@ -0,0 +1,28 @@ +name: OSV Scan + +on: + push: + pull_request: + workflow_call: + +jobs: + osv: + permissions: + contents: read + security-events: write + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run OSV Scanner + uses: google/osv-scanner-action@v1 + with: + scan-args: --lockfile bun.lock --recursive . + + - name: Upload OSV report + if: always() + uses: actions/upload-artifact@v4 + with: + name: osv-report + path: osv-scanner-results.sarif diff --git a/.github/workflows/owasp-scan.yml b/.github/workflows/owasp-scan.yml new file mode 100644 index 00000000000..46ffcf4af45 --- /dev/null +++ b/.github/workflows/owasp-scan.yml @@ -0,0 +1,66 @@ +name: owasp-dependency-check +on: + pull_request: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + depcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout (release tag) + if: github.event_name == 'release' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + - name: Checkout (PR/default) + if: github.event_name != 'release' + uses: actions/checkout@v4 + + - name: Setup Bun (repo action) + uses: ./.github/actions/setup-bun + + - name: Install workspace deps (Bun) + run: bun install --frozen-lockfile || bun install + + - name: Ensure per-package node_modules (symlink to root) + run: | + set -e + root_nm="$(pwd)/node_modules" + if [ ! -d "$root_nm" ]; then echo 'No root node_modules after bun install' >&2; exit 1; fi + # create a node_modules symlink in every workspace package that lacks one + git ls-files -z | tr '\0' '\n' | grep -E '(^|/)package.json$' | while read -r pj; do + pkgdir="$(dirname "$pj")" + [ "$pkgdir" = ".github/actions/setup-bun" ] && continue + if [ ! -d "$pkgdir/node_modules" ]; then + echo "linking $pkgdir/node_modules -> $root_nm" + ln -s "$root_nm" "$pkgdir/node_modules" || true + fi + done + + - name: Cache dependency-check data + uses: actions/cache@v4 + with: + path: ~/.m2/repository/org/owasp/dependency-check-data/ + key: depcheck-data-${{ runner.os }}-v2 + restore-keys: | + depcheck-data-${{ runner.os }}- + + - name: Run OWASP Dependency-Check + uses: dependency-check/Dependency-Check_Action@1.1.0 + with: + project: OpenCode + path: . + format: ALL + args: --enableExperimental + + - name: Upload reports + uses: actions/upload-artifact@v4 + with: + name: owasp-depcheck-report + path: reports/** diff --git a/.github/workflows/security-suite.yml b/.github/workflows/security-suite.yml new file mode 100644 index 00000000000..862f9abba27 --- /dev/null +++ b/.github/workflows/security-suite.yml @@ -0,0 +1,20 @@ +name: Security Suite + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +jobs: + bun-audit: + uses: ./.github/workflows/bun-audit.yml + + osv: + needs: bun-audit + uses: ./.github/workflows/osv-scan.yml + + owasp-dc: + needs: osv + uses: ./.github/workflows/odc.yml diff --git a/.github/workflows/windows-defender-scan.yml b/.github/workflows/windows-defender-scan.yml new file mode 100644 index 00000000000..82a81c41343 --- /dev/null +++ b/.github/workflows/windows-defender-scan.yml @@ -0,0 +1,156 @@ +name: av-windows-defender +on: + pull_request: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout (release tag) + if: github.event_name == 'release' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + - name: Checkout (PR/default) + if: github.event_name != 'release' + uses: actions/checkout@v4 + - name: Build and package + uses: ./.github/actions/build-package + - name: Upload build bundle + uses: actions/upload-artifact@v4 + with: + name: opencode-bundle + path: bundle/opencode.zip + + defender: + needs: build + runs-on: windows-latest + steps: + - name: Download build bundle + uses: actions/download-artifact@v4 + with: + name: opencode-bundle + path: bundle + + - name: Prepare scan dir + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path scan | Out-Null + Expand-Archive -Path bundle/opencode.zip -DestinationPath scan -Force + + - name: Locate MpCmdRun.exe + assert Defender is active + mark start + id: envdef + shell: pwsh + run: | + $mp = (Get-Command MpCmdRun.exe -ErrorAction SilentlyContinue)?.Path + if (-not $mp) { + $root = Join-Path $env:ProgramData 'Microsoft\Windows Defender\Platform' + if (Test-Path $root) { + $mp = Get-ChildItem $root -Recurse -Filter MpCmdRun.exe | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 -ExpandProperty FullName + } + } + if (-not $mp) { throw 'MpCmdRun.exe not found' } + + # AFTER (works on GH runners) + $s = Get-MpComputerStatus + if ($s.AMRunningMode -notin @('Normal','Passive') -or -not $s.AMServiceEnabled) { + throw "Defender engine unavailable (AMRunningMode=$($s.AMRunningMode), AMServiceEnabled=$($s.AMServiceEnabled))" + } + Write-Host "Defender status: AMRunningMode=$($s.AMRunningMode), RTP=$($s.RealTimeProtectionEnabled) - continuing (RTP not required for on-demand scans)." + + "MPCMDRUN=$mp" | Out-File -FilePath $env:GITHUB_ENV -Append + "DEFENDER_SINCE=$(Get-Date -Format o)" | Out-File -FilePath $env:GITHUB_ENV -Append + + # --- create a harmless but detectable file (EICAR) --- + - name: Create EICAR test file (ASCII, no newline) + shell: pwsh + run: | + $scan = (Resolve-Path 'scan').Path + $target = Join-Path $scan 'eicar.txt' + $p1 = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STAND' + $p2 = 'ARD-ANTIVIRUS-TEST-FILE!$H+H*' + Set-Content -LiteralPath $target -Value ($p1+$p2) -NoNewline -Encoding Ascii + + - name: Provoke RTP (force read) + shell: pwsh + run: | + cmd /c type scan\eicar.txt >NUL + Start-Sleep -Seconds 3 # give logs a moment + + # assert detection, but do NOT fail here; report via step output + - name: Collect detections and set outputs + id: detect + shell: pwsh + run: | + $since = [datetime]$env:DEFENDER_SINCE + $scanPath = (Resolve-Path 'scan').Path + $eicarPath = Join-Path $scanPath 'eicar.txt' # <-- whichever name you used + + # 1) pull threat history since our marker + $all = Get-MpThreatDetection | Where-Object { $_.InitialDetectionTime -ge $since } + + # classifier: EICAR if ThreatName mentions EICAR OR any resource equals our eicar file path + function Is-Eicar($d) { + try { + if ($d.ThreatName -match 'EICAR') { return $true } + } catch {} + try { + if ($d.Resources -and ($d.Resources.Resource | Where-Object { $_ -ieq $eicarPath })) { return $true } + } catch {} + return $false + } + + $eicar = @() + $real = @() + foreach ($d in $all) { if (Is-Eicar $d) { $eicar += $d } else { $real += $d } } + + # 2) (optional) also harvest Defender Operational events for context/evidence + $events = @() + try { + $events = Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-Windows Defender/Operational'; StartTime=$since } | + Where-Object { $_.Id -in 1116,1117 } | + ForEach-Object { [pscustomobject]@{ Id=$_.Id; TimeCreated=$_.TimeCreated; Message=$_.FormatDescription() } } + } catch { } + + # 3) write a structured artifact + [pscustomobject]@{ + since = $since + eicarHits = $eicar + realHits = $real + eventLog = $events + } | ConvertTo-Json -Depth 6 | Out-File defender-detections.json -Encoding UTF8 + + # 4) expose stable, lowercase outputs for gating + $eicarVerified = ($eicar.Count -gt 0) + $realCount = $real.Count + Add-Content -Path $env:GITHUB_OUTPUT -Value ("eicar_verified=" + $eicarVerified.ToString().ToLowerInvariant()) + Add-Content -Path $env:GITHUB_OUTPUT -Value ("real_detections=" + ([bool]($realCount -gt 0)).ToString().ToLowerInvariant()) + Add-Content -Path $env:GITHUB_OUTPUT -Value ("real_count=" + $realCount) + + - name: Upload scan results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: defender-detections + path: defender-detections.json + + # fail if EICAR missing OR real threats present + - name: Fail if EICAR not detected or real detections found + if: steps.detect.outputs.eicar_verified != 'true' || steps.detect.outputs.real_detections == 'true' + shell: pwsh + run: exit 1 + + # (optional) now run your on-demand directory scan of the bundle + - name: Windows Defender scan (directory) + shell: pwsh + run: | + & "$env:MPCMDRUN" -SignatureUpdate + & "$env:MPCMDRUN" -Scan -ScanType 3 -File (Resolve-Path 'scan').Path diff --git a/script/format.ts b/script/format.ts index c098097373d..37ceb9ac0de 100755 --- a/script/format.ts +++ b/script/format.ts @@ -2,6 +2,8 @@ import { $ } from "bun" +// Restore original behavior: use the package script named "prettier". +// (If missing, this will fail as before.) await $`bun run prettier --ignore-unknown --write` if (process.env["CI"] && (await $`git status --porcelain`.text())) {