diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000000..f0bb29e763 --- /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 0000000000..618c78c1c9 --- /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 108a53df2b..a6c274c6e5 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/clam-av.yml b/.github/workflows/clam-av.yml new file mode 100644 index 0000000000..5bc7b4b766 --- /dev/null +++ b/.github/workflows/clam-av.yml @@ -0,0 +1,74 @@ +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: Verify ClamAV detects EICAR signature + run: | + set -euo pipefail + printf 'X5O!P%%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.com + status=0 + clamscan eicar.com > eicar.log || status=$? + cat eicar.log + if [ "$status" -ne 1 ]; then + echo "ClamAV failed to report the EICAR signature" >&2 + exit 1 + fi + grep -q 'eicar.com: Eicar-Test-Signature FOUND' eicar.log + grep -q 'Infected files: 1' eicar.log + rm -f eicar.com eicar.log + + - 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 -qE 'Infected files: [1-9][0-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/owasp-scan.yml b/.github/workflows/owasp-scan.yml new file mode 100644 index 0000000000..46ffcf4af4 --- /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/windows-defender-scan.yml b/.github/workflows/windows-defender-scan.yml new file mode 100644 index 0000000000..b69d98eca3 --- /dev/null +++ b/.github/workflows/windows-defender-scan.yml @@ -0,0 +1,194 @@ +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 + + - name: On-demand scan EICAR file + shell: pwsh + run: | + $scan = (Resolve-Path 'scan').Path + $target = Join-Path $scan 'eicar.txt' + & "$env:MPCMDRUN" -Scan -ScanType 3 -File $target + Start-Sleep -Seconds 3 # allow detection telemetry to flush + + # 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 + + function Get-Detections { + param( + [datetime]$Start, + [string]$EicarPath + ) + + $results = Get-MpThreatDetection | Where-Object { $_.InitialDetectionTime -ge $Start } + + $eicarHits = @() + $realHits = @() + + foreach ($item in $results) { + $isEicar = $false + try { + if ($item.ThreatName -match 'EICAR') { $isEicar = $true } + } catch {} + try { + if (-not $isEicar -and $item.Resources -and ($item.Resources.Resource | Where-Object { $_ -ieq $EicarPath })) { + $isEicar = $true + } + } catch {} + + if ($isEicar) { $eicarHits += $item } + if (-not $isEicar) { $realHits += $item } + } + + return [pscustomobject]@{ Eicar = $eicarHits; Real = $realHits } + } + + # poll (up to 90s) because Defender threat history entries can be delayed + $deadline = (Get-Date).AddSeconds(90) + $interval = 5 + $detections = $null + + do { + $detections = Get-Detections -Start $since -EicarPath $eicarPath + if (($detections.Eicar.Count -gt 0) -or ($detections.Real.Count -gt 0)) { break } + if ((Get-Date) -ge $deadline) { break } + Start-Sleep -Seconds $interval + } while ($true) + + if (-not $detections) { + $detections = [pscustomobject]@{ Eicar = @(); Real = @() } + } + + $eicar = $detections.Eicar + $real = $detections.Real + + # 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 c098097373..37ceb9ac0d 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())) {