diff --git a/.github/release-ledger/README.md b/.github/release-ledger/README.md new file mode 100644 index 0000000..bf1747a --- /dev/null +++ b/.github/release-ledger/README.md @@ -0,0 +1,8 @@ +# Release ledger bootstrap + +Use `active-versions.json` as the root file when creating the protected orphan +`release-ledger` branch. Release workflows append `events/vVERSION.jsonl` and +promotion decisions to that branch; application source never belongs there. + +The branch is an append-only evidence store. Only the installed +`guardscan-release-bot` GitHub App may write it after bootstrap. diff --git a/.github/release-ledger/active-versions.json b/.github/release-ledger/active-versions.json new file mode 100644 index 0000000..eb8a23f --- /dev/null +++ b/.github/release-ledger/active-versions.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": "guardscan.active-trains.v1", + "trains": [] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f44f5..5c8c7c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,17 @@ name: CI on: push: - branches: [main, develop, "release/**"] + branches: [main, develop] pull_request: branches: [main, develop] permissions: contents: read +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: RELEASE_NODE_VERSION: 22.23.1 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 120bf12..f57e62a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -43,6 +43,8 @@ jobs: npm run lint:ratchet npm audit --omit=dev --audit-level=high npm run test:release + npm run test:package + npm run test:package-manager -- --manager npm - run: git diff --check npm: @@ -191,7 +193,7 @@ jobs: standalone/guardscan cosign verify-blob \ --bundle standalone/guardscan.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ standalone/guardscan @@ -220,6 +222,8 @@ jobs: APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} run: | IDENTITY="Developer ID Application: Nauman Tanwir ($APPLE_TEAM_ID)" + KEYCHAIN="$RUNNER_TEMP/guardscan-signing.keychain-db" + security find-identity -v -p codesigning "$KEYCHAIN" | grep -F "$IDENTITY" codesign --force --options runtime --timestamp --sign "$IDENTITY" standalone/guardscan codesign --verify --deep --strict --verbose=2 standalone/guardscan printf '%s' "$APPLE_NOTARY_PRIVATE_KEY" > "$RUNNER_TEMP/AuthKey.p8" @@ -256,6 +260,13 @@ jobs: cp "$RUNNER_TEMP/guardscan-notarization.dmg" standalone/stapled-notarization.dmg codesign -dvvv standalone/guardscan 2> standalone/apple-code-signing.txt + - name: Remove ephemeral Apple signing material + if: always() && matrix.os == 'darwin' + shell: bash + run: | + security delete-keychain "$RUNNER_TEMP/guardscan-signing.keychain-db" || true + rm -f "$RUNNER_TEMP/certificate.p12" "$RUNNER_TEMP/AuthKey.p8" + - name: Azure OIDC login if: matrix.os == 'windows' uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3 @@ -319,6 +330,32 @@ jobs: const tag = process.env.ARTIFACT_TAG; const base = `https://github.com/${process.env.GITHUB_REPOSITORY}/releases/download/${tag}`; const sbom = JSON.parse(fs.readFileSync('standalone-sbom/artifact-sbom.json')); + const prototype = JSON.parse(fs.readFileSync('standalone/standalone-prototype.json')); + const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(); + for (const [field, expected] of Object.entries({version, tag, commit})) { + if (prototype[field] !== expected) { + throw new Error(`standalone prototype ${field} does not match release source`); + } + } + if (prototype.platform?.os !== process.env.ARTIFACT_OS + || prototype.platform?.arch !== process.env.ARTIFACT_ARCH) { + throw new Error('standalone prototype platform does not match native target'); + } + const smoke = prototype.smoke; + const optionalCapabilities = smoke?.optionalCapabilities; + if (smoke?.valid !== true + || smoke.nodeAbsentFromPath !== true + || smoke.packageManagersAbsentFromPath !== true + || smoke.optionalCapabilitiesUnavailableSafely !== true + || !optionalCapabilities) { + throw new Error('standalone prototype lacks complete isolated smoke evidence'); + } + if (prototype.capabilities?.chartRendering + !== optionalCapabilities.chartRendering?.dependencyAvailable + || prototype.capabilities?.accurateTokenCounting + !== (optionalCapabilities.tokenCounting?.mode === 'accurate')) { + throw new Error('standalone prototype capability metadata conflicts with smoke evidence'); + } const signatureTypes = process.env.ARTIFACT_OS === 'darwin' ? ['apple-code-signing', 'apple-notarization'] : [process.env.ARTIFACT_OS === 'windows' ? 'authenticode' : 'sigstore']; @@ -332,12 +369,13 @@ jobs: schemaVersion: 'guardscan.standalone-evidence.v1', version, tag, - commit: cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(), + commit, platform: { os: process.env.ARTIFACT_OS, arch: process.env.ARTIFACT_ARCH, ...(process.env.ARTIFACT_LIBC ? {libc: process.env.ARTIFACT_LIBC} : {}), }, + optionalCapabilities, signatures: signatureTypes.map(type => ({ type, url: `${base}/${process.env.ARTIFACT_ID}.${signatureFile(type)}`, @@ -556,7 +594,7 @@ jobs: cosign sign-blob --yes --bundle release/SHA256SUMS.sigstore.json release/SHA256SUMS cosign verify-blob \ --bundle release/SHA256SUMS.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ release/SHA256SUMS - name: Attest complete release payload diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index f9ac882..d1f43a5 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -15,17 +15,30 @@ permissions: env: RELEASE_NODE_VERSION: 22.23.1 + SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed + SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939 jobs: discover: name: Discover active releases + if: github.event_name == 'workflow_dispatch' || vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 outputs: trains: ${{ steps.matrix.outputs.trains }} - implementation_ref: ${{ steps.matrix.outputs.implementation_ref }} steps: + - name: Validate optional manual version before minting credentials + env: + REQUEST_VERSION: ${{ inputs.version }} + run: | + node - <<'NODE' + const version = process.env.REQUEST_VERSION; + if (version && !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(version)) { + throw new Error('manual canary version is invalid'); + } + NODE - name: Create short-lived release app token id: app + if: inputs.version == '' || vars.RELEASE_AUTOMATION_ENABLED == 'true' uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} @@ -34,11 +47,12 @@ jobs: id: matrix env: GH_TOKEN: ${{ steps.app.outputs.token }} + REQUEST_VERSION: ${{ inputs.version }} run: | - if [ -n "${{ inputs.version }}" ]; then + if [ -n "$REQUEST_VERSION" ]; then printf '{"trains":[{"version":"%s","releasePr":0,"channel":"%s"}]}\n' \ - "${{ inputs.version }}" \ - "$([[ '${{ inputs.version }}' == *-rc.* ]] && echo rc || echo stable)" \ + "$REQUEST_VERSION" \ + "$([[ "$REQUEST_VERSION" == *-rc.* ]] && echo rc || echo stable)" \ > active.json else gh api \ @@ -47,7 +61,6 @@ jobs: > active.json fi echo "trains=$(node -p 'JSON.stringify(require("./active.json").trains || [])')" >> "$GITHUB_OUTPUT" - echo "implementation_ref=$(node -p 'require("./active.json").trains[0] ? `v${require("./active.json").trains[0].version}` : \"\"')" >> "$GITHUB_OUTPUT" package-managers: name: ${{ matrix.manager }} canary for ${{ matrix.train.version }} @@ -98,37 +111,41 @@ jobs: env: VERSION: ${{ matrix.train.version }} run: | + assert_version() { + ACTUAL_VERSION="$("$@" | tr -d '\r')" + test "$ACTUAL_VERSION" = "$VERSION" + } case "${{ matrix.manager }}" in npm) npm install --global "guardscan@$VERSION" - guardscan --version - npx --yes "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version npx --yes "guardscan@$VERSION" --version npm uninstall --global guardscan ;; pnpm) pnpm config set global-bin-dir "$RUNNER_TEMP/pnpm-bin" export PATH="$RUNNER_TEMP/pnpm-bin:$PATH" pnpm add --global "guardscan@$VERSION" - guardscan --version - pnpm dlx "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version pnpm dlx "guardscan@$VERSION" --version pnpm remove --global guardscan ;; yarn-classic) corepack enable corepack prepare yarn@1.22.22 --activate yarn global add "guardscan@$VERSION" - "$(yarn global bin)/guardscan" --version + assert_version "$(yarn global bin)/guardscan" --version yarn global remove guardscan ;; yarn-modern) corepack enable corepack prepare yarn@4.9.2 --activate - yarn dlx "guardscan@$VERSION" --version + assert_version yarn dlx "guardscan@$VERSION" --version ;; bun) bun add --global "guardscan@$VERSION" - guardscan --version - bunx "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version bunx "guardscan@$VERSION" --version bun remove --global guardscan ;; esac @@ -235,7 +252,7 @@ jobs: --dir release --pattern "$TARGET.guardscan.sigstore.json" cosign verify-blob \ --bundle "release/$TARGET.guardscan.sigstore.json" \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ executable/guardscan - name: Verify macOS signing and Gatekeeper @@ -263,14 +280,15 @@ jobs: EXE="$PWD/executable/$EXECUTABLE" ( cd project - env -i \ + ACTUAL_VERSION="$(env -i \ HOME="$PWD/../isolated-home" \ USERPROFILE="$PWD/../isolated-home" \ GUARDSCAN_HOME="$PWD/../isolated-home/.guardscan" \ GUARDSCAN_NO_TELEMETRY=true \ GUARDSCAN_OFFLINE=true \ PATH="" \ - "$EXE" --version + "$EXE" --version | tr -d '\r')" + test "$ACTUAL_VERSION" = "$VERSION" env -i HOME="$PWD/../isolated-home" PATH="" "$EXE" --help env -i HOME="$PWD/../isolated-home" PATH="" "$EXE" \ --no-telemetry scan --offline --no-cve --skip-tests --skip-ai \ @@ -296,10 +314,16 @@ jobs: run: | PYPI_VERSION="${VERSION/-rc./rc}" python -m pip install --no-deps "guardscan-cli==$PYPI_VERSION" - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" + guardscan --help | grep -q scan python -m pip uninstall -y guardscan-cli python -m pip install pipx python -m pipx install --pip-args=--no-deps "guardscan-cli==$PYPI_VERSION" + PIPX_BIN_DIR="$(python -m pipx environment --value PIPX_BIN_DIR)" + PIPX_GUARDSCAN="$PIPX_BIN_DIR/guardscan" + if [ -f "$PIPX_GUARDSCAN.exe" ]; then PIPX_GUARDSCAN="$PIPX_GUARDSCAN.exe"; fi + test "$("$PIPX_GUARDSCAN" --version | tr -d '\r')" = "$VERSION" + "$PIPX_GUARDSCAN" --help | grep -q scan python -m pipx runpip guardscan-cli show guardscan-cli python -m pipx uninstall guardscan-cli python - <<'PY' @@ -330,6 +354,26 @@ jobs: train: ${{ fromJSON(needs.discover.outputs.trains) }} adapter: [homebrew-macos, homebrew-linux, scoop] steps: + - name: Check out the pinned Scoop installer + if: matrix.adapter == 'scoop' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ScoopInstaller/Install + ref: ${{ env.SCOOP_INSTALL_COMMIT }} + path: .scoop-installer + persist-credentials: false + - name: Install the pinned Scoop CLI + if: matrix.adapter == 'scoop' + shell: pwsh + run: | + $scoopRoot = Join-Path $env:RUNNER_TEMP 'scoop' + & .\.scoop-installer\install.ps1 -RunAsAdmin -ScoopDir $scoopRoot + if (-not $?) { throw 'Pinned Scoop installer failed' } + git -C "$scoopRoot\apps\scoop\current" fetch origin $env:SCOOP_COMMIT --depth 1 + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch the pinned Scoop CLI' } + git -C "$scoopRoot\apps\scoop\current" checkout --detach $env:SCOOP_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Unable to select the pinned Scoop CLI' } + "$scoopRoot\shims" | Out-File $env:GITHUB_PATH -Append - name: Initialize fail-closed report shell: bash run: | @@ -360,15 +404,15 @@ jobs: brew install ntanwir10/tap/guardscan brew test ntanwir10/tap/guardscan brew upgrade --dry-run ntanwir10/tap/guardscan - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" brew uninstall guardscan else - git clone --branch "guardscan/v$VERSION" \ + git clone --branch "channel-preview/v$VERSION" \ https://github.com/ntanwir10/homebrew-tap preview-tap brew style preview-tap/Formula/guardscan.rb brew audit --strict preview-tap/Formula/guardscan.rb brew install --formula preview-tap/Formula/guardscan.rb - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" brew uninstall guardscan fi node -e ' @@ -386,15 +430,26 @@ jobs: CHANNEL: ${{ matrix.train.channel }} run: | if ($env:CHANNEL -eq 'stable') { - scoop bucket add guardscan https://github.com/ntanwir10/scoop-bucket + scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the stable GuardScan bucket' } + $package = 'guardscan/guardscan' } else { - git clone --branch "guardscan/v$env:VERSION" https://github.com/ntanwir10/scoop-bucket preview-bucket + git clone --branch "channel-preview/v$env:VERSION" https://github.com/ntanwir10/homebrew-tap preview-bucket + if ($LASTEXITCODE -ne 0) { throw 'Unable to clone the GuardScan preview bucket' } scoop bucket add guardscan-preview (Resolve-Path preview-bucket) + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the GuardScan preview bucket' } + $package = 'guardscan-preview/guardscan' + } + scoop install $package + if ($LASTEXITCODE -ne 0) { throw 'Scoop installation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne $env:VERSION) { + throw 'Scoop invocation or version check failed' } - scoop install guardscan - guardscan --version scoop update guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop update check failed' } scoop uninstall guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop uninstall failed' } node -e ' const fs = require("fs"); const report = JSON.parse(fs.readFileSync("report.json")); @@ -436,7 +491,6 @@ jobs: if: matrix.train.channel == 'stable' shell: pwsh run: | - $reports = @() foreach ($channel in @('winget', 'chocolatey')) { $report = @{ version = '${{ matrix.train.version }}' @@ -446,29 +500,34 @@ jobs: target = 'windows-x64' evidenceUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" } + $discovered = $false try { if ($channel -eq 'winget') { winget show --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" - if ($LASTEXITCODE -ne 0) { throw 'WinGet package is not public yet' } - winget install --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" --accept-package-agreements --accept-source-agreements - if ($LASTEXITCODE -ne 0) { throw 'WinGet installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'WinGet invocation failed' } - winget uninstall --exact --id NaumanTanwir.GuardScan - if ($LASTEXITCODE -ne 0) { throw 'WinGet uninstall failed' } + if ($LASTEXITCODE -eq 0) { + $discovered = $true + winget install --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw 'WinGet installation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ matrix.train.version }}') { throw 'WinGet invocation or version check failed' } + winget uninstall --exact --id NaumanTanwir.GuardScan + if ($LASTEXITCODE -ne 0) { throw 'WinGet uninstall failed' } + } } else { choco info guardscan --version "${{ matrix.train.version }}" --source https://community.chocolatey.org/api/v2/ - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey package is not public yet' } - choco install guardscan --version "${{ matrix.train.version }}" --yes - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey invocation failed' } - choco uninstall guardscan --yes - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey uninstall failed' } + if ($LASTEXITCODE -eq 0) { + $discovered = $true + choco install guardscan --version "${{ matrix.train.version }}" --yes + if ($LASTEXITCODE -ne 0) { throw 'Chocolatey installation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ matrix.train.version }}') { throw 'Chocolatey invocation or version check failed' } + choco uninstall guardscan --yes + if ($LASTEXITCODE -ne 0) { throw 'Chocolatey uninstall failed' } + } } - $report.status = 'passed' + if ($discovered) { $report.status = 'passed' } } catch { - if ($_ -notmatch 'not found|No package found|Unable to find') { $report.status = 'failed' } + if ($discovered) { $report.status = 'failed' } } $report | ConvertTo-Json -Compress | Out-File "$channel.json" } @@ -484,7 +543,10 @@ jobs: record: name: Append canary evidence needs: [discover, package-managers, native-and-pypi, adapters, moderated] - if: always() && needs.discover.outputs.trains != '[]' + if: >- + always() + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && needs.discover.outputs.trains != '[]' runs-on: ubuntu-24.04 concurrency: group: release-ledger @@ -498,9 +560,15 @@ jobs: private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ needs.discover.outputs.implementation_ref }} + ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 token: ${{ steps.app.outputs.token }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + - name: Install default-branch ledger tooling + working-directory: cli + run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: canary-* @@ -529,6 +597,18 @@ jobs: || a.report.checkedAt.localeCompare(b.report.checkedAt) || a.file.localeCompare(b.file)); const versions = [...new Set(reports.map(({report}) => report.version))]; + const expectedTargetCounts = { + npm: 1, + pnpm: 1, + yarn: 2, + bun: 1, + github: 5, + pypi: 5, + homebrew: 2, + scoop: 1, + winget: 1, + chocolatey: 1, + }; fs.mkdirSync('ledgers', {recursive: true}); for (const version of versions) { const tag = `v${version}`; @@ -542,7 +622,8 @@ jobs: const existing = readEvents(ledger); if (existing.length === 0) throw new Error(`release ledger is empty for ${tag}`); const commit = existing[0].commit; - for (const {file, report} of reports.filter(item => item.report.version === version)) { + const versionReports = reports.filter(item => item.report.version === version); + for (const {file, report} of versionReports) { if (report.status === 'pending') continue; const suffix = `${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}:${report.channel}:${report.target}:${path.basename(path.dirname(file))}`; appendEvent(ledger, { @@ -553,20 +634,40 @@ jobs: idempotencyKey: `canary:${suffix}`, payload: {status: report.status, evidenceUrl: report.evidenceUrl}, }); - if (report.status === 'passed') { - const types = ['winget', 'chocolatey'].includes(report.channel) + } + const versionAggregateTimestamp = versionReports + .filter(({report}) => report.status !== 'pending') + .map(({report}) => report.checkedAt) + .sort() + .at(-1); + for (const [channel, expectedCount] of Object.entries(expectedTargetCounts)) { + const channelReports = versionReports.filter(({report}) => report.channel === channel); + const resolved = channelReports.filter(({report}) => report.status !== 'pending'); + if (resolved.length === 0) continue; + const suffix = `${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}:${channel}`; + if (resolved.some(({report}) => report.status !== 'passed')) { + appendEvent(ledger, { + version, tag, commit, + timestamp: versionAggregateTimestamp, + type: 'channel_failed', + channel, + idempotencyKey: `channel-failed:${suffix}`, + payload: {error: 'one or more current public canary targets failed'}, + }); + continue; + } + if (resolved.length === expectedCount) { + const types = ['winget', 'chocolatey'].includes(channel) ? ['channel_accepted', 'channel_verified'] : ['channel_verified']; for (const type of types) { appendEvent(ledger, { version, tag, commit, - timestamp: report.checkedAt, + timestamp: versionAggregateTimestamp, type, - channel: report.channel, + channel, idempotencyKey: `${type}:${suffix}`, - payload: { - remoteIdentity: `${report.channel}:${version}`, - }, + payload: {}, }); } } @@ -575,11 +676,26 @@ jobs: NODE git worktree add ledger-branch origin/release-ledger cp ledgers/*.jsonl ledger-branch/events/ + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const {materializeReleaseState, readEvents} = require('./cli/scripts/release/events'); + const {reconcileRelease} = require('./cli/scripts/release/reconcile'); + const activeFile = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(activeFile)); + active.trains = (active.trains || []).filter(train => { + if (train.channel !== 'stable') return true; + const ledger = path.join('ledger-branch/events', `v${train.version}.jsonl`); + return !fs.existsSync(ledger) + || !reconcileRelease(materializeReleaseState(readEvents(ledger))).complete; + }); + fs.writeFileSync(activeFile, `${JSON.stringify(active, null, 2)}\n`); + NODE ( cd ledger-branch git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add events + git add events active-versions.json git commit -m "canary evidence: run $GITHUB_RUN_ID" git push origin HEAD:release-ledger ) diff --git a/.github/workflows/release-credential-health.yml b/.github/workflows/release-credential-health.yml new file mode 100644 index 0000000..8d8422c --- /dev/null +++ b/.github/workflows/release-credential-health.yml @@ -0,0 +1,583 @@ +name: Release credential and provider health + +on: + workflow_dispatch: + schedule: + - cron: "17 9 * * *" + +concurrency: + group: release-credential-health + cancel-in-progress: false + +permissions: + contents: read + +jobs: + github-catalog: + name: GitHub App and shared catalog connectivity + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Mint least-privilege cross-repository installation token + id: app + continue-on-error: true + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + permission-actions: write + permission-contents: write + permission-pull-requests: write + permission-issues: write + permission-workflows: write + permission-metadata: read + - name: Record GitHub App and catalog health + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + TOKEN_STEP_OUTCOME: ${{ steps.app.outcome }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'github-app-catalog', + status: 'healthy', + checkedAt: new Date().toISOString(), + checks: {}, + expiry: {status: 'unknown', reason: 'GitHub does not expose private-key age or expiry to an installation token'}, + }; + let stage = 'token_mint'; + try { + if (process.env.TOKEN_STEP_OUTCOME !== 'success' || !process.env.GH_TOKEN) { + throw new Error('token_mint_failed'); + } + evidence.checks.tokenMint = {status: 'healthy'}; + const api = (endpoint, jq) => cp.execFileSync( + 'gh', + ['api', endpoint, ...(jq ? ['--jq', jq] : [])], + {encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe']} + ).trim(); + const apiRaw = endpoint => cp.execFileSync( + 'gh', + ['api', '-H', 'Accept: application/vnd.github.raw+json', endpoint], + {encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe']} + ); + stage = 'guardscan_connectivity'; + if (api('repos/ntanwir10/GuardScan', '.full_name') !== 'ntanwir10/GuardScan') { + throw new Error('guardscan_identity_mismatch'); + } + stage = 'catalog_connectivity'; + if (api('repos/ntanwir10/homebrew-tap', '.full_name') !== 'ntanwir10/homebrew-tap') { + throw new Error('catalog_identity_mismatch'); + } + stage = 'workflow_connectivity'; + const trainWorkflow = apiRaw( + 'repos/ntanwir10/GuardScan/contents/.github/workflows/release-train.yml' + ); + const catalogWorkflow = apiRaw( + 'repos/ntanwir10/homebrew-tap/contents/.github/workflows/verify.yml' + ); + if (!trainWorkflow.includes('*/30 * * * *') + || !trainWorkflow.includes('catalog_updated') + || !catalogWorkflow.includes('channel-lock.json')) { + throw new Error('workflow_contract_invalid'); + } + evidence.checks.repositories = { + status: 'healthy', + repositories: ['ntanwir10/GuardScan', 'ntanwir10/homebrew-tap'], + }; + evidence.checks.requestedPermissions = { + status: 'healthy', + note: 'token mint rejects permissions not granted to the installation', + }; + evidence.checks.catalogWorkflow = {status: 'healthy'}; + evidence.checks.scheduledReconciliation = {status: 'healthy'}; + evidence.checks.exactInstallationSet = { + status: 'unknown', + reason: 'least-privilege repository-scoped token cannot enumerate unrelated App installations', + }; + evidence.checks.catalogBranchProtection = { + status: 'unknown', + reason: 'required-check metadata needs administration read permission not granted to the release App', + }; + evidence.checks.dispatchPermission = { + status: 'unknown', + reason: 'non-publishing monitor does not emit a test dispatch', + }; + } catch { + evidence.status = 'unhealthy'; + evidence.checks.failure = {status: 'unhealthy', code: `${stage}_failed`}; + } + fs.writeFileSync('github-app-catalog-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized GitHub health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-github-app + path: github-app-catalog-health.json + if-no-files-found: error + retention-days: 30 + + trusted-publishers: + name: npm and Python trusted-publisher observability + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Record provider visibility boundaries + run: | + node - <<'NODE' + const fs = require('fs'); + const reason = 'trusted-publisher bindings are not exposed to this non-publishing workflow identity'; + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'trusted-publishers', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + npm: {status: 'unknown', reason, requiredRehearsal: 'candidate preflight'}, + testpypi: {status: 'unknown', reason, requiredRehearsal: 'TestPyPI publish and lifecycle canary'}, + pypi: {status: 'unknown', reason, requiredRehearsal: 'exact release-train OIDC subject'}, + }, + expiry: {status: 'unknown', reason: 'OIDC trusted publishers have no stored release token expiry'}, + }; + fs.writeFileSync('trusted-publishers-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + NODE + - name: Upload sanitized trusted-publisher evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-trusted-publishers + path: trusted-publishers-health.json + if-no-files-found: error + retention-days: 30 + + apple: + name: Apple certificate expiry and notary authentication + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: macos-15 + timeout-minutes: 15 + environment: apple-notarization + steps: + - name: Check certificate and perform read-only notary authentication + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const path = require('path'); + const temp = path.join(process.env.RUNNER_TEMP, 'guardscan-credential-monitor'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'apple-signing-notary', + status: 'unhealthy', + checkedAt: new Date().toISOString(), + checks: {}, + renewal: {status: 'external-authority-required'}, + }; + let stage = 'configuration'; + const run = (command, args) => { + const result = cp.spawnSync(command, args, { + encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0 || result.error) throw new Error(`${stage}_failed`); + return result.stdout.trim(); + }; + try { + const required = [ + 'APPLE_CERTIFICATE_P12', 'APPLE_CERTIFICATE_PASSWORD', 'APPLE_TEAM_ID', + 'APPLE_NOTARY_KEY_ID', 'APPLE_NOTARY_ISSUER_ID', 'APPLE_NOTARY_PRIVATE_KEY', + ]; + if (required.some(name => !process.env[name])) throw new Error('configuration_missing'); + fs.mkdirSync(temp, {recursive: true, mode: 0o700}); + const p12 = path.join(temp, 'certificate.p12'); + const pem = path.join(temp, 'certificate.pem'); + const notaryKey = path.join(temp, 'AuthKey.p8'); + fs.writeFileSync(p12, Buffer.from(process.env.APPLE_CERTIFICATE_P12, 'base64'), {mode: 0o600}); + fs.writeFileSync(notaryKey, process.env.APPLE_NOTARY_PRIVATE_KEY, {mode: 0o600}); + stage = 'certificate_decode'; + run('openssl', [ + 'pkcs12', '-in', p12, '-clcerts', '-nokeys', + '-passin', 'env:APPLE_CERTIFICATE_PASSWORD', '-out', pem, + ]); + stage = 'certificate_identity'; + const subject = run('openssl', ['x509', '-in', pem, '-noout', '-subject', '-nameopt', 'RFC2253']); + const teamMatched = subject.includes(process.env.APPLE_TEAM_ID); + if (!teamMatched) throw new Error('certificate_team_mismatch'); + const notAfter = run('openssl', ['x509', '-in', pem, '-noout', '-enddate']).replace(/^notAfter=/, ''); + const expiryMs = Date.parse(notAfter); + if (!Number.isFinite(expiryMs)) throw new Error('certificate_expiry_invalid'); + const daysRemaining = Math.floor((expiryMs - Date.now()) / 86400000); + const alertThresholds = [60, 30, 14, 7]; + evidence.checks.certificate = { + status: daysRemaining <= 0 ? 'unhealthy' : daysRemaining <= 60 ? 'warning' : 'healthy', + teamMatched, + expiresAt: new Date(expiryMs).toISOString(), + daysRemaining, + alertThresholdDays: alertThresholds, + crossedAlertThresholdDays: alertThresholds.filter(days => daysRemaining <= days), + }; + stage = 'notary_authentication'; + const history = run('xcrun', [ + 'notarytool', 'history', '--key', notaryKey, + '--key-id', process.env.APPLE_NOTARY_KEY_ID, + '--issuer', process.env.APPLE_NOTARY_ISSUER_ID, + '--output-format', 'json', + ]); + JSON.parse(history); + evidence.checks.notaryAuthentication = {status: 'healthy', operation: 'history-read'}; + evidence.status = daysRemaining <= 7 ? 'unhealthy' : daysRemaining <= 60 ? 'warning' : 'healthy'; + if (evidence.status === 'warning') { + console.error(`::warning::Apple signing certificate expires in ${daysRemaining} days`); + } + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } finally { + fs.rmSync(temp, {recursive: true, force: true}); + } + fs.writeFileSync('apple-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Apple health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-apple + path: apple-health.json + if-no-files-found: error + retention-days: 30 + + azure: + name: Azure OIDC and signing endpoint health + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: windows-signing + permissions: + contents: read + id-token: write + steps: + - name: Exchange GitHub OIDC identity with Azure + id: azure-login + continue-on-error: true + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Record Azure identity and non-signing preflight + env: + AZURE_LOGIN_OUTCOME: ${{ steps.azure-login.outcome }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'azure-artifact-signing', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: {}, + expiry: {status: 'unknown', reason: 'Azure OIDC federation has no stored client-secret expiry'}, + }; + let stage = 'oidc_login'; + try { + const required = [ + 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'AZURE_CLIENT_ID', + 'AZURE_SIGNING_ACCOUNT', 'AZURE_SIGNING_PROFILE', 'AZURE_SIGNING_ENDPOINT', + ]; + if (required.some(name => !process.env[name])) throw new Error('configuration_missing'); + if (process.env.AZURE_LOGIN_OUTCOME !== 'success') throw new Error('oidc_login_failed'); + const accountResult = cp.spawnSync('az', ['account', 'show', '--output', 'json'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }); + if (accountResult.status !== 0 || accountResult.error) throw new Error('account_identity_failed'); + const account = JSON.parse(accountResult.stdout); + if (String(account.tenantId).toLowerCase() !== process.env.AZURE_TENANT_ID.toLowerCase() + || String(account.id).toLowerCase() !== process.env.AZURE_SUBSCRIPTION_ID.toLowerCase()) { + throw new Error('account_identity_mismatch'); + } + evidence.checks.oidc = {status: 'healthy'}; + evidence.checks.accountIdentity = {status: 'healthy', tenantMatched: true, subscriptionMatched: true}; + stage = 'signing_endpoint'; + const endpoint = new URL(process.env.AZURE_SIGNING_ENDPOINT); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) { + throw new Error('signing_endpoint_invalid'); + } + const endpointResult = cp.spawnSync('curl', [ + '--silent', '--show-error', '--output', '/dev/null', '--head', + '--max-time', '15', endpoint.toString(), + ], {stdio: ['ignore', 'ignore', 'pipe']}); + if (endpointResult.status !== 0 || endpointResult.error) throw new Error('signing_endpoint_unreachable'); + evidence.checks.endpointConnectivity = {status: 'healthy'}; + evidence.checks.signingProfile = {status: 'unknown', reason: 'configured profile cannot be queried without a management resource identifier and signer-role inspection'}; + evidence.checks.federatedSubject = {status: 'unknown', reason: 'Azure does not expose federated-subject metadata through the configured data-plane coordinates'}; + evidence.checks.signerRole = {status: 'unknown', reason: 'role assignment is not safely inferable from OIDC login alone'}; + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } + fs.writeFileSync('azure-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Azure health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-azure + path: azure-health.json + if-no-files-found: error + retention-days: 30 + + winget: + name: WinGet submitter token identity and expiry metadata + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: winget + steps: + - name: Run read-only WinGet submitter authentication preflight + env: + GH_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'winget-submitter', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + claStatus: {status: 'unknown', reason: 'Microsoft CLA state is not exposed by a non-submitting GitHub API request'}, + submissionPermission: {status: 'unknown', reason: 'write capability is intentionally not exercised by this monitor'}, + }, + expiry: {status: 'unknown', reason: 'GitHub did not expose token-expiration metadata'}, + }; + let stage = 'configuration'; + const run = args => { + const result = cp.spawnSync('gh', args, { + encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0 || result.error) throw new Error(`${stage}_failed`); + return result.stdout; + }; + try { + if (!process.env.GH_TOKEN) throw new Error('configuration_missing'); + stage = 'token_identity'; + const response = run(['api', '--include', 'user']); + const bodyIndex = response.indexOf('{'); + if (bodyIndex < 0) throw new Error('token_identity_invalid'); + const headers = response.slice(0, bodyIndex); + const user = JSON.parse(response.slice(bodyIndex)); + if (user.login !== 'ntanwir10') throw new Error('token_identity_mismatch'); + evidence.checks.tokenIdentity = {status: 'healthy', login: user.login}; + stage = 'package_repository_connectivity'; + const repository = JSON.parse(run(['api', 'repos/microsoft/winget-pkgs'])); + if (repository.full_name !== 'microsoft/winget-pkgs') throw new Error('repository_identity_mismatch'); + evidence.checks.packageRepository = {status: 'healthy'}; + const expiryHeader = /^github-authentication-token-expiration:\s*(.+)$/im.exec(headers); + const scopesHeader = /^x-oauth-scopes:\s*(.*)$/im.exec(headers); + evidence.checks.scopeMetadata = scopesHeader && scopesHeader[1].trim() + ? {status: 'observed', scopes: scopesHeader[1].split(',').map(value => value.trim()).filter(Boolean)} + : {status: 'unknown', reason: 'GitHub did not expose token-scope metadata for this token type'}; + if (expiryHeader) { + const expiryMs = Date.parse(expiryHeader[1].trim()); + if (Number.isFinite(expiryMs)) { + const daysRemaining = Math.floor((expiryMs - Date.now()) / 86400000); + evidence.expiry = { + status: 'known', + expiresAt: new Date(expiryMs).toISOString(), + daysRemaining, + alertThresholdDays: [30, 14, 7], + }; + if (daysRemaining <= 0) evidence.status = 'unhealthy'; + else if (daysRemaining <= 30) evidence.status = 'warning'; + } + } + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } + fs.writeFileSync('winget-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'warning') console.error('::warning::WinGet submitter token is within its expiry alert window'); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized WinGet health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-winget + path: winget-health.json + if-no-files-found: error + retention-days: 30 + + chocolatey: + name: Chocolatey non-publishing provider visibility + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: chocolatey + steps: + - name: Check configuration and public repository connectivity + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'chocolatey-publisher', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + apiKeyAuthentication: {status: 'unknown', reason: 'Chocolatey exposes no documented non-publishing API-key authentication check'}, + accountOwnership: {status: 'unknown', reason: 'publisher ownership is not exposed by the public package feed'}, + }, + expiry: {status: 'unknown', reason: 'Chocolatey does not expose API-key expiry metadata'}, + }; + try { + if (!process.env.CHOCO_API_KEY) throw new Error('configuration_missing'); + evidence.checks.apiKeyConfigured = {status: 'healthy'}; + const result = cp.spawnSync('curl', [ + '--fail', '--silent', '--show-error', '--output', '/dev/null', + '--max-time', '15', 'https://community.chocolatey.org/api/v2/', + ], {stdio: ['ignore', 'ignore', 'pipe']}); + if (result.status !== 0 || result.error) throw new Error('public_repository_unreachable'); + evidence.checks.publicRepository = {status: 'healthy'}; + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : 'provider_check_failed', + }; + } + fs.writeFileSync('chocolatey-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Chocolatey health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-chocolatey + path: chocolatey-health.json + if-no-files-found: error + retention-days: 30 + + report: + name: Aggregate sanitized provider health + if: >- + always() + && (vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + needs: [github-catalog, trusted-publishers, apple, azure, winget, chocolatey] + steps: + - name: Download provider evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: credential-health-* + path: credential-health-evidence + merge-multiple: true + - name: Build machine-readable health report + run: | + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const root = 'credential-health-evidence'; + const providers = fs.readdirSync(root) + .filter(name => name.endsWith('.json')) + .map(name => JSON.parse(fs.readFileSync(path.join(root, name), 'utf8'))) + .sort((left, right) => left.provider.localeCompare(right.provider)); + const expected = [ + 'apple-signing-notary', 'azure-artifact-signing', 'chocolatey-publisher', + 'github-app-catalog', 'trusted-publishers', 'winget-submitter', + ]; + if (providers.length !== expected.length + || providers.some((item, index) => ( + item.schemaVersion !== 'guardscan.credential-health.v1' + || item.provider !== expected[index] + || !['healthy', 'warning', 'unknown', 'unhealthy'].includes(item.status) + ))) { + throw new Error('provider health evidence is missing, duplicated, or malformed'); + } + const statuses = new Set(providers.map(item => item.status)); + const status = statuses.has('unhealthy') ? 'unhealthy' + : statuses.has('warning') ? 'warning' + : statuses.has('unknown') ? 'unknown' : 'healthy'; + const report = { + schemaVersion: 'guardscan.credential-health-report.v1', + status, + checkedAt: new Date().toISOString(), + providers, + }; + fs.writeFileSync('credential-health-report.json', `${JSON.stringify(report, null, 2)}\n`, {mode: 0o600}); + const summary = [ + '## Release credential and provider health', + '', + `Overall: **${status}**`, + '', + '| Provider | Status |', + '| --- | --- |', + ...providers.map(item => `| ${item.provider} | ${item.status} |`), + '', + 'Unknown means the provider does not safely expose that state to this non-publishing workflow.', + ]; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n')}\n`); + NODE + - name: Upload aggregate credential health report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-credential-health-report + path: credential-health-report.json + if-no-files-found: error + retention-days: 30 + - name: Fail closed on unhealthy provider checks + run: | + node -e "const report=require('./credential-health-report.json'); if(report.status==='unhealthy') process.exit(1)" diff --git a/.github/workflows/release-first-withdrawal.yml b/.github/workflows/release-first-withdrawal.yml new file mode 100644 index 0000000..b171c79 --- /dev/null +++ b/.github/workflows/release-first-withdrawal.yml @@ -0,0 +1,424 @@ +name: Withdraw first stable release + +on: + workflow_call: + inputs: + version: + description: Defective first stable version + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-ledger + cancel-in-progress: false + +env: + RELEASE_NODE_VERSION: 22.23.1 + DEFECTIVE_VERSION: ${{ inputs.version }} + +jobs: + withdraw: + name: Withdraw without inventing a known-good baseline + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Validate untrusted request before minting credentials + run: | + node - <<'NODE' + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + if (!stable.test(process.env.DEFECTIVE_VERSION || '')) { + throw new Error('first-release withdrawal version is invalid'); + } + NODE + - name: Create short-lived cross-repository release app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: v${{ inputs.version }} + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + - working-directory: cli + run: npm ci + - name: Verify protected first-release authority + id: authority + run: | + git fetch origin release-ledger + git worktree add ledger-branch origin/release-ledger + LEDGER_COMMIT="$(git -C ledger-branch rev-parse HEAD)" + export LEDGER_COMMIT + node - <<'NODE' + const fs = require('fs'); + const {assertFirstReleaseWithdrawal} = require('./cli/scripts/release/first-release-withdrawal'); + const authority = assertFirstReleaseWithdrawal( + 'ledger-branch', + process.env.DEFECTIVE_VERSION, + process.env.LEDGER_COMMIT + ); + fs.writeFileSync( + 'first-release-authority.json', + `${JSON.stringify(authority, null, 2)}\n` + ); + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `completed=${String(authority.alreadyCompleted)}\n` + ); + NODE + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + cp "ledger-branch/events/$DEFECTIVE_TAG.jsonl" release-events.jsonl + if [ "$(node -p 'String(require("./first-release-authority.json").alreadyCompleted)')" = true ]; then + cp "ledger-branch/recoveries/$DEFECTIVE_TAG-plan.json" rollback-plan.json + cp "ledger-branch/recoveries/$DEFECTIVE_TAG-evidence.json" rollback-evidence.json + fi + - name: Generate and persist the first-release withdrawal start + if: steps.authority.outputs.completed != 'true' + env: + RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + run: | + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION" + export ROLLBACK_KEY + ROLLBACK_TIMESTAMP="$(node - <<'NODE' + const {readEvents} = require('./cli/scripts/release/events'); + const existing = readEvents('release-events.jsonl') + .find(event => event.idempotencyKey === process.env.ROLLBACK_KEY); + process.stdout.write(existing?.timestamp || new Date().toISOString()); + NODE + )" + node cli/scripts/release/index.js rollback \ + --ledger release-events.jsonl \ + --timestamp "$ROLLBACK_TIMESTAMP" \ + --idempotency-key "$ROLLBACK_KEY" \ + --tag "$DEFECTIVE_TAG" \ + --first-release-withdrawal \ + --first-release-authority first-release-authority.json \ + > rollback-plan.json + node - <<'NODE' + const plan = require('./rollback-plan.json'); + if (plan.schemaVersion !== 'guardscan.rollback-plan.v1' + || plan.mode !== 'first-release-withdrawal' + || plan.version !== process.env.DEFECTIVE_VERSION + || plan.knownGood !== undefined + || plan.forwardFixBranch !== undefined) { + throw new Error('generated first-release withdrawal plan is invalid'); + } + const {readEvents} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + if (!events.some(event => ( + event.type === 'incident_opened' + && event.payload.kind === 'recovery' + ))) { + throw new Error('first-release withdrawal must retain an open recovery incident'); + } + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + if (!events.some(event => ( + event.type === 'action_required' && event.channel === action.channel + ))) { + throw new Error(`withdrawal ledger is missing external authority action for ${action.channel}`); + } + } + NODE + mkdir -p ledger-branch/events + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" + ( + cd ledger-branch + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add "events/$DEFECTIVE_TAG.jsonl" + git commit -m "first release withdrawal started: $DEFECTIVE_TAG" || exit 0 + git push origin HEAD:release-ledger + ) + - name: Remove or verify the exact first-release catalog projection + id: catalog + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + ALREADY_COMPLETED: ${{ steps.authority.outputs.completed }} + run: | + CATALOG_BRANCH="rollback/v$DEFECTIVE_VERSION-remove-first-release" + PUBLISH_BRANCH="guardscan/v$DEFECTIVE_VERSION" + gh repo clone ntanwir10/homebrew-tap catalog-withdrawal + git -C catalog-withdrawal fetch origin main + CATALOG_BASE="$(git -C catalog-withdrawal rev-parse origin/main)" + PUBLISH_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$PUBLISH_BRANCH" --base main --state open --limit 20 \ + --json number,headRefName,baseRefName,headRepository)" + export PUBLISH_PRS PUBLISH_BRANCH + node - <<'NODE' + const prs = JSON.parse(process.env.PUBLISH_PRS || '[]'); + if (prs.length > 1 || prs.some(pr => ( + pr.headRefName !== process.env.PUBLISH_BRANCH + || pr.baseRefName !== 'main' + || pr.headRepository?.nameWithOwner?.toLowerCase() !== 'ntanwir10/homebrew-tap' + ))) { + throw new Error('catalog publication pull request identity is ambiguous'); + } + NODE + PUBLISH_PR="$(node -p 'String(JSON.parse(process.env.PUBLISH_PRS || "[]")[0]?.number || "")')" + if [ -n "$PUBLISH_PR" ]; then + gh pr close --repo ntanwir10/homebrew-tap "$PUBLISH_PR" \ + --comment "Closed by the protected first-release withdrawal for v$DEFECTIVE_VERSION." + fi + test "$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$PUBLISH_BRANCH" --base main --state open --limit 1 \ + --json number --jq length)" = 0 + if [ "$ALREADY_COMPLETED" = true ]; then + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_BASE:$CATALOG_PATH" 2>/dev/null; then + echo "completed withdrawal was republished at $CATALOG_PATH" >&2 + exit 1 + fi + done + CATALOG_STATE="$(node -p 'require("./rollback-evidence.json").catalog.state')" + CATALOG_BRANCH="$(node -p 'require("./rollback-evidence.json").catalog.branch')" + CATALOG_PR="$(node -p 'String(require("./rollback-evidence.json").catalog.pullRequest)')" + CATALOG_MERGE_SHA="$(node -p 'require("./rollback-evidence.json").catalog.commit')" + git -C catalog-withdrawal fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_MERGE_SHA:$CATALOG_PATH" 2>/dev/null; then + echo "stored withdrawal evidence contains $CATALOG_PATH" >&2 + exit 1 + fi + done + echo "state=$CATALOG_STATE" >> "$GITHUB_OUTPUT" + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + exit 0 + fi + CATALOG_REMOTE_HEAD="" + if git -C catalog-withdrawal fetch origin \ + "refs/heads/$CATALOG_BRANCH:refs/remotes/origin/$CATALOG_BRANCH"; then + CATALOG_REMOTE_HEAD="$(git -C catalog-withdrawal rev-parse "refs/remotes/origin/$CATALOG_BRANCH")" + fi + git -C catalog-withdrawal switch -c "$CATALOG_BRANCH" "$CATALOG_BASE" + export DEFECTIVE_VERSION + node - <<'NODE' + const fs = require('fs'); + const {prepareFirstReleaseCatalogWithdrawal} = require('./cli/scripts/release/first-release-withdrawal'); + const result = prepareFirstReleaseCatalogWithdrawal( + 'catalog-withdrawal', + process.env.DEFECTIVE_VERSION, + require('./first-release-authority.json').defectiveCommit + ); + fs.writeFileSync('catalog-withdrawal.json', `${JSON.stringify(result, null, 2)}\n`); + NODE + git -C catalog-withdrawal add -A -- \ + Formula/guardscan.rb bucket/guardscan.json channel-lock.json + CATALOG_CHANGED_FILES="$(git -C catalog-withdrawal diff --cached --name-status)" + if [ -z "$CATALOG_CHANGED_FILES" ]; then + test "$(node -p 'require("./catalog-withdrawal.json").state')" = already-absent + CATALOG_PR=0 + CATALOG_MERGE_SHA="$CATALOG_BASE" + CATALOG_STATE=already-absent + else + export CATALOG_CHANGED_FILES + node - <<'NODE' + const rows = process.env.CATALOG_CHANGED_FILES.split('\n').filter(Boolean).sort(); + const expected = [ + 'D\tFormula/guardscan.rb', + 'D\tbucket/guardscan.json', + 'D\tchannel-lock.json', + ]; + if (JSON.stringify(rows) !== JSON.stringify(expected)) { + throw new Error(`catalog withdrawal contains unreviewed changes: ${rows.join(', ')}`); + } + NODE + git -C catalog-withdrawal config user.name guardscan-release-bot + git -C catalog-withdrawal config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-withdrawal commit \ + -m "withdraw GuardScan v$DEFECTIVE_VERSION first-release catalog" + CATALOG_HEAD="$(git -C catalog-withdrawal rev-parse HEAD)" + if [ -n "$CATALOG_REMOTE_HEAD" ]; then + git -C catalog-withdrawal push \ + --force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$CATALOG_BRANCH" + else + git -C catalog-withdrawal push origin "HEAD:refs/heads/$CATALOG_BRANCH" + fi + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const matches = prs.filter(pr => pr.baseRefName === 'main'); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog withdrawal branch has a closed-unmerged pull request'); + } + const bound = matches.find(pr => ( + ['OPEN', 'MERGED'].includes(pr.state) + && pr.headRefOid === process.env.CATALOG_HEAD + && pr.headRepository?.nameWithOwner?.toLowerCase() === 'ntanwir10/homebrew-tap' + )); + process.stdout.write(String(bound?.number || '')); + NODE + )" + if [ -z "$CATALOG_PR" ]; then + gh pr create \ + --repo ntanwir10/homebrew-tap \ + --base main \ + --head "$CATALOG_BRANCH" \ + --title "Withdraw GuardScan v$DEFECTIVE_VERSION first-release catalog" \ + --body "Removes only the generated first-party listings for defective v$DEFECTIVE_VERSION. The immutable release remains auditable." + CATALOG_PR="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state open --limit 1 --json number --jq '.[0].number')" + fi + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + if [ "$CATALOG_PR_STATE" = OPEN ]; then + gh pr checks --repo ntanwir10/homebrew-tap "$CATALOG_PR" --watch --fail-fast + gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --squash --match-head-commit "$CATALOG_HEAD" + fi + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + test "$CATALOG_PR_STATE" = MERGED + CATALOG_MERGE_SHA="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --json mergeCommit --jq .mergeCommit.oid)" + CATALOG_STATE=removed + fi + case "$CATALOG_MERGE_SHA" in + ''|*[!a-f0-9]*) echo "catalog withdrawal has no trusted commit" >&2; exit 1 ;; + esac + test "${#CATALOG_MERGE_SHA}" = 40 + git -C catalog-withdrawal fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_MERGE_SHA:$CATALOG_PATH" 2>/dev/null; then + echo "catalog withdrawal left $CATALOG_PATH published" >&2 + exit 1 + fi + done + echo "state=$CATALOG_STATE" >> "$GITHUB_OUTPUT" + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + - name: Complete repository withdrawal and deactivate the train + if: steps.authority.outputs.completed != 'true' + env: + CATALOG_STATE: ${{ steps.catalog.outputs.state }} + CATALOG_BRANCH: ${{ steps.catalog.outputs.branch }} + CATALOG_PR: ${{ steps.catalog.outputs.pull_request }} + CATALOG_COMMIT: ${{ steps.catalog.outputs.commit }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const plan = require('./rollback-plan.json'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + if (state.recovery?.mode !== 'first-release-withdrawal' + || !Object.values(state.incidents).some(incident => ( + incident.kind === 'recovery' && incident.status === 'open' + ))) { + throw new Error('first-release withdrawal lost its protected recovery state'); + } + const now = new Date().toISOString(); + const externalActionsPending = plan.actions + .filter(action => action.automation === 'external-action-required') + .map(action => action.channel) + .sort(); + const evidence = { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: process.env.DEFECTIVE_VERSION, + requiredNextVersion: state.recovery.requiredNextVersion, + externalActionsPending, + catalog: { + state: process.env.CATALOG_STATE, + branch: process.env.CATALOG_BRANCH, + pullRequest: Number(process.env.CATALOG_PR), + commit: process.env.CATALOG_COMMIT, + }, + completedAt: now, + }; + fs.writeFileSync('rollback-evidence.json', `${JSON.stringify(evidence, null, 2)}\n`); + const terminal = { + github: 'superseded', + pnpm: 'superseded', + yarn: 'superseded', + bun: 'superseded', + homebrew: 'withdrawn', + scoop: 'withdrawn', + }; + for (const [channel, channelState] of Object.entries(state.channels)) { + const type = channelState.status === 'planned' + || (channel === 'github' && !channelState.publication) + ? 'withdrawn' + : terminal[channel]; + if (!type) continue; + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: now, + type, + channel, + idempotencyKey: `first-release-withdrawal:${channel}:${state.version}`, + payload: { + artifactIds: channelState.artifactIds, + ...(channelState.remoteIdentity ? {remoteIdentity: channelState.remoteIdentity} : {}), + ...(channelState.remoteDigest ? {remoteDigest: channelState.remoteDigest} : {}), + }, + }); + } + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: now, + type: 'rollback_repository_completed', + idempotencyKey: `rollback-repository-completed:${state.version}`, + payload: evidence, + }); + const completed = materializeReleaseState(readEvents('release-events.jsonl')); + for (const [channel, channelState] of Object.entries(completed.channels)) { + if (!externalActionsPending.includes(channel) + && !['withdrawn', 'superseded'].includes(channelState.status)) { + throw new Error(`repository withdrawal left ${channel} non-terminal`); + } + } + const expectedRecoveryStatus = externalActionsPending.length > 0 + ? 'provider-actions-pending' + : 'repository-completed'; + if (completed.recovery?.status !== expectedRecoveryStatus) { + throw new Error('repository withdrawal completion state is inconsistent'); + } + NODE + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" + mkdir -p ledger-branch/recoveries + cp rollback-plan.json "ledger-branch/recoveries/$DEFECTIVE_TAG-plan.json" + cp rollback-evidence.json "ledger-branch/recoveries/$DEFECTIVE_TAG-evidence.json" + node - <<'NODE' + const fs = require('fs'); + const file = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(file, 'utf8')); + active.trains = active.trains.filter( + train => train.version !== process.env.DEFECTIVE_VERSION + ); + fs.writeFileSync(file, `${JSON.stringify(active, null, 2)}\n`); + NODE + ( + cd ledger-branch + git add events active-versions.json recoveries + git commit -m "first release repository withdrawal complete: v$DEFECTIVE_VERSION" || exit 0 + git push origin HEAD:release-ledger + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rollback-plan-v${{ inputs.version }} + path: | + first-release-authority.json + rollback-plan.json + rollback-evidence.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 34e6572..4d7df28 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,7 +15,7 @@ permissions: jobs: release-pr: name: Update release pull request - if: ${{ vars.RELEASE_APP_ID != '' }} + if: ${{ vars.RELEASE_AUTOMATION_ENABLED == 'true' && vars.RELEASE_APP_ID != '' }} runs-on: ubuntu-latest steps: - name: Create short-lived release app token diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 6fec18c..b311858 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -18,15 +18,23 @@ permissions: env: RELEASE_NODE_VERSION: 22.23.1 + RELEASE_NPM_VERSION: 11.5.2 + RELEASE_WINGETCREATE_VERSION: 1.12.13.0 + RELEASE_WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea jobs: github: name: Immutable GitHub release + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 environment: ${{ inputs.channel == 'rc' && 'release-rc' || 'release-stable' }} permissions: contents: write steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-payload-${{ inputs.tag }} @@ -81,9 +89,32 @@ jobs: (cd redownload && sha256sum --check SHA256SUMS) cosign verify-blob \ --bundle redownload/SHA256SUMS.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ redownload/SHA256SUMS + - name: Bind the exact GitHub release asset set to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const files = {}; + for (const entry of fs.readdirSync('redownload', {withFileTypes: true})) { + if (!entry.isFile()) throw new Error('GitHub release evidence contains a non-file'); + files[entry.name] = crypto.createHash('sha256') + .update(fs.readFileSync(path.join('redownload', entry.name))).digest('hex'); + } + writePublicationEvidence('github-publication-evidence.json', { + channel: 'github', + version: process.env.RELEASE_TAG.slice(1), + tag: process.env.RELEASE_TAG, + remoteIdentity: `github:${process.env.GITHUB_REPOSITORY}/releases/tag/${process.env.RELEASE_TAG}`, + files, + }); + NODE - name: Publish verified draft env: GH_TOKEN: ${{ github.token }} @@ -91,9 +122,16 @@ jobs: if [ "$(gh release view "${{ inputs.tag }}" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)" = true ]; then gh release edit "${{ inputs.tag }}" --repo "${{ github.repository }}" --draft=false fi + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-github-evidence-${{ inputs.tag }} + path: github-publication-evidence.json + if-no-files-found: error + retention-days: 30 npm: name: npm trusted publication + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] environment: npm-publish @@ -113,6 +151,10 @@ jobs: with: node-version: ${{ env.RELEASE_NODE_VERSION }} registry-url: https://registry.npmjs.org + - name: Install trusted-publishing-capable npm CLI + run: | + npm install --global "npm@${RELEASE_NPM_VERSION}" + test "$(npm --version)" = "$RELEASE_NPM_VERSION" - working-directory: cli run: npm ci - name: Verify and classify registry state @@ -130,9 +172,32 @@ jobs: - name: Verify public registry integrity working-directory: cli run: npm run release:npm-preflight -- --artifact-dir ../npm-artifact + - name: Bind the exact npm tarball to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const artifact = JSON.parse(fs.readFileSync('npm-artifact/npm-artifact.json')); + writePublicationEvidence('npm-publication-evidence.json', { + channel: 'npm', + version: process.env.RELEASE_TAG.slice(1), + tag: process.env.RELEASE_TAG, + remoteIdentity: `npm:${artifact.packageName}@${artifact.version}`, + files: {[artifact.filename]: artifact.sha256}, + }); + NODE + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-npm-evidence-${{ inputs.tag }} + path: npm-publication-evidence.json + if-no-files-found: error + retention-days: 30 pypi-test: - name: TestPyPI trusted publication and lifecycle + name: TestPyPI trusted publication + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] environment: pypi @@ -140,14 +205,18 @@ jobs: contents: read id-token: write steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-payload-${{ inputs.tag }} path: payload - - name: Select Linux x64 wheel + - name: Select every tested platform wheel run: | mkdir dist - cp payload/*manylinux_2_28_x86_64.whl dist/ + cp payload/*.whl dist/ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" @@ -169,10 +238,16 @@ jobs: raise remote = {"urls": []} remote_files = {item["filename"]: item["digests"]["sha256"] for item in remote["urls"]} - for wheel in pathlib.Path("dist").glob("*.whl"): - digest = hashlib.sha256(wheel.read_bytes()).hexdigest() - if wheel.name in remote_files and remote_files[wheel.name] != digest: - raise SystemExit(f"TestPyPI integrity conflict for {wheel.name}") + local_files = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + unexpected = sorted(set(remote_files) - set(local_files)) + if unexpected: + raise SystemExit(f"TestPyPI has unexpected files for this version: {unexpected}") + for filename, digest in local_files.items(): + if filename in remote_files and remote_files[filename] != digest: + raise SystemExit(f"TestPyPI integrity conflict for {filename}") PY - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: @@ -180,30 +255,121 @@ jobs: packages-dir: dist skip-existing: true print-hash: true - - name: Convert release tag to PEP 440 - id: version - shell: bash + - name: Verify complete TestPyPI file set + env: + REGISTRY_JSON_BASE: https://test.pypi.org/pypi/guardscan-cli + RELEASE_VERSION: ${{ inputs.tag }} run: | - VERSION="${{ inputs.tag }}" - VERSION="${VERSION#v}" - VERSION="${VERSION/-rc./rc}" - echo "value=$VERSION" >> "$GITHUB_OUTPUT" - - name: Test TestPyPI wheel through pip and pipx + python - <<'PY' + import hashlib, json, os, pathlib, time, urllib.error, urllib.request + + version = os.environ["RELEASE_VERSION"].removeprefix("v").replace("-rc.", "rc") + url = f'{os.environ["REGISTRY_JSON_BASE"]}/{version}/json' + local = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + for attempt in range(30): + try: + with urllib.request.urlopen(url, timeout=30) as response: + remote = { + item["filename"]: item["digests"]["sha256"] + for item in json.load(response)["urls"] + } + except urllib.error.HTTPError as error: + if error.code != 404: + raise + remote = {} + if remote == local: + break + if set(remote) - set(local): + raise SystemExit("TestPyPI returned unexpected files for this version") + if any(local.get(name) != digest for name, digest in remote.items()): + raise SystemExit("TestPyPI returned a conflicting wheel digest") + if attempt == 29: + raise SystemExit("TestPyPI did not converge to the complete tested wheel set") + time.sleep(10) + PY + + pypi-test-lifecycle: + name: TestPyPI lifecycle on ${{ matrix.target.id }} + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' + needs: [pypi-test] + runs-on: ${{ matrix.target.runner }} + strategy: + fail-fast: false + matrix: + target: + - id: linux-x64-glibc + runner: ubuntu-24.04 + - id: linux-arm64-glibc + runner: ubuntu-24.04-arm + - id: darwin-arm64 + runner: macos-15 + - id: darwin-x64 + runner: macos-15-intel + - id: windows-x64 + runner: windows-2025 + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - name: Test TestPyPI wheel through pip and pipx on ${{ matrix.target.id }} + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} run: | + VERSION="${RELEASE_TAG#v}" + PYPI_VERSION="${VERSION/-rc./rc}" + exercise_guardscan() { + executable="$1" + label="$2" + project="$PWD/testpypi-$label" + rm -rf "$project" + mkdir -p "$project" + printf '{"name":"guardscan-testpypi","version":"1.0.0","private":true}\n' \ + > "$project/package.json" + printf 'module.exports = 42;\n' > "$project/index.js" + ( + cd "$project" + test "$("$executable" --version | tr -d '\r')" = "$VERSION" + "$executable" --help > help.txt + grep -q scan help.txt + "$executable" --no-telemetry scan --offline --no-cve --skip-tests --skip-ai \ + --format json --output scan.json + python - <<'PY' + import json + scan = json.load(open('scan.json', encoding='utf-8')) + if ( + scan.get('schemaVersion') != 'guardscan.scan.v1' + or scan.get('run', {}).get('executionMode') != 'static-analysis' + or scan.get('run', {}).get('offline') is not True + ): + raise SystemExit('TestPyPI offline scan contract failed') + PY + ) + } + python -m pip install --index-url https://test.pypi.org/simple/ \ - --no-deps "guardscan-cli==${{ steps.version.outputs.value }}" - guardscan --version + --no-deps "guardscan-cli==$PYPI_VERSION" + exercise_guardscan guardscan pip python -m pip uninstall -y guardscan-cli + python -m pip install pipx python -m pipx install --index-url https://test.pypi.org/simple/ \ - --pip-args=--no-deps "guardscan-cli==${{ steps.version.outputs.value }}" - "$HOME/.local/bin/guardscan" --version + --pip-args=--no-deps "guardscan-cli==$PYPI_VERSION" + PIPX_BIN_DIR="$(python -m pipx environment --value PIPX_BIN_DIR)" + GUARDSCAN="$PIPX_BIN_DIR/guardscan" + if [ -f "$GUARDSCAN.exe" ]; then GUARDSCAN="$GUARDSCAN.exe"; fi + exercise_guardscan "$GUARDSCAN" pipx + python -m pipx runpip guardscan-cli show guardscan-cli python -m pipx uninstall guardscan-cli pypi: name: PyPI trusted publication + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 - needs: [pypi-test] + needs: [pypi-test-lifecycle] environment: pypi permissions: contents: read @@ -236,19 +402,91 @@ jobs: raise remote = {"urls": []} remote_files = {item["filename"]: item["digests"]["sha256"] for item in remote["urls"]} - for wheel in pathlib.Path("dist").glob("*.whl"): - digest = hashlib.sha256(wheel.read_bytes()).hexdigest() - if wheel.name in remote_files and remote_files[wheel.name] != digest: - raise SystemExit(f"PyPI integrity conflict for {wheel.name}") + local_files = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + unexpected = sorted(set(remote_files) - set(local_files)) + if unexpected: + raise SystemExit(f"PyPI has unexpected files for this version: {unexpected}") + for filename, digest in local_files.items(): + if filename in remote_files and remote_files[filename] != digest: + raise SystemExit(f"PyPI integrity conflict for {filename}") PY - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: packages-dir: dist skip-existing: true print-hash: true + - name: Verify complete PyPI file set + env: + REGISTRY_JSON_BASE: https://pypi.org/pypi/guardscan-cli + RELEASE_VERSION: ${{ inputs.tag }} + run: | + python - <<'PY' + import hashlib, json, os, pathlib, time, urllib.error, urllib.request + + version = os.environ["RELEASE_VERSION"].removeprefix("v").replace("-rc.", "rc") + url = f'{os.environ["REGISTRY_JSON_BASE"]}/{version}/json' + local = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + for attempt in range(30): + try: + with urllib.request.urlopen(url, timeout=30) as response: + remote = { + item["filename"]: item["digests"]["sha256"] + for item in json.load(response)["urls"] + } + except urllib.error.HTTPError as error: + if error.code != 404: + raise + remote = {} + if remote == local: + break + if set(remote) - set(local): + raise SystemExit("PyPI returned unexpected files for this version") + if any(local.get(name) != digest for name, digest in remote.items()): + raise SystemExit("PyPI returned a conflicting wheel digest") + if attempt == 29: + raise SystemExit("PyPI did not converge to the complete tested wheel set") + time.sleep(10) + PY + - name: Bind the exact PyPI wheel set to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const files = {}; + for (const entry of fs.readdirSync('dist', {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith('.whl')) continue; + files[entry.name] = crypto.createHash('sha256') + .update(fs.readFileSync(path.join('dist', entry.name))).digest('hex'); + } + const version = process.env.RELEASE_TAG.slice(1); + writePublicationEvidence('pypi-publication-evidence.json', { + channel: 'pypi', + version, + tag: process.env.RELEASE_TAG, + remoteIdentity: `pypi:guardscan-cli@${version.replace('-rc.', 'rc')}`, + files, + }); + NODE + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-pypi-evidence-${{ inputs.tag }} + path: pypi-publication-evidence.json + if-no-files-found: error + retention-days: 30 - adapters: - name: Homebrew and Scoop bot PRs + catalog: + name: Shared Homebrew and Scoop catalog projection + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] permissions: @@ -261,7 +499,7 @@ jobs: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ntanwir10 - repositories: homebrew-tap,scoop-bucket + repositories: homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ inputs.ref }} @@ -274,55 +512,168 @@ jobs: node-version: ${{ env.RELEASE_NODE_VERSION }} - working-directory: cli run: npm ci - - working-directory: cli - run: npm run release:render -- --manifest ../payload/release-manifest.json --output-dir ../adapters - - name: Push deterministic adapter branches and open PRs + - name: Render the cryptographically bound shared catalog projection + working-directory: cli + env: + RELEASE_REF: ${{ inputs.ref }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + MANIFEST_SHA256="$(sha256sum ../payload/release-manifest.json | cut -d ' ' -f 1)" + node scripts/release/index.js catalog \ + --manifest ../payload/release-manifest.json \ + --output-dir ../catalog-output \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$RELEASE_REF" + - name: Validate complete generated catalog projection + run: | + test -f catalog-output/Formula/guardscan.rb + test -f catalog-output/bucket/guardscan.json + test -f catalog-output/channel-lock.json + test -f catalog/homebrew-tap/.github/workflows/verify.yml + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lock = JSON.parse(fs.readFileSync('catalog-output/channel-lock.json')); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1') { + throw new Error('unexpected channel catalog schema'); + } + const expected = new Map([ + ['Formula/guardscan.rb', 'catalog-output/Formula/guardscan.rb'], + ['bucket/guardscan.json', 'catalog-output/bucket/guardscan.json'], + ]); + for (const [target, source] of expected) { + const digest = crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex'); + if (lock.files?.[target]?.sha256 !== digest) { + throw new Error(`channel-lock digest mismatch for ${target}`); + } + } + NODE + - name: Push one deterministic catalog update env: GH_TOKEN: ${{ steps.app.outputs.token }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_TAG: ${{ inputs.tag }} run: | - BRANCH="guardscan/${{ inputs.tag }}" - for SPEC in "homebrew-tap:adapters/homebrew/Formula/guardscan.rb:Formula/guardscan.rb" \ - "scoop-bucket:adapters/scoop/bucket/guardscan.json:bucket/guardscan.json"; do - IFS=: read -r REPO SOURCE TARGET <<< "$SPEC" - gh repo clone "ntanwir10/$REPO" "$REPO" - ( - cd "$REPO" - git config user.name guardscan-release-bot - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then - git switch --detach "origin/$BRANCH" - if [ ! -f "$TARGET" ] || ! cmp --silent "../$SOURCE" "$TARGET"; then - echo "::error::Release-integrity incident: $REPO/$BRANCH differs from the rendered adapter" - exit 1 + CATALOG_REPOSITORY="ntanwir10/homebrew-tap" + if [ "$RELEASE_CHANNEL" = rc ]; then + BRANCH="channel-preview/$RELEASE_TAG" + else + BRANCH="guardscan/$RELEASE_TAG" + fi + gh repo clone "$CATALOG_REPOSITORY" catalog-checkout + ( + cd catalog-checkout + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + projection_matches() { + for SPEC in \ + "../catalog-output/Formula/guardscan.rb:Formula/guardscan.rb" \ + "../catalog-output/bucket/guardscan.json:bucket/guardscan.json" \ + "../catalog-output/channel-lock.json:channel-lock.json" \ + "../catalog/homebrew-tap/.github/workflows/verify.yml:.github/workflows/verify.yml"; do + IFS=: read -r SOURCE TARGET <<< "$SPEC" + if [ ! -f "$TARGET" ] || ! cmp --silent "$SOURCE" "$TARGET"; then + return 1 fi - else - git switch -c "$BRANCH" - mkdir -p "$(dirname "$TARGET")" - cp "../$SOURCE" "$TARGET" - git add "$TARGET" - git commit -m "guardscan ${{ inputs.tag }}" - git push origin "$BRANCH" + done + } + MAIN_ALREADY_CURRENT=false + if [ "$RELEASE_CHANNEL" = stable ] && projection_matches; then + MAIN_ALREADY_CURRENT=true + BRANCH=main + elif [ "$RELEASE_CHANNEL" = stable ] && [ -f channel-lock.json ]; then + EXISTING_TAG="$(node -p 'require("./channel-lock.json").source?.tag || ""')" + if [ "$EXISTING_TAG" = "$RELEASE_TAG" ]; then + echo "::error::Release-integrity incident: main has this release identity with different catalog bytes" + exit 1 + fi + fi + if [ "$MAIN_ALREADY_CURRENT" = true ]; then + PR_NUMBER= + PR_STATE=MERGED + elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then + git switch --detach "origin/$BRANCH" + if ! projection_matches; then + echo "::error::Release-integrity incident: $CATALOG_REPOSITORY@$BRANCH differs" + exit 1 fi - PR_NUMBER="$(gh pr list --repo "ntanwir10/$REPO" \ + else + git switch -c "$BRANCH" + mkdir -p Formula bucket .github/workflows + cp ../catalog-output/Formula/guardscan.rb Formula/guardscan.rb + cp ../catalog-output/bucket/guardscan.json bucket/guardscan.json + cp ../catalog-output/channel-lock.json channel-lock.json + cp ../catalog/homebrew-tap/.github/workflows/verify.yml .github/workflows/verify.yml + git add Formula/guardscan.rb bucket/guardscan.json channel-lock.json .github/workflows/verify.yml + git commit -m "guardscan $RELEASE_TAG" + git push origin "$BRANCH" + fi + CATALOG_COMMIT="$(git rev-parse HEAD)" + if [ "$MAIN_ALREADY_CURRENT" = true ]; then + PR_NUMBER="$(gh api \ + "repos/$CATALOG_REPOSITORY/commits/$CATALOG_COMMIT/pulls" \ + --jq '.[0].number')" + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ + --search "\"GuardScan $RELEASE_TAG\" in:title" \ + --state merged --limit 1 --json number --jq '.[0].number')" + fi + if [ -z "$PR_NUMBER" ]; then + echo "::error::Current catalog bytes have no merged GuardScan projection PR" + exit 1 + fi + PR_STATE=MERGED + else + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ --head "$BRANCH" --state all --limit 1 --json number --jq '.[0].number')" if [ -z "$PR_NUMBER" ]; then - gh pr create --repo "ntanwir10/$REPO" \ - --base main --head "$BRANCH" \ - --title "GuardScan ${{ inputs.tag }}" \ - --body "Generated from immutable release-manifest.json." - PR_NUMBER="$(gh pr list --repo "ntanwir10/$REPO" \ + PR_ARGS=( + --repo "$CATALOG_REPOSITORY" + --base main + --head "$BRANCH" + --title "GuardScan $RELEASE_TAG" + --body "Generated from GuardScan's immutable release manifest and bound by channel-lock.json." + ) + if [ "$RELEASE_CHANNEL" = rc ]; then PR_ARGS+=(--draft); fi + gh pr create "${PR_ARGS[@]}" + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ --head "$BRANCH" --state all --limit 1 --json number --jq '.[0].number')" fi - PR_STATE="$(gh pr view --repo "ntanwir10/$REPO" "$PR_NUMBER" --json state --jq .state)" - if [ "${{ inputs.channel }}" = stable ] && [ "$PR_STATE" != MERGED ]; then - gh pr merge --repo "ntanwir10/$REPO" "$PR_NUMBER" --auto --squash + PR_STATE="$(gh pr view --repo "$CATALOG_REPOSITORY" "$PR_NUMBER" --json state --jq .state)" + if [ "$RELEASE_CHANNEL" = stable ] && [ "$PR_STATE" != MERGED ]; then + gh pr merge --repo "$CATALOG_REPOSITORY" "$PR_NUMBER" --auto --squash fi - ) - done + fi + export CATALOG_REPOSITORY BRANCH CATALOG_COMMIT PR_NUMBER PR_STATE + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lock = fs.readFileSync('channel-lock.json'); + fs.writeFileSync('../catalog-result.json', `${JSON.stringify({ + schemaVersion: 'guardscan.catalog-publication.v1', + repository: process.env.CATALOG_REPOSITORY, + branch: process.env.BRANCH, + commit: process.env.CATALOG_COMMIT, + pullRequest: process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : null, + state: process.env.PR_STATE, + lockSha256: crypto.createHash('sha256').update(lock).digest('hex'), + }, null, 2)}\n`); + NODE + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-catalog-${{ inputs.tag }} + path: | + catalog-output/channel-lock.json + catalog-result.json + if-no-files-found: error + retention-days: 30 winget: name: WinGet submission - if: inputs.channel == 'stable' + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && inputs.channel == 'stable' runs-on: windows-2025 needs: [github] environment: winget @@ -344,6 +695,9 @@ jobs: - name: Validate and install WinGet local manifest shell: pwsh run: | + if ($null -eq (Get-Command winget -ErrorAction SilentlyContinue)) { + throw 'WinGet is unavailable on the selected Windows runner' + } $installer = Get-ChildItem adapters\winget -Filter '*.installer.yaml' -Recurse | Select-Object -First 1 if ($null -eq $installer) { throw 'Rendered WinGet installer manifest is missing' } $manifestDirectory = $installer.Directory.FullName @@ -351,22 +705,206 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'WinGet manifest validation failed' } winget install --manifest $manifestDirectory --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest invocation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ inputs.tag }}'.TrimStart('v')) { + throw 'WinGet local manifest invocation or version check failed' + } winget uninstall --exact --id NaumanTanwir.GuardScan if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest uninstall failed' } - - name: Submit WinGet manifests + - name: Preflight and submit WinGet manifests with exact evidence shell: pwsh env: + GH_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.tag }} WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | - dotnet tool install --global wingetcreate - if ($LASTEXITCODE -ne 0) { throw 'wingetcreate installation failed' } - wingetcreate submit adapters\winget --token $env:WINGET_GITHUB_TOKEN + $ErrorActionPreference = 'Stop' + $version = $env:RELEASE_TAG.TrimStart('v') + $packageIdentity = "NaumanTanwir.GuardScan@$version" + $manifestPath = "manifests/n/NaumanTanwir/GuardScan/$version" + $installer = Get-ChildItem adapters\winget -Filter '*.installer.yaml' -Recurse | Select-Object -First 1 + if ($null -eq $installer) { throw 'Rendered WinGet installer manifest is missing' } + $manifestDirectory = $installer.Directory.FullName + $localFiles = [ordered]@{} + Get-ChildItem $manifestDirectory -File -Filter '*.yaml' | Sort-Object Name | ForEach-Object { + $localFiles[$_.Name] = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + if ($localFiles.Count -ne 3) { throw 'WinGet evidence requires exactly three rendered manifests' } + $digestInput = ($localFiles.GetEnumerator() | ForEach-Object { "$($_.Key)`0$($_.Value)`n" }) -join '' + [IO.File]::WriteAllText('winget-digest-input.txt', $digestInput, [Text.UTF8Encoding]::new($false)) + $manifestDigest = (Get-FileHash winget-digest-input.txt -Algorithm SHA256).Hash.ToLowerInvariant() + + function Write-WinGetEvidence($state, $remoteIdentity, $pullRequest, $commit, $publicBytesVerified, $reason = $null) { + $evidence = [ordered]@{ + schemaVersion = 'guardscan.moderated-submission.v1' + channel = 'winget' + version = $version + tag = $env:RELEASE_TAG + state = $state + packageIdentity = $packageIdentity + remoteIdentity = $remoteIdentity + remoteDigest = $manifestDigest + files = $localFiles + provider = [ordered]@{ + repository = 'microsoft/winget-pkgs' + path = $manifestPath + pullRequest = $pullRequest + commit = $commit + publicBytesVerified = $publicBytesVerified + pendingStateQuery = 'digest-bound open pull request, then protected release ledger' + } + checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + if (-not [string]::IsNullOrWhiteSpace($reason)) { $evidence.reason = $reason } + $evidence | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 + } + + function Assert-WinGetRemoteFiles($remoteFiles) { + $expectedNames = @($localFiles.Keys | Sort-Object) + $actualNames = @($remoteFiles | ForEach-Object { Split-Path $_.filename -Leaf } | Sort-Object) + if (Compare-Object $expectedNames $actualNames) { + throw 'WinGet public manifest integrity conflict' + } + foreach ($remoteFile in $remoteFiles) { + $name = Split-Path $remoteFile.filename -Leaf + $destination = Join-Path $env:RUNNER_TEMP "winget-remote-$name" + Invoke-WebRequest $remoteFile.raw_url -OutFile $destination + $remoteDigest = (Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($remoteDigest -ne $localFiles[$name]) { + throw 'WinGet public manifest integrity conflict' + } + } + } + + function Find-WinGetPullRequest($submitter, $title) { + $query = "repo:microsoft/winget-pkgs is:pr is:open author:$submitter in:title `"$title`"" + $search = gh api --method GET search/issues -f q="$query" -f per_page=20 + if ($LASTEXITCODE -ne 0) { throw 'Unable to query pending WinGet pull requests reliably' } + $match = ($search | Out-String | ConvertFrom-Json).items | + Where-Object { $_.title -eq $title } | + Sort-Object updated_at -Descending | + Select-Object -First 1 + if ($null -eq $match) { return $null } + $pull = gh api "repos/microsoft/winget-pkgs/pulls/$($match.number)" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read pending WinGet pull request' } + return ($pull | Out-String | ConvertFrom-Json) + } + + function Assert-WinGetPullRequest($pullRequest) { + $pullFiles = gh api "repos/microsoft/winget-pkgs/pulls/$($pullRequest.number)/files?per_page=100" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read pending WinGet pull request files' } + $remoteFiles = @($pullFiles | Out-String | ConvertFrom-Json | + Where-Object { $_.filename -like "$manifestPath/*" }) + Assert-WinGetRemoteFiles $remoteFiles + } + + $publicListing = gh api "repos/microsoft/winget-pkgs/contents/${manifestPath}?ref=master" 2>&1 + $publicStatus = $LASTEXITCODE + if ($publicStatus -eq 0) { + $entries = @($publicListing | Out-String | ConvertFrom-Json) + $remoteFiles = @($entries | ForEach-Object { + [pscustomobject]@{ filename = "$manifestPath/$($_.name)"; raw_url = $_.download_url } + }) + Assert-WinGetRemoteFiles $remoteFiles + $catalogCommit = (gh api --method GET repos/microsoft/winget-pkgs/commits -f path="$manifestPath" -f per_page=1 --jq '.[0].sha').Trim() + if ($LASTEXITCODE -ne 0 -or $catalogCommit -notmatch '^[a-f0-9]{40}$') { + throw 'Unable to resolve exact public WinGet catalog commit' + } + Write-WinGetEvidence 'public-exact' "github:microsoft/winget-pkgs@$catalogCommit#$manifestPath" $null $catalogCommit $true + exit 0 + } + if (($publicListing | Out-String) -notmatch 'HTTP 404') { + throw 'Unable to query public WinGet catalog reliably' + } + + git fetch origin release-ledger + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch protected release ledger' } + $ledgerPath = "events/$($env:RELEASE_TAG).jsonl" + $ledgerEntry = git ls-tree --name-only origin/release-ledger -- $ledgerPath + if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect protected release ledger' } + if (-not [string]::IsNullOrWhiteSpace(($ledgerEntry | Out-String))) { + $ledgerLines = git show "origin/release-ledger:$ledgerPath" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } + $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { + $_.channel -eq 'winget' -and $_.type -in @( + 'channel_submitted', 'channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted' + ) + } | Select-Object -Last 1 + if ($null -ne $existing) { + if ($existing.payload.remoteDigest -ne $manifestDigest) { + throw 'WinGet protected-ledger digest conflicts with rendered manifests' + } + if ($null -eq $existing.payload.submission) { + throw 'WinGet protected-ledger evidence is incomplete' + } + $recordedPull = $existing.payload.submission.provider.pullRequest + if ($null -eq $recordedPull -or $recordedPull -le 0) { + throw 'WinGet protected-ledger pull request identity is invalid' + } + $pullJson = gh api "repos/microsoft/winget-pkgs/pulls/$recordedPull" + if ($LASTEXITCODE -ne 0) { throw 'Unable to query recorded WinGet pull request' } + $pullRequest = ($pullJson | Out-String | ConvertFrom-Json) + $remoteIdentity = "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" + if ($pullRequest.merged) { + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'accepted' $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false + exit 0 + } + if ($pullRequest.state -eq 'closed') { + $labels = @($pullRequest.labels | ForEach-Object { $_.name } | Sort-Object) -join ',' + $reason = "provider pull request closed without merge; labels=$labels" + Write-WinGetEvidence 'rejected' $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false $reason + exit 0 + } + Assert-WinGetPullRequest $pullRequest + $state = if ($existing.type -in @('channel_rejected', 'channel_corrected')) { + 'resubmitted' + } else { 'pending-ledger' } + Write-WinGetEvidence $state $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false + Write-Host 'WinGet provider state was resolved from the recorded pull request; the protected release ledger prevents a blind duplicate.' + exit 0 + } + } + + $submitter = (gh api user --jq .login).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($submitter)) { + throw 'Unable to resolve WinGet submitting identity' + } + $pullTitle = "GuardScan $version [$($manifestDigest.Substring(0, 12))]" + $pullRequest = Find-WinGetPullRequest $submitter $pullTitle + if ($null -ne $pullRequest) { + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'pending' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false + exit 0 + } + + $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v$env:RELEASE_WINGETCREATE_VERSION/wingetcreate.exe" + Invoke-WebRequest $wingetCreateUrl -OutFile wingetcreate.exe + $wingetCreateDigest = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($wingetCreateDigest -ne $env:RELEASE_WINGETCREATE_SHA256) { + throw 'Pinned wingetcreate executable failed SHA-256 verification' + } + & .\wingetcreate.exe submit --prtitle "$pullTitle" --no-open --token $env:WINGET_GITHUB_TOKEN $manifestDirectory + if ($LASTEXITCODE -ne 0) { throw 'WinGet submission failed' } + for ($attempt = 0; $attempt -lt 30; $attempt += 1) { + $pullRequest = Find-WinGetPullRequest $submitter $pullTitle + if ($null -ne $pullRequest) { break } + Start-Sleep -Seconds 10 + } + if ($null -eq $pullRequest) { throw 'Submitted WinGet pull request identity did not become queryable' } + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'submitted' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-winget-evidence-${{ inputs.tag }} + path: winget-evidence.json + if-no-files-found: error + retention-days: 30 chocolatey: name: Chocolatey submission - if: inputs.channel == 'stable' + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && inputs.channel == 'stable' runs-on: windows-2025 needs: [github] environment: chocolatey @@ -388,6 +926,9 @@ jobs: - name: Pack and test Chocolatey from a local feed shell: pwsh run: | + if ($null -eq (Get-Command choco -ErrorAction SilentlyContinue)) { + throw 'Chocolatey is unavailable on the selected Windows runner' + } New-Item -ItemType Directory -Force local-feed | Out-Null Push-Location adapters\chocolatey choco pack guardscan.nuspec --output-directory ..\..\local-feed @@ -395,16 +936,119 @@ jobs: Pop-Location choco install guardscan --source "$(Resolve-Path local-feed)" --yes if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed invocation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ inputs.tag }}'.TrimStart('v')) { + throw 'Chocolatey local-feed invocation or version check failed' + } choco uninstall guardscan --yes if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed uninstall failed' } - - name: Submit Chocolatey package + - name: Preflight and submit Chocolatey package with exact evidence shell: pwsh env: CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + RELEASE_TAG: ${{ inputs.tag }} run: | + $ErrorActionPreference = 'Stop' + $version = $env:RELEASE_TAG.TrimStart('v') $package = Get-ChildItem local-feed\*.nupkg | Select-Object -First 1 if ($null -eq $package) { throw 'Chocolatey package is missing' } + $packageDigest = (Get-FileHash $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $packageIdentity = "guardscan@$version" + $publicUrl = "https://community.chocolatey.org/api/v2/package/guardscan/$version" + + function Write-ChocolateyEvidence($state, $remoteIdentity, $publicBytesVerified, $reason = $null) { + $evidence = [ordered]@{ + schemaVersion = 'guardscan.moderated-submission.v1' + channel = 'chocolatey' + version = $version + tag = $env:RELEASE_TAG + state = $state + packageIdentity = $packageIdentity + packageFilename = $package.Name + remoteIdentity = $remoteIdentity + remoteDigest = $packageDigest + provider = [ordered]@{ + url = $publicUrl + publicBytesVerified = $publicBytesVerified + pendingStateQuery = 'public package bytes only; protected release ledger prevents a blind duplicate after recorded submission' + } + checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + if (-not [string]::IsNullOrWhiteSpace($reason)) { $evidence.reason = $reason } + $evidence | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + } + + $public = $false + try { + Invoke-WebRequest $publicUrl -OutFile public-guardscan.nupkg + $public = $true + } catch { + $statusCode = if ($null -ne $_.Exception.Response) { + [int]$_.Exception.Response.StatusCode + } else { 0 } + if ($statusCode -ne 404) { throw 'Unable to query public Chocolatey package reliably' } + } + if ($public) { + $publicDigest = (Get-FileHash public-guardscan.nupkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($publicDigest -ne $packageDigest) { + throw 'Chocolatey public package integrity conflict' + } + Write-ChocolateyEvidence 'public-exact' $publicUrl $true + exit 0 + } + + git fetch origin release-ledger + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch protected release ledger' } + $ledgerPath = "events/$($env:RELEASE_TAG).jsonl" + $ledgerEntry = git ls-tree --name-only origin/release-ledger -- $ledgerPath + if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect protected release ledger' } + if (-not [string]::IsNullOrWhiteSpace(($ledgerEntry | Out-String))) { + $ledgerLines = git show "origin/release-ledger:$ledgerPath" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } + $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { + $_.channel -eq 'chocolatey' -and $_.type -in @( + 'channel_submitted', 'channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted' + ) + } | + Select-Object -Last 1 + if ($null -ne $existing) { + if ($existing.payload.remoteDigest -ne $packageDigest) { + throw 'Chocolatey protected-ledger digest conflicts with rendered package' + } + $submission = if ($null -ne $existing.payload.submission) { + $existing.payload.submission + } else { + ($ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.channel -eq 'chocolatey' -and $null -ne $_.payload.submission } | + Select-Object -Last 1).payload.submission + } + if ($null -eq $submission) { + throw 'Chocolatey protected-ledger evidence is incomplete' + } + $submittedAt = [DateTimeOffset]::Parse($existing.timestamp) + if ($existing.type -eq 'channel_rejected') { + $reason = $existing.payload.reason ?? 'provider moderation requires maintainer correction' + Write-ChocolateyEvidence 'rejected' "chocolatey:$packageIdentity" $false $reason + exit 0 + } + if (([DateTimeOffset]::UtcNow - $submittedAt).TotalDays -ge 35) { + $reason = 'public package remained absent through the documented 35-day no-response moderation window; maintainer confirmation is required' + Write-ChocolateyEvidence 'rejected' "chocolatey:$packageIdentity" $false $reason + exit 0 + } + $submission | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + Write-Host 'Chocolatey pending moderation cannot be queried reliably; the protected release ledger prevents a blind duplicate.' + exit 0 + } + } + choco push $package.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY if ($LASTEXITCODE -ne 0) { throw 'Chocolatey submission failed' } + Write-ChocolateyEvidence 'submitted' "chocolatey:$packageIdentity" $false + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-chocolatey-evidence-${{ inputs.tag }} + path: chocolatey-evidence.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index ec18ca3..65b9f17 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -22,6 +22,8 @@ on: type: string schedule: - cron: "*/30 * * * *" + repository_dispatch: + types: [catalog_updated] permissions: contents: read @@ -36,7 +38,7 @@ env: jobs: scheduler: name: Dispatch reconciliation for every active train - if: github.event_name == 'schedule' + if: github.event_name == 'schedule' && vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 steps: - name: Create short-lived release app token @@ -66,10 +68,87 @@ jobs: } ' + catalog-hint: + name: Validate catalog dispatch hint and request reconciliation + if: github.event_name == 'repository_dispatch' && vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Create short-lived cross-repository release app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - name: Refetch and validate the hinted immutable catalog commit + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + HINT_VERSION: ${{ github.event.client_payload.version }} + HINT_CATALOG_COMMIT: ${{ github.event.client_payload.catalog_commit || github.event.client_payload.commit }} + HINT_LOCK_SHA256: ${{ github.event.client_payload.lock_sha256 || github.event.client_payload.lockSha256 }} + run: | + test -n "$HINT_CATALOG_COMMIT" + test -n "$HINT_LOCK_SHA256" + case "$HINT_CATALOG_COMMIT" in + *[!a-f0-9]*|'') echo "Invalid catalog commit hint" >&2; exit 1 ;; + esac + test "${#HINT_CATALOG_COMMIT}" = 40 + case "$HINT_LOCK_SHA256" in + *[!a-f0-9]*|'') echo "Invalid catalog lock digest hint" >&2; exit 1 ;; + esac + test "${#HINT_LOCK_SHA256}" = 64 + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/ntanwir10/homebrew-tap/contents/channel-lock.json?ref=$HINT_CATALOG_COMMIT" \ + > hinted-channel-lock.json + ACTUAL_LOCK_SHA256="$(sha256sum hinted-channel-lock.json | cut -d ' ' -f 1)" + test "$ACTUAL_LOCK_SHA256" = "$HINT_LOCK_SHA256" + RECONCILE_VERSION="$(node - <<'NODE' + const lock = require('./hinted-channel-lock.json'); + if ( + lock.schemaVersion !== 'guardscan.channel-catalog.v1' + || lock.source?.repository?.toLowerCase() !== process.env.GITHUB_REPOSITORY.toLowerCase() + || lock.source?.tag !== `v${lock.source?.version}` + || (process.env.HINT_VERSION && lock.source.version !== process.env.HINT_VERSION) + ) { + throw new Error('catalog dispatch hint does not match its refetched lock'); + } + process.stdout.write(lock.source.version); + NODE + )" + export RECONCILE_VERSION + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + > active-versions.json + RELEASE_PR="$(node - <<'NODE' + const active = require('./active-versions.json').trains || []; + const train = active.find(item => item.version === process.env.RECONCILE_VERSION); + process.stdout.write(train ? String(train.releasePr) : ''); + NODE + )" + if [ -z "$RELEASE_PR" ]; then + echo "Catalog hint is for an inactive release; no reconciliation requested." + exit 0 + fi + gh workflow run release-train.yml \ + --repo "$GITHUB_REPOSITORY" \ + -f action=reconcile \ + -f version="$RECONCILE_VERSION" \ + -f release_pr="$RELEASE_PR" + prepare: name: Resolve exact source and create protected tag - if: github.event_name == 'workflow_dispatch' && (inputs.action == 'candidate' || inputs.action == 'promote') + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && (inputs.action == 'candidate' || inputs.action == 'promote') runs-on: ubuntu-24.04 + env: + REQUEST_ACTION: ${{ inputs.action }} + REQUEST_VERSION: ${{ inputs.version }} + REQUEST_RELEASE_PR: ${{ inputs.release_pr }} concurrency: group: release-ledger cancel-in-progress: false @@ -80,7 +159,25 @@ jobs: channel: ${{ steps.result.outputs.channel }} release_pr: ${{ steps.result.outputs.release_pr }} source_pr_head: ${{ steps.result.outputs.source_pr_head }} + source_pr_base: ${{ steps.result.outputs.source_pr_base }} + source_pr_tree: ${{ steps.result.outputs.source_pr_tree }} steps: + - name: Validate untrusted release request syntax before minting credentials + run: | + node - <<'NODE' + const action = process.env.REQUEST_ACTION; + const version = process.env.REQUEST_VERSION; + const releasePr = process.env.REQUEST_RELEASE_PR; + if (!['candidate', 'promote'].includes(action)) { + throw new Error('prepare request action is invalid'); + } + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(version || '')) { + throw new Error('release request version is invalid'); + } + if (!/^[1-9][0-9]*$/.test(releasePr || '') || !Number.isSafeInteger(Number(releasePr))) { + throw new Error('release request PR is invalid'); + } + NODE - name: Create short-lived release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 @@ -92,13 +189,81 @@ jobs: env: GH_TOKEN: ${{ steps.app.outputs.token }} run: | - test -n "${{ inputs.release_pr }}" - HEAD_SHA="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json headRefOid \ - --jq .headRefOid)" - test "${#HEAD_SHA}" = 40 + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + > active-versions.json + node - <<'NODE' + const active = require('./active-versions.json').trains || []; + const stableVersion = (process.env.REQUEST_VERSION || '').replace(/-rc\.[0-9]+$/, ''); + const incompleteStable = active.filter(train => ( + train.channel === 'stable' + && !( + process.env.REQUEST_ACTION === 'promote' + && train.version === stableVersion + && train.releasePr === Number(process.env.REQUEST_RELEASE_PR) + ) + )); + if (incompleteStable.length > 0) { + throw new Error( + `cannot start or promote while a stable train remains incomplete: ${ + incompleteStable.map(train => train.version).join(', ') + }` + ); + } + NODE + gh api "repos/${GITHUB_REPOSITORY}/pulls/$REQUEST_RELEASE_PR" > release-pr.json + HEAD_SHA="$(node - <<'NODE' + const pr = require('./release-pr.json'); + const resumableMergedPromotion = process.env.REQUEST_ACTION === 'promote' + && pr.state === 'closed' + && pr.merged === true + && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha || ''); + if (pr.state !== 'open' && !resumableMergedPromotion) { + throw new Error('release PR must be OPEN or an already-merged resumable promotion'); + } + if (pr.base?.ref !== 'main') { + throw new Error('release PR must target main'); + } + if ( + pr.head?.repo?.full_name?.toLowerCase() + !== process.env.GITHUB_REPOSITORY.toLowerCase() + ) { + throw new Error('release PR must originate from this repository, not a fork'); + } + if (!/^[a-f0-9]{40}$/.test(pr.head?.sha || '')) { + throw new Error('release PR has no valid head commit'); + } + process.stdout.write(pr.head.sha); + NODE + )" + BASE_SHA="$(node -p 'require("./release-pr.json").base.sha')" + TREE_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/$HEAD_SHA" --jq .tree.sha)" + for SHA in "$BASE_SHA" "$TREE_SHA"; do + case "$SHA" in + *[!a-f0-9]*|'') echo "Release PR commit identity is invalid" >&2; exit 1 ;; + esac + test "${#SHA}" = 40 + done echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "tree_sha=$TREE_SHA" >> "$GITHUB_OUTPUT" + echo "is_draft=$(node -p 'String(require("./release-pr.json").draft)')" >> "$GITHUB_OUTPUT" + echo "pr_state=$(node -p 'require("./release-pr.json").merged ? "MERGED" : "OPEN"')" >> "$GITHUB_OUTPUT" + echo "merge_sha=$(node -p 'require("./release-pr.json").merge_commit_sha || ""')" >> "$GITHUB_OUTPUT" + - name: Mark validated release PR ready and recheck candidate source + if: inputs.action == 'candidate' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }} + SOURCE_IS_DRAFT: ${{ steps.source.outputs.is_draft }} + run: | + if [ "$SOURCE_IS_DRAFT" = true ]; then + gh pr ready "$REQUEST_RELEASE_PR" --repo "$GITHUB_REPOSITORY" + fi + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "${GITHUB_REPOSITORY}" --json headRefOid --jq .headRefOid)" + test "$CURRENT_HEAD" = "$EXPECTED_HEAD" - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ steps.source.outputs.head_sha }} @@ -114,8 +279,11 @@ jobs: if: inputs.action == 'candidate' env: RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + SOURCE_PR_HEAD: ${{ steps.source.outputs.head_sha }} + SOURCE_PR_BASE: ${{ steps.source.outputs.base_sha }} + SOURCE_PR_TREE: ${{ steps.source.outputs.tree_sha }} run: | - case "${{ inputs.version }}" in + case "$REQUEST_VERSION" in *-rc.[0-9]*) ;; *) echo "Candidate version must end in -rc.N" >&2; exit 1 ;; esac @@ -125,45 +293,97 @@ jobs: process.stdout.write(new Date(value).toISOString()); ')" node cli/scripts/release/index.js candidate \ - --candidate-version "${{ inputs.version }}" \ - --source-pr "${{ inputs.release_pr }}" \ - --source-pr-head "${{ steps.source.outputs.head_sha }}" \ + --candidate-version "$REQUEST_VERSION" \ + --source-pr "$REQUEST_RELEASE_PR" \ + --source-pr-head "$SOURCE_PR_HEAD" \ + --source-pr-base "$SOURCE_PR_BASE" \ + --source-pr-tree "$SOURCE_PR_TREE" \ --timestamp "$CREATED_AT" - npm --prefix cli run release:validate -- --tag "v${{ inputs.version }}" + npm --prefix cli run release:validate -- --tag "v$REQUEST_VERSION" git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com git add cli/package.json cli/package-lock.json cli/CHANGELOG.md .release-candidate.json GIT_AUTHOR_DATE="$CREATED_AT" GIT_COMMITTER_DATE="$CREATED_AT" \ - git commit -m "chore(release): candidate ${{ inputs.version }}" - if git rev-parse --verify "refs/tags/v${{ inputs.version }}" >/dev/null 2>&1; then - test "$(git rev-list -n 1 "v${{ inputs.version }}")" = "$(git rev-parse HEAD)" + git commit -m "chore(release): candidate $REQUEST_VERSION" + if git rev-parse --verify "refs/tags/v$REQUEST_VERSION" >/dev/null 2>&1; then + test "$(git rev-list -n 1 "v$REQUEST_VERSION")" = "$(git rev-parse HEAD)" else - git tag "v${{ inputs.version }}" + git tag "v$REQUEST_VERSION" fi - git push origin "HEAD:refs/heads/release-candidate/v${{ inputs.version }}" - git push origin "refs/tags/v${{ inputs.version }}" + git push origin "HEAD:refs/heads/release-candidate/v$REQUEST_VERSION" + git push origin "refs/tags/v$REQUEST_VERSION" - name: Emit candidate build identity if: inputs.action == 'candidate' id: candidate run: | echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "tag=v$REQUEST_VERSION" >> "$GITHUB_OUTPUT" - - name: Materialize machine promotion decision + - name: Materialize or recover the persisted machine promotion decision if: inputs.action == 'promote' env: GH_TOKEN: ${{ steps.app.outputs.token }} CURRENT_HEAD: ${{ steps.source.outputs.head_sha }} + CURRENT_BASE: ${{ steps.source.outputs.base_sha }} + PR_STATE: ${{ steps.source.outputs.pr_state }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl - RC_TAG="v${{ inputs.version }}" + git worktree add decision-ledger origin/release-ledger + git show "origin/release-ledger:events/v$REQUEST_VERSION.jsonl" > release-events.jsonl + RC_TAG="v$REQUEST_VERSION" git fetch origin "refs/tags/$RC_TAG:refs/tags/$RC_TAG" git switch --detach "$RC_TAG" npm --prefix cli ci - CURRENT_HEAD="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" --json headRefOid --jq .headRefOid)" - node - <<'NODE' + if [ "$PR_STATE" = MERGED ]; then + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const {validateDocument} = require('./cli/scripts/release/lib'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + const candidate = JSON.parse(fs.readFileSync('.release-candidate.json')); + const pr = JSON.parse(fs.readFileSync('release-pr.json')); + const promotion = [...events].reverse().find(event => ( + event.type === 'promotion_decided' + && event.payload.eligible === true + && event.payload.result === 'permitted' + && /^[a-f0-9]{64}$/.test(event.payload.decisionSha256 || '') + )); + if (!promotion) throw new Error('merged release PR has no permitted persisted promotion decision'); + const matches = fs.readdirSync('decision-ledger/decisions') + .filter(name => name.endsWith('.json')) + .map(name => path.join('decision-ledger/decisions', name)) + .filter(file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + === promotion.payload.decisionSha256); + if (matches.length !== 1) { + throw new Error('persisted promotion decision cannot be resolved uniquely by digest'); + } + const decision = validateDocument('decision', matches[0], 'cli'); + if (!decision.eligible + || decision.result !== 'permitted' + || decision.rc.version !== state.version + || decision.rc.tag !== state.tag + || decision.rc.commit !== state.commit + || decision.rc.manifestSha256 !== state.manifestSha256 + || decision.stable.sourcePr !== Number(process.env.REQUEST_RELEASE_PR) + || decision.stable.sourcePrHead !== candidate.sourcePrHead + || decision.stable.sourcePrBase !== candidate.sourcePrBase + || decision.stable.sourcePrTree !== candidate.sourcePrTree + || pr.head.sha !== candidate.sourcePrHead + || pr.merged !== true + || !/^[a-f0-9]{40}$/.test(pr.merge_commit_sha || '')) { + throw new Error('persisted promotion decision does not bind the merged release source'); + } + fs.copyFileSync(matches[0], 'promotion-decision.json'); + NODE + else + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)" + CURRENT_BASE="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)" + export CURRENT_HEAD CURRENT_BASE + node - <<'NODE' const fs = require('fs'); const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); const events = readEvents('release-events.jsonl'); @@ -174,7 +394,8 @@ jobs: const canaries = Object.entries(state.canaries).flatMap(([channel, samples]) => samples.map(sample => ({channel, status: sample.status, checkedAt: sample.checkedAt})) ); - const incidents = Object.entries(state.incidents).map(([incidentId, value]) => ({incidentId, ...value})); + const incidents = Object.entries(state.incidents) + .map(([incidentId, value]) => ({incidentId, ...value})); const input = { rc: { version: state.version, @@ -184,8 +405,11 @@ jobs: publishedAt: published.timestamp, sourcePr: candidate.sourcePr, sourcePrHead: candidate.sourcePrHead, + sourcePrBase: candidate.sourcePrBase, + sourcePrTree: candidate.sourcePrTree, }, currentSourcePrHead: process.env.CURRENT_HEAD, + currentSourcePrBase: process.env.CURRENT_BASE, evaluatedAt: new Date().toISOString(), requiredChannels: ['npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi'], canaries, @@ -193,54 +417,76 @@ jobs: }; fs.writeFileSync('promotion-input.json', `${JSON.stringify(input, null, 2)}\n`); NODE - node cli/scripts/release/index.js promote \ - --promotion-input promotion-input.json \ - --output promotion-decision.json \ - --ledger release-events.jsonl \ - --idempotency-key "promotion:${{ inputs.version }}:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" \ - --tag "$RC_TAG" - git worktree add decision-ledger origin/release-ledger - mkdir -p decision-ledger/events decision-ledger/decisions - cp release-events.jsonl "decision-ledger/events/v${{ inputs.version }}.jsonl" - cp promotion-decision.json \ - "decision-ledger/decisions/v${{ inputs.version }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json" - ( - cd decision-ledger - git config user.name guardscan-release-bot - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add events decisions - git commit -m "promotion decision: v${{ inputs.version }}" - git push origin HEAD:release-ledger - ) + node cli/scripts/release/index.js promote \ + --promotion-input promotion-input.json \ + --output promotion-decision.json \ + --ledger release-events.jsonl \ + --idempotency-key "promotion:$REQUEST_VERSION:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" \ + --tag "$RC_TAG" + mkdir -p decision-ledger/events decision-ledger/decisions + cp release-events.jsonl "decision-ledger/events/v$REQUEST_VERSION.jsonl" + cp promotion-decision.json \ + "decision-ledger/decisions/v$REQUEST_VERSION-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json" + ( + cd decision-ledger + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add events decisions + git commit -m "promotion decision: v$REQUEST_VERSION" + git push origin HEAD:release-ledger + ) + fi - name: Auto-merge unchanged stable release PR and create stable tag if: inputs.action == 'promote' id: stable env: GH_TOKEN: ${{ steps.app.outputs.token }} + PR_STATE: ${{ steps.source.outputs.pr_state }} + RECORDED_MERGE_SHA: ${{ steps.source.outputs.merge_sha }} + EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }} run: | STABLE_VERSION="$(node -p 'require("./promotion-decision.json").stable.version')" - gh pr merge "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" \ - --auto --squash --match-head-commit "${{ steps.source.outputs.head_sha }}" - for ATTEMPT in $(seq 1 60); do - STATE="$(gh pr view "${{ inputs.release_pr }}" --repo "${GITHUB_REPOSITORY}" --json state --jq .state)" - if [ "$STATE" = MERGED ]; then break; fi - sleep 10 - done - test "$STATE" = MERGED - MERGE_SHA="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" --json mergeCommit --jq .mergeCommit.oid)" + if [ "$PR_STATE" = OPEN ]; then + EXPECTED_BASE="$(node -p 'require("./promotion-decision.json").stable.sourcePrBase')" + CURRENT_BASE="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)" + test "$CURRENT_BASE" = "$EXPECTED_BASE" + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)" + test "$CURRENT_HEAD" = "$EXPECTED_HEAD" + gh pr merge "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" \ + --squash --match-head-commit "$EXPECTED_HEAD" + for ATTEMPT in $(seq 1 60); do + STATE="$(gh pr view "$REQUEST_RELEASE_PR" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" + if [ "$STATE" = MERGED ]; then break; fi + sleep 10 + done + test "$STATE" = MERGED + MERGE_SHA="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json mergeCommit --jq .mergeCommit.oid)" + else + MERGE_SHA="$RECORDED_MERGE_SHA" + fi git fetch origin "$MERGE_SHA" + git fetch origin main + git merge-base --is-ancestor "$MERGE_SHA" origin/main git switch --detach "$MERGE_SHA" test "$(node -p 'require("./cli/package.json").version')" = "$STABLE_VERSION" - git tag "v$STABLE_VERSION" - git push origin "refs/tags/v$STABLE_VERSION" + EXPECTED_TREE="$(node -p 'require("./promotion-decision.json").stable.sourcePrTree')" + test "$(git rev-parse "$MERGE_SHA^{tree}")" = "$EXPECTED_TREE" + if git ls-remote --exit-code --tags origin "refs/tags/v$STABLE_VERSION" >/dev/null 2>&1; then + git fetch origin "refs/tags/v$STABLE_VERSION:refs/tags/v$STABLE_VERSION" + test "$(git rev-list -n 1 "v$STABLE_VERSION")" = "$MERGE_SHA" + else + git tag "v$STABLE_VERSION" + git push origin "refs/tags/v$STABLE_VERSION" + fi echo "ref=$MERGE_SHA" >> "$GITHUB_OUTPUT" echo "tag=v$STABLE_VERSION" >> "$GITHUB_OUTPUT" - name: Select build result id: result run: | - if [ "${{ inputs.action }}" = candidate ]; then + if [ "$REQUEST_ACTION" = candidate ]; then echo "ref=${{ steps.candidate.outputs.ref }}" >> "$GITHUB_OUTPUT" echo "tag=${{ steps.candidate.outputs.tag }}" >> "$GITHUB_OUTPUT" echo "channel=rc" >> "$GITHUB_OUTPUT" @@ -250,13 +496,19 @@ jobs: echo "channel=stable" >> "$GITHUB_OUTPUT" fi echo "build=true" >> "$GITHUB_OUTPUT" - echo "release_pr=${{ inputs.release_pr }}" >> "$GITHUB_OUTPUT" + echo "release_pr=$REQUEST_RELEASE_PR" >> "$GITHUB_OUTPUT" echo "source_pr_head=${{ steps.source.outputs.head_sha }}" >> "$GITHUB_OUTPUT" + echo "source_pr_base=${{ steps.source.outputs.base_sha }}" >> "$GITHUB_OUTPUT" + echo "source_pr_tree=${{ steps.source.outputs.tree_sha }}" >> "$GITHUB_OUTPUT" build: name: Build exact tagged release if: needs.prepare.outputs.build == 'true' needs: [prepare] + permissions: + contents: read + id-token: write + attestations: write uses: ./.github/workflows/release-build.yml with: ref: ${{ needs.prepare.outputs.ref }} @@ -268,6 +520,10 @@ jobs: name: Publish every automated channel if: needs.prepare.outputs.build == 'true' needs: [prepare, build] + permissions: + actions: read + contents: write + id-token: write uses: ./.github/workflows/release-publish.yml with: ref: ${{ needs.prepare.outputs.ref }} @@ -299,12 +555,40 @@ jobs: with: name: release-payload-${{ needs.prepare.outputs.tag }} path: payload + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-github-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/github + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-npm-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/npm + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-pypi-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/pypi + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-catalog-${{ needs.prepare.outputs.tag }} + path: catalog-evidence + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: needs.prepare.outputs.channel == 'stable' + with: + name: release-winget-evidence-${{ needs.prepare.outputs.tag }} + path: moderated-evidence/winget + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: needs.prepare.outputs.channel == 'stable' + with: + name: release-chocolatey-evidence-${{ needs.prepare.outputs.tag }} + path: moderated-evidence/chocolatey - name: Fetch ledger, append chained events, and push env: GH_TOKEN: ${{ steps.app.outputs.token }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_PR: ${{ needs.prepare.outputs.release_pr }} SOURCE_PR_HEAD: ${{ needs.prepare.outputs.source_pr_head }} + SOURCE_PR_BASE: ${{ needs.prepare.outputs.source_pr_base }} + SOURCE_PR_TREE: ${{ needs.prepare.outputs.source_pr_tree }} RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} run: | git fetch origin release-ledger @@ -315,6 +599,8 @@ jobs: const fs = require('fs'); const cp = require('child_process'); const {appendEvent, readEvents} = require('./cli/scripts/release/events'); + const {releaseTrainChannels} = require('./cli/scripts/release/lib'); + const {createPublicationEvidence} = require('./cli/scripts/release/publication-evidence'); const tag = process.env.RELEASE_TAG; const version = tag.slice(1); const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(); @@ -322,6 +608,11 @@ jobs: const manifest = fs.readFileSync('payload/release-manifest.json'); const manifestDocument = JSON.parse(manifest); const manifestSha256 = crypto.createHash('sha256').update(manifest).digest('hex'); + const catalogResult = JSON.parse(fs.readFileSync('catalog-evidence/catalog-result.json')); + const catalogLock = JSON.parse(fs.readFileSync('catalog-evidence/catalog-output/channel-lock.json')); + const lockSha256 = crypto.createHash('sha256') + .update(fs.readFileSync('catalog-evidence/catalog-output/channel-lock.json')) + .digest('hex'); const now = new Date().toISOString(); const timestampFor = idempotencyKey => ( readEvents(ledger).find(event => event.idempotencyKey === idempotencyKey)?.timestamp || now @@ -329,6 +620,7 @@ jobs: const base = {version, tag, commit}; if (readEvents(ledger).length === 0) { const idempotencyKey = `train:${tag}`; + const channels = releaseTrainChannels(process.env.RELEASE_CHANNEL); appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'train_started', idempotencyKey, @@ -336,7 +628,9 @@ jobs: profile: 'full', releasePr: Number(process.env.RELEASE_PR), sourcePrHead: process.env.SOURCE_PR_HEAD, - channels: ['npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'winget', 'chocolatey', 'pypi'], + sourcePrBase: process.env.SOURCE_PR_BASE, + sourcePrTree: process.env.SOURCE_PR_TREE, + channels, }, }); } @@ -357,42 +651,176 @@ jobs: artifact.kind === 'standalone' && artifact.platform.os === 'windows' ).map(artifact => artifact.id), }; + const sha256File = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + const canonicalEntries = entries => entries.sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + )); + const expectedProviderFiles = { + github: Object.fromEntries(canonicalEntries(fs.readdirSync('payload').map(filename => [ + filename, + sha256File(`payload/${filename}`), + ]))), + npm: Object.fromEntries(canonicalEntries(manifestDocument.artifacts + .filter(artifact => artifact.kind === 'npm-tarball') + .map(artifact => [artifact.filename, artifact.sha256]))), + pypi: Object.fromEntries(canonicalEntries(manifestDocument.artifacts + .filter(artifact => artifact.kind === 'python-wheel') + .map(artifact => [artifact.filename, artifact.sha256]))), + }; + const providerEvidence = Object.fromEntries(['github', 'npm', 'pypi'].map(channel => { + const filename = `provider-evidence/${channel}/${channel}-publication-evidence.json`; + const evidence = JSON.parse(fs.readFileSync(filename, 'utf8')); + const normalized = createPublicationEvidence(evidence); + if (JSON.stringify(normalized) !== JSON.stringify(evidence) + || evidence.channel !== channel + || evidence.version !== version + || evidence.tag !== tag + || JSON.stringify(evidence.files) !== JSON.stringify(expectedProviderFiles[channel])) { + throw new Error(`${channel} publication evidence does not match exact release artifacts`); + } + const expectedIdentity = { + github: `github:${process.env.GITHUB_REPOSITORY}/releases/tag/${tag}`, + npm: `npm:guardscan@${version}`, + pypi: `pypi:guardscan-cli@${version.replace('-rc.', 'rc')}`, + }[channel]; + if (evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${channel} publication evidence has an unexpected remote identity`); + } + return [channel, evidence]; + })); + const readModeratedEvidence = channel => { + const file = `moderated-evidence/${channel}/${channel}-evidence.json`; + const evidence = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); + if (evidence.schemaVersion !== 'guardscan.moderated-submission.v1' + || evidence.channel !== channel + || evidence.version !== version + || evidence.tag !== tag + || typeof evidence.packageIdentity !== 'string' + || evidence.packageIdentity.length === 0 + || typeof evidence.remoteIdentity !== 'string' + || evidence.remoteIdentity.length === 0 + || !/^[a-f0-9]{64}$/.test(evidence.remoteDigest || '') + || ![ + 'submitted', 'pending', 'pending-ledger', 'accepted', 'public-exact', + 'rejected', 'corrected', 'resubmitted', + ].includes(evidence.state) + || typeof evidence.provider?.pendingStateQuery !== 'string') { + throw new Error(`${channel} submission evidence is invalid or belongs to another release`); + } + if (channel === 'winget') { + const expectedNames = [ + 'NaumanTanwir.GuardScan.installer.yaml', + 'NaumanTanwir.GuardScan.locale.en-US.yaml', + 'NaumanTanwir.GuardScan.yaml', + ]; + const names = Object.keys(evidence.files || {}).sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames) + || names.some(name => !/^[a-f0-9]{64}$/.test(evidence.files[name]))) { + throw new Error('WinGet submission evidence has an invalid rendered manifest set'); + } + const digestInput = names.map(name => `${name}\0${evidence.files[name]}\n`).join(''); + const aggregate = crypto.createHash('sha256').update(digestInput).digest('hex'); + const expectedRemoteIdentity = evidence.state === 'public-exact' + ? `github:microsoft/winget-pkgs@${evidence.provider.commit}#${evidence.provider.path}` + : `github:microsoft/winget-pkgs/pull/${evidence.provider.pullRequest}@${evidence.provider.commit}#${evidence.provider.path}`; + if (aggregate !== evidence.remoteDigest + || evidence.packageIdentity !== `NaumanTanwir.GuardScan@${version}` + || evidence.provider.repository !== 'microsoft/winget-pkgs' + || evidence.provider.path !== `manifests/n/NaumanTanwir/GuardScan/${version}` + || !/^[a-f0-9]{40}$/.test(evidence.provider.commit || '') + || evidence.remoteIdentity !== expectedRemoteIdentity + || evidence.provider.publicBytesVerified !== (evidence.state === 'public-exact') + || (evidence.state !== 'public-exact' + && (!Number.isInteger(evidence.provider.pullRequest) + || evidence.provider.pullRequest <= 0))) { + throw new Error('WinGet submission evidence does not match the rendered release identity'); + } + } else { + const expectedRemoteIdentity = evidence.state === 'public-exact' + ? evidence.provider.url + : `chocolatey:${evidence.packageIdentity}`; + if (evidence.packageIdentity !== `guardscan@${version}` + || evidence.packageFilename !== `guardscan.${version}.nupkg` + || evidence.provider.url !== `https://community.chocolatey.org/api/v2/package/guardscan/${version}` + || evidence.remoteIdentity !== expectedRemoteIdentity + || evidence.provider.publicBytesVerified !== (evidence.state === 'public-exact')) { + throw new Error('Chocolatey submission evidence does not match the rendered package identity'); + } + } + const normalized = {...evidence}; + delete normalized.checkedAt; + return normalized; + }; for (const channel of ['github', 'npm', 'pypi']) { + const evidence = providerEvidence[channel]; const idempotencyKey = `published:${channel}:${tag}`; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'channel_published', channel, idempotencyKey, payload: { artifactIds: artifactIds[channel], - remoteIdentity: `${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: evidence.remoteIdentity, + remoteDigest: evidence.aggregateSha256, + publication: evidence, }, }); } for (const channel of ['homebrew', 'scoop']) { - const idempotencyKey = `adapter:${channel}:${tag}`; + const catalogPath = channel === 'homebrew' + ? 'Formula/guardscan.rb' + : 'bucket/guardscan.json'; + const pathDigest = catalogLock.files?.[catalogPath]?.sha256; + if (!/^[a-f0-9]{64}$/.test(pathDigest || '')) { + throw new Error(`catalog lock has no valid digest for ${catalogPath}`); + } + const idempotencyKey = `catalog-submitted:${channel}:${tag}:${lockSha256}`; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), - type: process.env.RELEASE_CHANNEL === 'stable' ? 'channel_published' : 'channel_submitted', + type: 'channel_submitted', channel, idempotencyKey, payload: { artifactIds: artifactIds[channel], - remoteIdentity: `ntanwir10/${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: `github:${catalogResult.repository}@${catalogResult.commit}#${catalogPath}`, + remoteDigest: pathDigest, + catalog: { + repository: catalogResult.repository, + commit: catalogResult.commit, + pullRequest: catalogResult.pullRequest, + lockDigest: lockSha256, + manifestDigest: manifestSha256, + path: catalogPath, + fileDigest: pathDigest, + }, }, }); } if (process.env.RELEASE_CHANNEL === 'stable') { for (const channel of ['winget', 'chocolatey']) { - const idempotencyKey = `submitted:${channel}:${tag}`; + const evidence = readModeratedEvidence(channel); + const eventType = { + accepted: 'channel_accepted', + 'public-exact': 'channel_accepted', + rejected: 'channel_rejected', + corrected: 'channel_corrected', + resubmitted: 'channel_resubmitted', + }[evidence.state] || 'channel_submitted'; + const identityKey = crypto.createHash('sha256') + .update(evidence.remoteIdentity) + .digest('hex') + .slice(0, 16); + const idempotencyKey = `${eventType}:${channel}:${tag}:${evidence.remoteDigest}:${identityKey}`; appendEvent(ledger, { - ...base, timestamp: timestampFor(idempotencyKey), type: 'channel_submitted', channel, + ...base, timestamp: timestampFor(idempotencyKey), type: eventType, channel, idempotencyKey, payload: { artifactIds: artifactIds.scoop, - remoteIdentity: `${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: evidence.remoteIdentity, + remoteDigest: evidence.remoteDigest, + submission: evidence, + ...(eventType === 'channel_rejected' + ? {reason: evidence.reason || 'provider rejected the moderated submission'} + : {}), }, }); } @@ -408,8 +836,11 @@ jobs: active.trains = active.trains.filter(train => train.version !== process.env.RELEASE_TAG.slice(1)); if (process.env.RELEASE_CHANNEL === 'stable') { active.trains = active.trains.filter(train => !( - train.channel === 'rc' - && train.releasePr === Number(process.env.RELEASE_PR) + train.channel === 'stable' + || ( + train.channel === 'rc' + && train.releasePr === Number(process.env.RELEASE_PR) + ) )); } active.trains.push({ @@ -433,17 +864,37 @@ jobs: name: Reconcile remote state and trigger eligible promotion if: github.event_name == 'workflow_dispatch' && inputs.action == 'reconcile' runs-on: ubuntu-24.04 + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_PR: ${{ inputs.release_pr }} + concurrency: + group: release-ledger + cancel-in-progress: false steps: - - name: Create short-lived release app token + - name: Validate untrusted reconciliation request before minting credentials + run: | + node - <<'NODE' + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(process.env.RELEASE_VERSION || '')) { + throw new Error('reconciliation version is invalid'); + } + if (!/^[1-9][0-9]*$/.test(process.env.RELEASE_PR || '') + || !Number.isSafeInteger(Number(process.env.RELEASE_PR))) { + throw new Error('reconciliation release PR is invalid'); + } + NODE + - name: Create short-lived cross-repository release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: v${{ inputs.version }} fetch-depth: 0 + token: ${{ steps.app.outputs.token }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.RELEASE_NODE_VERSION }} @@ -454,13 +905,219 @@ jobs: GH_TOKEN: ${{ steps.app.outputs.token }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl + git show "origin/release-ledger:events/v$RELEASE_VERSION.jsonl" > release-events.jsonl node cli/scripts/release/index.js reconcile \ --ledger release-events.jsonl \ - --tag "v${{ inputs.version }}" > reconciliation.json + --tag "v$RELEASE_VERSION" > reconciliation.json cat reconciliation.json + - name: Authoritatively refetch and reconcile the shared catalog + id: catalog + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + AUTOMATION_ENABLED: ${{ vars.RELEASE_AUTOMATION_ENABLED }} + run: | + ACTIVE_CHANNEL="$(gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + --jq ".trains[] | select(.version == \"$RELEASE_VERSION\") | .channel")" + test "$ACTIVE_CHANNEL" = rc || test "$ACTIVE_CHANNEL" = stable + if [ "$ACTIVE_CHANNEL" = rc ]; then + CATALOG_REF="channel-preview/v$RELEASE_VERSION" + UPDATE_BRANCH="$CATALOG_REF" + else + CATALOG_REF=main + UPDATE_BRANCH="guardscan/v$RELEASE_VERSION" + fi + gh repo clone ntanwir10/homebrew-tap catalog-checkout + if git -C catalog-checkout fetch origin "$CATALOG_REF"; then + git -C catalog-checkout switch --detach "origin/$CATALOG_REF" + fi + CATALOG_COMMIT="$(git -C catalog-checkout rev-parse HEAD)" + mkdir catalog-verification + gh release download "v$RELEASE_VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --dir catalog-verification \ + --pattern release-manifest.json + MANIFEST_SHA256="$(sha256sum catalog-verification/release-manifest.json | cut -d ' ' -f 1)" + LEDGER_MANIFEST_SHA256="$(node - <<'NODE' + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + process.stdout.write(materializeReleaseState(readEvents('release-events.jsonl')).manifestSha256 || ''); + NODE + )" + test "$MANIFEST_SHA256" = "$LEDGER_MANIFEST_SHA256" + SOURCE_COMMIT="$(git rev-parse HEAD)" + node cli/scripts/release/index.js catalog-status \ + --manifest catalog-verification/release-manifest.json \ + --catalog-root catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" \ + > catalog-verification/status.json + cat catalog-verification/status.json + INTEGRITY_INCIDENT="$(node -p 'String(require("./catalog-verification/status.json").integrityIncident)')" + CATALOG_CLASSIFICATION="$(node -p 'require("./catalog-verification/status.json").classification')" + if [ "$INTEGRITY_INCIDENT" = true ]; then + echo "::error::Release-integrity incident: catalog classification is $CATALOG_CLASSIFICATION" + exit 1 + fi + if [ "$CATALOG_CLASSIFICATION" != exact ]; then + if [ "$AUTOMATION_ENABLED" != true ]; then + echo "exact=false" >> "$GITHUB_OUTPUT" + echo "Automation is disabled; catalog drift was diagnosed without mutation." + exit 0 + fi + if git -C catalog-checkout show-ref --verify --quiet "refs/remotes/origin/$UPDATE_BRANCH"; then + git -C catalog-checkout switch --detach "origin/$UPDATE_BRANCH" + node cli/scripts/release/index.js catalog \ + --manifest catalog-verification/release-manifest.json \ + --output-dir catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" \ + --check + cmp catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml + else + git -C catalog-checkout switch -c "$UPDATE_BRANCH" origin/main + node cli/scripts/release/index.js catalog \ + --manifest catalog-verification/release-manifest.json \ + --output-dir catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" + mkdir -p catalog-checkout/.github/workflows + cp catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml + git -C catalog-checkout config user.name guardscan-release-bot + git -C catalog-checkout config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-checkout add Formula/guardscan.rb bucket/guardscan.json \ + channel-lock.json .github/workflows/verify.yml + git -C catalog-checkout commit -m "guardscan v$RELEASE_VERSION" + git -C catalog-checkout push origin "$UPDATE_BRANCH" + fi + PR_NUMBER="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$UPDATE_BRANCH" --state all --limit 1 --json number --jq '.[0].number')" + if [ -z "$PR_NUMBER" ]; then + PR_ARGS=( + --repo ntanwir10/homebrew-tap + --base main + --head "$UPDATE_BRANCH" + --title "GuardScan v$RELEASE_VERSION" + --body "Generated from GuardScan's immutable release manifest and bound by channel-lock.json." + ) + if [ "$ACTIVE_CHANNEL" = rc ]; then PR_ARGS+=(--draft); fi + gh pr create "${PR_ARGS[@]}" + PR_NUMBER="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$UPDATE_BRANCH" --state all --limit 1 --json number --jq '.[0].number')" + fi + if [ "$ACTIVE_CHANNEL" = stable ]; then + PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$PR_NUMBER" --json state --jq .state)" + if [ "$PR_STATE" != MERGED ]; then + gh pr merge --repo ntanwir10/homebrew-tap "$PR_NUMBER" --auto --squash + fi + fi + echo "exact=false" >> "$GITHUB_OUTPUT" + echo "Catalog is converging through PR #$PR_NUMBER; verification remains pending." + exit 0 + fi + if ! cmp --silent catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml; then + echo "::error::Release-integrity incident: catalog verification workflow differs from its source" + exit 1 + fi + CATALOG_PULL_REQUEST="$(node - <<'NODE' + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const state = materializeReleaseState(readEvents('release-events.jsonl')); + process.stdout.write(String(state.channels?.homebrew?.catalog?.pullRequest || '')); + NODE + )" + if [ -z "$CATALOG_PULL_REQUEST" ]; then + CATALOG_PULL_REQUEST="$(gh api \ + "repos/ntanwir10/homebrew-tap/commits/$CATALOG_COMMIT/pulls" \ + --jq '.[0].number')" + fi + case "$CATALOG_PULL_REQUEST" in + ''|*[!0-9]*) echo "Catalog publication evidence has no pull request" >&2; exit 1 ;; + esac + export ACTIVE_CHANNEL CATALOG_COMMIT CATALOG_PULL_REQUEST CATALOG_REF MANIFEST_SHA256 SOURCE_COMMIT + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lockFile = fs.readFileSync('catalog-checkout/channel-lock.json'); + const lock = JSON.parse(lockFile); + fs.writeFileSync('catalog-verification/evidence.json', `${JSON.stringify({ + repository: 'ntanwir10/homebrew-tap', + ref: process.env.CATALOG_REF, + commit: process.env.CATALOG_COMMIT, + pullRequest: Number(process.env.CATALOG_PULL_REQUEST), + channel: process.env.ACTIVE_CHANNEL, + manifestSha256: process.env.MANIFEST_SHA256, + lockSha256: crypto.createHash('sha256').update(lockFile).digest('hex'), + files: lock.files, + }, null, 2)}\n`); + NODE + echo "exact=true" >> "$GITHUB_OUTPUT" + - name: Persist refetched catalog publication evidence + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && steps.catalog.outputs.exact == 'true' + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents} = require('./cli/scripts/release/events'); + const evidence = require('./catalog-verification/evidence.json'); + const tag = `v${process.env.RELEASE_VERSION}`; + const events = readEvents('release-events.jsonl'); + if (events.length === 0) throw new Error(`release ledger is empty for ${tag}`); + const commit = events[0].commit; + const timestamp = new Date().toISOString(); + for (const [channel, catalogPath] of [ + ['homebrew', 'Formula/guardscan.rb'], + ['scoop', 'bucket/guardscan.json'], + ]) { + const remoteDigest = evidence.files?.[catalogPath]?.sha256; + if (!/^[a-f0-9]{64}$/.test(remoteDigest || '')) { + throw new Error(`catalog evidence has no valid digest for ${catalogPath}`); + } + const idempotencyKey = `catalog-published:${channel}:${tag}:${evidence.commit}:${evidence.lockSha256}`; + const existing = events.find(event => event.idempotencyKey === idempotencyKey); + appendEvent('release-events.jsonl', { + version: process.env.RELEASE_VERSION, + tag, + commit, + timestamp: existing?.timestamp || timestamp, + type: 'channel_published', + channel, + idempotencyKey, + payload: { + remoteIdentity: `github:${evidence.repository}@${evidence.commit}#${catalogPath}`, + remoteDigest, + catalog: { + repository: evidence.repository, + commit: evidence.commit, + pullRequest: evidence.pullRequest, + lockDigest: evidence.lockSha256, + manifestDigest: evidence.manifestSha256, + path: catalogPath, + fileDigest: remoteDigest, + }, + }, + }); + } + NODE + git worktree add ledger-branch origin/release-ledger + cp release-events.jsonl "ledger-branch/events/v$RELEASE_VERSION.jsonl" + ( + cd ledger-branch + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add "events/v$RELEASE_VERSION.jsonl" + git commit -m "catalog reconciled: v$RELEASE_VERSION" || exit 0 + git push origin HEAD:release-ledger + ) - name: Trigger promotion when the machine policy is eligible - if: contains(inputs.version, '-rc.') + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && contains(inputs.version, '-rc.') env: GH_TOKEN: ${{ steps.app.outputs.token }} run: | @@ -480,24 +1137,59 @@ jobs: gh workflow run release-train.yml \ --repo "${GITHUB_REPOSITORY}" \ -f action=promote \ - -f version="${{ inputs.version }}" \ - -f release_pr="${{ inputs.release_pr }}" + -f version="$RELEASE_VERSION" \ + -f release_pr="$RELEASE_PR" fi + first-release-withdrawal: + name: Withdraw first stable release without a known-good baseline + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && inputs.action == 'rollback' + && inputs.known_good == '' + permissions: + contents: read + pull-requests: read + uses: ./.github/workflows/release-first-withdrawal.yml + with: + version: ${{ inputs.version }} + secrets: inherit + rollback: name: Append rollback and prepare forward fix - if: github.event_name == 'workflow_dispatch' && inputs.action == 'rollback' + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && inputs.action == 'rollback' + && inputs.known_good != '' runs-on: ubuntu-24.04 + env: + DEFECTIVE_VERSION: ${{ inputs.version }} + KNOWN_GOOD_VERSION: ${{ inputs.known_good }} concurrency: group: release-ledger cancel-in-progress: false steps: - - name: Create short-lived release app token + - name: Validate untrusted rollback request before minting credentials + run: | + node - <<'NODE' + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + if (!stable.test(process.env.DEFECTIVE_VERSION || '')) { + throw new Error('rollback defective version is invalid'); + } + if (!stable.test(process.env.KNOWN_GOOD_VERSION || '')) { + throw new Error('verified known-good release is required'); + } + NODE + - name: Create short-lived cross-repository release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: v${{ inputs.version }} @@ -508,26 +1200,527 @@ jobs: node-version: ${{ env.RELEASE_NODE_VERSION }} - working-directory: cli run: npm ci - - name: Generate append-only recovery plan + - name: Verify the exact known-good release and protected ledger + id: known-good + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + git fetch origin release-ledger \ + "refs/tags/v$KNOWN_GOOD_VERSION:refs/tags/v$KNOWN_GOOD_VERSION" + KNOWN_GOOD_TAG="v$KNOWN_GOOD_VERSION" + KNOWN_GOOD_COMMIT="$(git rev-list -n 1 "$KNOWN_GOOD_TAG")" + case "$KNOWN_GOOD_COMMIT" in + *[!a-f0-9]*|'') echo "known-good tag has no valid commit" >&2; exit 1 ;; + esac + test "${#KNOWN_GOOD_COMMIT}" = 40 + if ! git show "origin/release-ledger:events/$KNOWN_GOOD_TAG.jsonl" \ + > known-good-events.jsonl; then + echo "known-good release ledger is incomplete" >&2 + exit 1 + fi + mkdir known-good-release + gh release download "$KNOWN_GOOD_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir known-good-release \ + --pattern release-manifest.json + export KNOWN_GOOD_COMMIT KNOWN_GOOD_TAG + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const {reconcileRelease} = require('./cli/scripts/release/reconcile'); + const events = readEvents('known-good-events.jsonl'); + const state = materializeReleaseState(events); + const manifestFile = fs.readFileSync('known-good-release/release-manifest.json'); + const manifest = JSON.parse(manifestFile); + const manifestDigest = crypto.createHash('sha256').update(manifestFile).digest('hex'); + if (state.version !== process.env.KNOWN_GOOD_VERSION + || state.tag !== process.env.KNOWN_GOOD_TAG + || state.commit !== process.env.KNOWN_GOOD_COMMIT + || manifest.version !== state.version + || manifest.tag !== state.tag + || manifest.commit !== state.commit) { + throw new Error('known-good release identity does not match its protected ledger'); + } + if (state.manifestSha256 !== manifestDigest) { + throw new Error('known-good manifest digest does not match its protected ledger'); + } + if (!reconcileRelease(state).complete) { + throw new Error('known-good release ledger is incomplete'); + } + const catalogCommits = ['homebrew', 'scoop'] + .map(channel => state.channels[channel]?.catalog?.commit) + .filter(Boolean); + if (catalogCommits.length !== 2 || new Set(catalogCommits).size !== 1) { + throw new Error('known-good catalog evidence is incomplete or inconsistent'); + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, [ + `commit=${process.env.KNOWN_GOOD_COMMIT}`, + `catalog_commit=${catalogCommits[0]}`, + `manifest_sha256=${manifestDigest}`, + '', + ].join('\n')); + NODE + - name: Generate and persist the append-only recovery start env: RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_COMMIT: ${{ steps.known-good.outputs.commit }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl - ARGS=(--ledger release-events.jsonl \ - --timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ - --idempotency-key "rollback:${{ inputs.version }}:${GITHUB_RUN_ID}" \ - --tag "v${{ inputs.version }}") - if [ -n "${{ inputs.known_good }}" ]; then ARGS+=(--known-good "${{ inputs.known_good }}"); fi - node cli/scripts/release/index.js rollback "${ARGS[@]}" > rollback-plan.json + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + git show "origin/release-ledger:events/$DEFECTIVE_TAG.jsonl" > release-events.jsonl + ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION" + export ROLLBACK_KEY + ROLLBACK_TIMESTAMP="$(node - <<'NODE' + const {readEvents} = require('./cli/scripts/release/events'); + const existing = readEvents('release-events.jsonl') + .find(event => event.idempotencyKey === process.env.ROLLBACK_KEY); + process.stdout.write(existing?.timestamp || new Date().toISOString()); + NODE + )" + node cli/scripts/release/index.js rollback \ + --ledger release-events.jsonl \ + --timestamp "$ROLLBACK_TIMESTAMP" \ + --idempotency-key "$ROLLBACK_KEY" \ + --tag "$DEFECTIVE_TAG" \ + --known-good "$KNOWN_GOOD_VERSION" \ + --known-good-commit "$KNOWN_GOOD_COMMIT" \ + > rollback-plan.json cat rollback-plan.json + node - <<'NODE' + const plan = require('./rollback-plan.json'); + if (plan.schemaVersion !== 'guardscan.rollback-plan.v1' + || plan.mode !== 'known-good' + || plan.version !== process.env.DEFECTIVE_VERSION + || plan.knownGood.version !== process.env.KNOWN_GOOD_VERSION + || plan.knownGood.commit !== process.env.KNOWN_GOOD_COMMIT) { + throw new Error('generated rollback plan does not match verified recovery authority'); + } + const {readEvents} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + if (!events.some(event => event.type === 'action_required' && event.channel === action.channel)) { + throw new Error(`rollback ledger is missing external authority action for ${action.channel}`); + } + } + NODE git worktree add ledger-branch origin/release-ledger - cp release-events.jsonl "ledger-branch/events/v${{ inputs.version }}.jsonl" + mkdir -p ledger-branch/events + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" ( cd ledger-branch git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add "events/v${{ inputs.version }}.jsonl" - git commit -m "rollback started: v${{ inputs.version }}" + git add "events/$DEFECTIVE_TAG.jsonl" + git commit -m "rollback started: $DEFECTIVE_TAG" || exit 0 + git push origin HEAD:release-ledger + ) + - name: Prepare or verify the deterministic forward-fix pull request + id: forward-fix + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_COMMIT: ${{ steps.known-good.outputs.commit }} + run: | + FORWARD_FIX_VERSION="$(node -p 'require("./rollback-plan.json").forwardFixVersion')" + FORWARD_FIX_BRANCH="$(node -p 'require("./rollback-plan.json").forwardFixBranch')" + test "$(git rev-list -n 1 "v$KNOWN_GOOD_VERSION")" = "$KNOWN_GOOD_COMMIT" + FORWARD_FIX_REMOTE_HEAD="" + if git fetch origin "refs/heads/$FORWARD_FIX_BRANCH:refs/remotes/origin/$FORWARD_FIX_BRANCH"; then + FORWARD_FIX_REMOTE_HEAD="$(git rev-parse "refs/remotes/origin/$FORWARD_FIX_BRANCH")" + fi + git worktree add forward-fix-source "v$KNOWN_GOOD_VERSION" + git -C forward-fix-source switch -c "$FORWARD_FIX_BRANCH" + export FORWARD_FIX_VERSION + node - <<'NODE' + const {prepareForwardFixSource} = require('./cli/scripts/release/recovery-source'); + const result = prepareForwardFixSource('forward-fix-source', { + knownGoodVersion: process.env.KNOWN_GOOD_VERSION, + defectiveVersion: process.env.DEFECTIVE_VERSION, + forwardFixVersion: process.env.FORWARD_FIX_VERSION, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + NODE + git -C forward-fix-source config user.name guardscan-release-bot + git -C forward-fix-source config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C forward-fix-source add cli/CHANGELOG.md cli/package.json cli/package-lock.json + if ! git -C forward-fix-source diff --cached --quiet; then + FORWARD_FIX_CREATED_AT="$(git show -s --format=%cI "$KNOWN_GOOD_COMMIT")" + GIT_AUTHOR_DATE="$FORWARD_FIX_CREATED_AT" GIT_COMMITTER_DATE="$FORWARD_FIX_CREATED_AT" \ + git -C forward-fix-source commit \ + -m "fix(release): forward fix v$FORWARD_FIX_VERSION from v$KNOWN_GOOD_VERSION" + fi + CHANGED_FILES="$(git -C forward-fix-source diff --name-only "$KNOWN_GOOD_COMMIT...HEAD")" + export CHANGED_FILES + node - <<'NODE' + const actual = process.env.CHANGED_FILES.split('\n').filter(Boolean).sort(); + const expected = ['cli/CHANGELOG.md', 'cli/package-lock.json', 'cli/package.json']; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`forward-fix branch contains unreviewed paths: ${actual.join(', ')}`); + } + NODE + FORWARD_FIX_HEAD="$(git -C forward-fix-source rev-parse HEAD)" + FORWARD_FIX_PRS_BEFORE="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_HEAD FORWARD_FIX_PRS_BEFORE + node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS_BEFORE || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const matches = prs.filter(pr => pr.baseRefName === 'main'); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('forward-fix branch has a closed-unmerged pull request'); + } + const merged = matches.find(pr => pr.state === 'MERGED'); + if (merged && ( + merged.headRefOid !== process.env.FORWARD_FIX_HEAD + || merged.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('merged forward-fix pull request does not match the deterministic trusted tree'); + } + NODE + if [ -n "$FORWARD_FIX_REMOTE_HEAD" ]; then + git -C forward-fix-source push \ + --force-with-lease="refs/heads/$FORWARD_FIX_BRANCH:$FORWARD_FIX_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$FORWARD_FIX_BRANCH" + else + git -C forward-fix-source push origin "HEAD:refs/heads/$FORWARD_FIX_BRANCH" + fi + FORWARD_FIX_HEAD="$(git -C forward-fix-source rev-parse HEAD)" + FORWARD_FIX_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_HEAD FORWARD_FIX_PRS + FORWARD_FIX_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('forward-fix branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + if (open && ( + open.headRefOid !== process.env.FORWARD_FIX_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('forward-fix pull request is not bound to the pushed head'); + } + process.stdout.write(String(open?.number || merged?.number || '')); + NODE + )" + if [ -z "$FORWARD_FIX_PR" ]; then + FORWARD_FIX_PR_ARGS=( + --repo "$GITHUB_REPOSITORY" + --base main + --head "$FORWARD_FIX_BRANCH" + --title "Forward fix GuardScan v$DEFECTIVE_VERSION from verified v$KNOWN_GOOD_VERSION" + --body "Restores the verified known-good source as v$FORWARD_FIX_VERSION. Immutable defective artifacts remain auditable and are superseded through the release ledger." + ) + gh pr create "${FORWARD_FIX_PR_ARGS[@]}" + FORWARD_FIX_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state open --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_PRS + fi + FORWARD_FIX_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const open = prs.find(pr => pr.state === 'OPEN' + && pr.baseRefName === 'main' + && pr.headRepository?.nameWithOwner?.toLowerCase() === repository + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + const merged = prs.find(pr => pr.state === 'MERGED' + && pr.baseRefName === 'main' + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + if (!open && !merged) throw new Error('forward-fix pull request is not bound to the pushed head'); + process.stdout.write(String(open?.number || merged?.number || '')); + NODE + )" + echo "version=$FORWARD_FIX_VERSION" >> "$GITHUB_OUTPUT" + echo "branch=$FORWARD_FIX_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$FORWARD_FIX_PR" >> "$GITHUB_OUTPUT" + echo "commit=$FORWARD_FIX_HEAD" >> "$GITHUB_OUTPUT" + - name: Restore the shared catalog to the exact known-good projection + id: catalog-rollback + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_CATALOG_COMMIT: ${{ steps.known-good.outputs.catalog_commit }} + run: | + CATALOG_BRANCH="rollback/v$DEFECTIVE_VERSION-to-v$KNOWN_GOOD_VERSION" + gh repo clone ntanwir10/homebrew-tap catalog-rollback + git -C catalog-rollback fetch origin main + git -C catalog-rollback fetch origin "$KNOWN_GOOD_CATALOG_COMMIT" + CATALOG_BASE="$(git -C catalog-rollback rev-parse origin/main)" + CATALOG_REMOTE_HEAD="" + if git -C catalog-rollback fetch origin \ + "refs/heads/$CATALOG_BRANCH:refs/remotes/origin/$CATALOG_BRANCH"; then + CATALOG_REMOTE_HEAD="$(git -C catalog-rollback rev-parse "refs/remotes/origin/$CATALOG_BRANCH")" + fi + git -C catalog-rollback switch -c "$CATALOG_BRANCH" "$CATALOG_BASE" + git -C catalog-rollback restore --source "$KNOWN_GOOD_CATALOG_COMMIT" -- \ + Formula/guardscan.rb bucket/guardscan.json channel-lock.json + git -C catalog-rollback config user.name guardscan-release-bot + git -C catalog-rollback config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-rollback add Formula/guardscan.rb bucket/guardscan.json channel-lock.json + if ! git -C catalog-rollback diff --cached --quiet; then + git -C catalog-rollback commit \ + -m "rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION" + fi + CATALOG_CHANGED_FILES="$(git -C catalog-rollback diff --name-only "$CATALOG_BASE...HEAD")" + CATALOG_ALREADY_RESTORED=false + if [ -z "$CATALOG_CHANGED_FILES" ]; then + if [ -z "$CATALOG_REMOTE_HEAD" ]; then + echo "catalog rollback has no projection changes or existing merged recovery" >&2 + exit 1 + fi + CATALOG_HEAD="$CATALOG_REMOTE_HEAD" + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const merged = prs.find(pr => pr.state === 'MERGED' + && pr.baseRefName === 'main' + && pr.headRepository?.nameWithOwner?.toLowerCase() === 'ntanwir10/homebrew-tap' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (!merged) throw new Error('catalog already-restored path has no bound merged pull request'); + process.stdout.write(JSON.stringify({ + state: 'MERGED', number: merged.number, mergeCommit: merged.mergeCommit.oid, + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE=MERGED + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + CATALOG_ALREADY_RESTORED=true + fi + if [ "$CATALOG_ALREADY_RESTORED" != true ]; then + export CATALOG_CHANGED_FILES + node - <<'NODE' + const actual = process.env.CATALOG_CHANGED_FILES.split('\n').filter(Boolean).sort(); + const allowed = ['Formula/guardscan.rb', 'bucket/guardscan.json', 'channel-lock.json']; + if (actual.some(file => !allowed.includes(file))) { + throw new Error(`catalog rollback branch contains unreviewed paths: ${actual.join(', ')}`); + } + NODE + if [ -n "$CATALOG_REMOTE_HEAD" ]; then + git -C catalog-rollback push \ + --force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$CATALOG_BRANCH" + else + git -C catalog-rollback push origin "HEAD:refs/heads/$CATALOG_BRANCH" + fi + CATALOG_HEAD="$(git -C catalog-rollback rev-parse HEAD)" + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const repository = 'ntanwir10/homebrew-tap'; + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog rollback branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (open && ( + open.headRefOid !== process.env.CATALOG_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + process.stdout.write(JSON.stringify({ + state: open ? 'OPEN' : merged ? 'MERGED' : '', + number: open?.number || merged?.number || '', + mergeCommit: merged?.mergeCommit?.oid || '', + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).state')" + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + if [ -z "$CATALOG_PR" ]; then + gh pr create \ + --repo ntanwir10/homebrew-tap \ + --base main \ + --head "$CATALOG_BRANCH" \ + --title "Rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION" \ + --body "Restores the exact cryptographically locked catalog projection from verified v$KNOWN_GOOD_VERSION." + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state open --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_PRS + fi + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const repository = 'ntanwir10/homebrew-tap'; + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog rollback branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (open && ( + open.headRefOid !== process.env.CATALOG_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + if (!open && !merged) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + process.stdout.write(JSON.stringify({ + state: open ? 'OPEN' : 'MERGED', + number: open?.number || merged?.number, + mergeCommit: merged?.mergeCommit?.oid || '', + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).state')" + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + if [ "$CATALOG_PR_STATE" = OPEN ]; then + gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --squash + for ATTEMPT in $(seq 1 60); do + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + if [ "$CATALOG_PR_STATE" = MERGED ]; then break; fi + sleep 10 + done + test "$CATALOG_PR_STATE" = MERGED + CATALOG_MERGE_SHA="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --json mergeCommit --jq .mergeCommit.oid)" + fi + fi + case "$CATALOG_MERGE_SHA" in + ''|*[!a-f0-9]*) echo "catalog rollback has no actual merge commit" >&2; exit 1 ;; + esac + test "${#CATALOG_MERGE_SHA}" = 40 + git -C catalog-rollback fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + test "$(git -C catalog-rollback rev-parse "$CATALOG_MERGE_SHA:$CATALOG_PATH")" = \ + "$(git -C catalog-rollback rev-parse "$KNOWN_GOOD_CATALOG_COMMIT:$CATALOG_PATH")" + done + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + - name: Complete repository recovery and deactivate the defective train + env: + FORWARD_FIX_VERSION: ${{ steps.forward-fix.outputs.version }} + FORWARD_FIX_BRANCH: ${{ steps.forward-fix.outputs.branch }} + FORWARD_FIX_PR: ${{ steps.forward-fix.outputs.pull_request }} + FORWARD_FIX_COMMIT: ${{ steps.forward-fix.outputs.commit }} + CATALOG_BRANCH: ${{ steps.catalog-rollback.outputs.branch }} + CATALOG_PR: ${{ steps.catalog-rollback.outputs.pull_request }} + CATALOG_COMMIT: ${{ steps.catalog-rollback.outputs.commit }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + const now = new Date().toISOString(); + const completionKey = `rollback-repository-completed:${process.env.DEFECTIVE_VERSION}`; + const existingCompletion = events.find(event => event.idempotencyKey === completionKey); + const currentEvidence = { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'known-good', + defectiveVersion: process.env.DEFECTIVE_VERSION, + knownGoodVersion: process.env.KNOWN_GOOD_VERSION, + forwardFix: { + version: process.env.FORWARD_FIX_VERSION, + branch: process.env.FORWARD_FIX_BRANCH, + pullRequest: Number(process.env.FORWARD_FIX_PR), + commit: process.env.FORWARD_FIX_COMMIT, + }, + catalog: { + branch: process.env.CATALOG_BRANCH, + pullRequest: Number(process.env.CATALOG_PR), + commit: process.env.CATALOG_COMMIT, + }, + completedAt: now, + }; + const evidence = existingCompletion?.payload || currentEvidence; + if (existingCompletion) { + const comparable = value => JSON.stringify({...value, completedAt: undefined}); + if (comparable(evidence) !== comparable(currentEvidence)) { + throw new Error('repository rollback retry conflicts with protected completion evidence'); + } + } + fs.writeFileSync('rollback-evidence.json', `${JSON.stringify(evidence, null, 2)}\n`); + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: existingCompletion?.timestamp || now, + type: 'rollback_repository_completed', + idempotencyKey: completionKey, + payload: evidence, + }); + const refreshed = readEvents('release-events.jsonl'); + for (const [channel, channelState] of Object.entries(state.channels)) { + if (!['github', 'pnpm', 'yarn', 'bun', 'homebrew', 'scoop'].includes(channel) + || !['published', 'submitted', 'accepted', 'verified'].includes(channelState.status)) { + continue; + } + const key = `rollback-superseded:${channel}:${process.env.DEFECTIVE_VERSION}`; + const existing = refreshed.find(event => event.idempotencyKey === key); + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: existing?.timestamp || now, + type: 'superseded', + channel, + idempotencyKey: key, + payload: { + artifactIds: channelState.artifactIds, + ...(channelState.remoteIdentity ? {remoteIdentity: channelState.remoteIdentity} : {}), + ...(channelState.remoteDigest ? {remoteDigest: channelState.remoteDigest} : {}), + }, + }); + } + NODE + cp release-events.jsonl "ledger-branch/events/v$DEFECTIVE_VERSION.jsonl" + mkdir -p ledger-branch/recoveries + cp rollback-plan.json "ledger-branch/recoveries/v$DEFECTIVE_VERSION-plan.json" + cp rollback-evidence.json "ledger-branch/recoveries/v$DEFECTIVE_VERSION-evidence.json" + node - <<'NODE' + const fs = require('fs'); + const file = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(file, 'utf8')); + active.trains = active.trains.filter( + train => train.version !== process.env.DEFECTIVE_VERSION + ); + fs.writeFileSync(file, `${JSON.stringify(active, null, 2)}\n`); + NODE + ( + cd ledger-branch + git add events active-versions.json recoveries + git commit -m "rollback repository recovery complete: v$DEFECTIVE_VERSION" || exit 0 git push origin HEAD:release-ledger ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rollback-plan-v${{ inputs.version }} + path: | + rollback-plan.json + rollback-evidence.json + if-no-files-found: error + retention-days: 90 diff --git a/.gitignore b/.gitignore index c53992c..bb27287 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ docs/AI_ARCHITECTURE.md docs/AI_FEATURES_MASTER_PLAN.md docs/AI_IMPLEMENTATION_DECISIONS.md docs/PHASE_4_5_IMPLEMENTATION_PLAN.md -docs/CLOUDFLARE_DOMAIN_SETUP.md docs/database-schema.md docs/deployment.md docs/API.md @@ -63,6 +62,7 @@ ci-gitconfig ci-netrc cli/scripts/* !cli/scripts/eslint-ratchet.js +!cli/scripts/eslint-ratchet-lib.js !cli/scripts/eslint-baseline.json !cli/scripts/clean-dist.js !cli/scripts/package-smoke.js diff --git a/PRIVACY.md b/PRIVACY.md index 1593489..49e4ec6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,313 +1,188 @@ # Privacy Policy -**Last Updated**: November 24, 2025 +**Last updated: August 2, 2026** -GuardScan is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and protect information when you use GuardScan. +GuardScan is a local-first security scanner and BYOK AI client. This document +describes the CLI's network boundaries and the data it stores. It distinguishes +local scanning, third-party AI providers, public vulnerability services, and +optional self-hosted telemetry because they have different privacy properties. ---- +## Defaults -## 🎯 Core Privacy Principles +A new non-interactive installation starts with: -1. **Your source code NEVER leaves your machine** (for static analysis) -2. **No code is uploaded** to GuardScan servers -3. **AI features use YOUR API keys** - we never see your code -4. **Telemetry is optional** and can be disabled -5. **All data is anonymized** when telemetry is enabled +- offline mode enabled; +- telemetry disabled; +- no AI provider selected; and +- local caches enabled for commands that use them. ---- - -## 📊 What Information We Collect - -### When Telemetry is Enabled (Optional) - -If you enable telemetry (default: enabled, can be disabled with `--no-telemetry`), we collect: - -1. **Client ID** - - Anonymous identifier generated on first run - - Used to track usage patterns (not personal identification) - - Stored locally in your configuration - -2. **Repository ID** - - Hash of your repository path - - Used to track repository-level statistics - - Cannot be reversed to identify your repository - -3. **Usage Statistics** - - Commands executed (e.g., "security", "scan", "run") - - Lines of code scanned (aggregate counts) - - Feature usage (which commands are used most) - - Error types (for debugging, no stack traces) - -4. **System Information** (Anonymized) - - Node.js version - - Operating system type (not specific version) - - GuardScan version - -### What We Do NOT Collect - -- ❌ **Source code** - Never collected or transmitted -- ❌ **File contents** - Never collected or transmitted -- ❌ **API keys** - Never collected or stored -- ❌ **Personal information** - No names, emails, or identifiers -- ❌ **Repository paths** - Only hashed repository IDs -- ❌ **File names** - Not collected -- ❌ **Git history** - Not collected -- ❌ **Network information** - No IP addresses stored - ---- - -## 🔒 How We Use Collected Information - -### Telemetry Data Usage - -When telemetry is enabled, we use the collected data to: - -1. **Product Improvement** - - Understand which features are most used - - Identify areas for improvement - - Prioritize development efforts - -2. **Bug Fixes** - - Identify common error patterns - - Improve error handling - - Enhance stability - -3. **Analytics** - - Aggregate usage statistics - - Measure adoption of features - - Track version distribution - -### Data Storage - -- **Location**: Cloudflare Workers (global edge network) -- **Database**: Supabase (PostgreSQL) -- **Retention**: Data is retained for up to 1 year -- **Security**: All data is encrypted in transit and at rest - ---- - -## 🚫 Disabling Telemetry - -You can disable telemetry at any time: - -### Per-Command +View the active settings with: ```bash -guardscan --no-telemetry security +guardscan config --show +guardscan telemetry status ``` -### Globally +## Source code and static analysis -```bash -# Edit your config file -guardscan config +GuardScan's built-in static scanners process source files locally. GuardScan does not send source code, source-derived prompts, file names, file paths, secrets, or API keys to a GuardScan-operated service. -# Or manually edit: ~/.guardscan/config.yaml -# Set telemetry.enabled: false -``` +Some commands deliberately invoke other software, such as a project test runner, linter, package manager, mutation tool, or configured AI provider. `guardscan scan` is static-only by default and requires `--run-project-code` before it invokes repository-controlled tests or linters. Those child processes receive a scrubbed environment and isolated temporary home, but they can still read accessible files and modify the repository. `--isolate-project-network` requests an OS-backed network sandbox and fails the affected check when that sandbox is unavailable. Other explicitly invoked project-tool commands retain their own behavior and privacy policies. + +## Offline mode -### Environment Variable +Persist offline mode or apply it to one invocation: ```bash -export GUARDSCAN_NO_TELEMETRY=true -guardscan security +guardscan config --offline=true +guardscan --offline security ``` ---- - -## 🤖 AI Provider Privacy - -When you use AI features (code review, documentation generation, etc.): - -### Your API Keys - -- **Stored locally** in your configuration file (`~/.guardscan/config.yaml`) -- **Never transmitted** to GuardScan servers -- **Sent directly** to your chosen AI provider (OpenAI, Anthropic, etc.) - -### Your Code - -- **Code snippets** are sent to your AI provider (OpenAI, Claude, etc.) -- **GuardScan does NOT see** your code or AI responses -- **You control** which AI provider receives your code -- **Review your AI provider's privacy policy** (OpenAI, Anthropic, Google, etc.) - -### Local AI (Ollama) - -- When using Ollama, **everything stays local** -- No data leaves your machine -- No network requests to external services +`--no-cloud` remains a deprecated alias for `--offline`. ---- +While offline mode is active, GuardScan blocks its cloud AI providers and cloud embedding providers, advisory lookups, update checks, and both telemetry recording and delivery. Ollama and LM Studio remain available only at literal IPv4 `127/8` or IPv6 `::1` endpoints; hostname aliases, private-network addresses, and remote endpoints are rejected. HTTP redirects are disabled for provider and telemetry transports. Dependency inventory and SBOM generation continue from local manifests, lockfiles, and installed package metadata. -## 📡 Network Communication +Offline CVE scanning does not claim that an old local snapshot is current. It requires a fresh snapshot whose inventory digest matches the repository. Missing, stale, mismatched, or incomplete coverage is an operational failure unless `--allow-partial` is explicitly supplied. -### Static Analysis (Offline) +## AI providers -- **No network requests** required -- Works completely offline -- No data transmission +AI features send selected repository context directly to the provider you configure. For OpenAI, Anthropic, Gemini, or OpenRouter, that is a third-party cloud service. For Ollama or LM Studio at a literal loopback address, it is a local service. A non-loopback self-hosted endpoint requires offline mode to be disabled and the separately named `allowRemoteSelfHosted: true` configuration approval. -### AI Features +`guardscan run` always completes its required local scanner pass before optional AI enrichment. Scanner status, errors, and coverage remain in the report; incomplete required coverage exits with code `2` unless the supported CVE-only partial policy was explicitly selected. -- **Direct connection** to your AI provider -- No GuardScan servers involved -- Your code goes directly to OpenAI/Claude/etc. +GuardScan does not proxy AI requests or receive the provider response. Review the chosen provider's retention and training policies before enabling cloud AI. API credentials can be read from the provider's environment variable or stored in the local configuration file; GuardScan does not include them in telemetry. -### Telemetry (If Enabled) +## Vulnerability advisory lookups -- **HTTPS only** - All data encrypted in transit -- **Minimal data** - Only metadata, no code -- **Optional** - Can be completely disabled +Online vulnerability scans send package ecosystem, package name, and exact version to the configured OSV-compatible endpoint. When known-exploitation enrichment is enabled, GuardScan also downloads the public CISA KEV catalog; that request contains no repository or package data. Neither request sends source files. Successful OSV coverage and the validated KEV catalog can be cached locally for later offline use. -### Version Checking +Snapshots are stored beneath `~/.guardscan/cache/vulnerabilities` (or the equivalent directory under `GUARDSCAN_HOME`). Clear them with: -- **npm registry** - Checks for updates -- **No personal data** sent -- **Can be disabled** by setting environment variable - ---- - -## 🔐 Data Security - -### How We Protect Your Data - -1. **Encryption** - - All data encrypted in transit (HTTPS/TLS) - - Database encryption at rest - - Secure API endpoints - -2. **Access Control** - - Limited access to telemetry data - - No access to source code (we don't collect it) - - Regular security audits - -3. **Infrastructure** - - Cloudflare Workers (edge network) - - Supabase (PostgreSQL database) - - Industry-standard security practices - -### Your Data Security - -1. **Local Storage** - - Configuration files stored locally - - API keys stored in local config - - Cache files stored locally - -2. **Best Practices** - - Don't commit config files to version control - - Use environment variables for sensitive data - - Regularly rotate API keys - ---- - -## 🌍 Data Location - -- **Telemetry Data**: Stored in Supabase (PostgreSQL) - location depends on your Supabase region -- **Processing**: Cloudflare Workers (global edge network) -- **Your Code**: Never stored anywhere - stays on your machine - ---- - -## 👥 Third-Party Services - -GuardScan uses the following third-party services: - -### Required Services - -- **npm Registry**: For package installation and version checking -- **AI Providers** (if configured): OpenAI, Anthropic, Google, Ollama - -### Optional Services (Telemetry) - -- **Cloudflare Workers**: Backend API for telemetry -- **Supabase**: Database for telemetry storage - -**Note**: When telemetry is disabled, no data is sent to Cloudflare or Supabase. - ---- - -## 🔄 Data Retention and Deletion - -### Telemetry Data - -- **Retention**: Up to 1 year -- **Deletion**: You can request deletion by emailing -- **Anonymization**: Data is anonymized and cannot be linked to individuals +```bash +guardscan vuln db clear --repo --force +guardscan vuln db clear --all --force +``` -### Local Data +See [Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for coverage and severity limitations. -- **Configuration**: Stored locally, you control it -- **Cache**: Stored locally, can be cleared with `guardscan reset` -- **Logs**: Stored locally, you control retention +## Telemetry ---- +Telemetry is opt-in and never uploads automatically. Enabling consent allows +GuardScan to queue an allowlisted event locally; delivery happens only when you +configure a user-operated collector with `GUARDSCAN_TELEMETRY_URL` and run +`guardscan telemetry sync`. -## 👶 Children's Privacy +GuardScan does not operate a hosted telemetry collector. The former Cloudflare +service at `api.guardscancli.com` is retired under +[ADR 007](./docs/adrs/007-retire-hosted-cloudflare-telemetry.md). It is not a +valid default or self-hosting target. The apex `guardscancli.com` website and +document identifiers remain active and are separate from that retired service. -GuardScan is not intended for users under 13 years of age. We do not knowingly collect personal information from children. +An event may contain: ---- +- a random event ID; +- the command category; +- aggregate lines of code; +- execution duration; +- a coarse execution mode; and +- an event timestamp. -## 🔄 Changes to This Policy +Events do not contain source code, prompts, responses, file names, file paths, repository names, repository hashes, stack traces, API keys, dependency names, or vulnerability details. -We may update this Privacy Policy from time to time. Changes will be: +The telemetry schema is exact: `eventId`, `action`, `loc`, `durationMs`, +`executionMode`, and `occurredAt`. Unknown fields, invalid numeric values, and +timestamps outside the supported range are quarantined locally before status, +pruning, or synchronization. Quarantined files are never uploaded. -- Posted on this page -- Dated with "Last Updated" timestamp -- Communicated via GitHub releases for significant changes +Maintenance retains the newest 1,000 valid events for at most 30 days. Separate +CLI processes publish without replacing one another, so concurrent writers may +briefly exceed the retention target until the next bounded maintenance pass. A +single pass examines at most 2,000 directory entries. Event and metadata files +are limited to 64 KiB; legacy migration inputs are limited to 8 MiB and 1,000 +events. Invalid migrations are quarantined without partially importing them, +and only one process may synchronize an outbox at a time. A failed upload +remains queued for a later explicit retry. A successful response removes only +accepted events. ---- +To use telemetry, enable consent, leave offline mode, configure an HTTPS collector, and sync explicitly: -## 📧 Contact Us +```bash +guardscan config --telemetry=true --offline=false +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com +guardscan telemetry status +guardscan telemetry sync +``` -For privacy-related questions or concerns: +`GUARDSCAN_OFFLINE=true`, `--offline`, `GUARDSCAN_NO_TELEMETRY=true`, or `--no-telemetry` suppresses recording and delivery for the invocation. Persistently disabling telemetry also exhaustively deletes every queued event, including queues larger than the retention limit. Inspect or delete the local outbox at any time: -- **Email**: -- **GitHub Issues**: For general questions (not sensitive privacy matters) +```bash +guardscan telemetry status +guardscan telemetry clear --force +``` ---- +There is no GuardScan-hosted endpoint or default. Whoever operates the +self-hosted endpoint is responsible for publishing its retention, deletion, +access-control, and jurisdiction terms. -## ✅ Your Rights +After `1.1.0` is published, the retired endpoint returns `410 Gone` for seven +days without accepting, storing, or redirecting telemetry, and is then deleted. +Users of `1.0.5` and earlier should upgrade or disable telemetry. Existing local +queues are not transferred and remain available to inspect, explicitly sync to +a user-operated collector, or delete. -You have the right to: +## Local storage -1. **Disable telemetry** at any time -2. **Request data deletion** (email ) -3. **Access your data** (if telemetry is enabled) -4. **Use GuardScan completely offline** (no telemetry, no network) +GuardScan stores configuration and state beneath `~/.guardscan`, or beneath the directory selected by `GUARDSCAN_HOME`. Depending on enabled features, local data can include: ---- +- configuration and API credentials; +- exact and semantic AI caches containing prompts, responses, file paths, and code-derived text; +- local embedding indexes; +- vulnerability coverage snapshots; +- circuit-breaker and metrics state; and +- the telemetry outbox. -## 📋 Summary +GuardScan creates its private state directories with mode `0700` and sensitive +files with mode `0600` on platforms that support POSIX permissions. Local AI +metrics retain provider/model names, timing, token counts, cost estimates, and +structured error categories, but do not persist raw provider error messages. +Metrics are stored as bounded per-span event files so concurrent CLI processes +do not replace one another's snapshots. Persisted metrics use an exact, +allowlisted schema; malformed optional fields are quarantined before +aggregation. Metrics maintenance retains the newest 1,000 valid spans, limits +individual event files to 64 KiB and legacy inputs to 8 MiB/1,000 spans, and +keeps at most 20 recent quarantine artifacts per state area. A conflicting +write for the same span identity is rejected rather than silently replacing +the first value. -**What GuardScan Collects (if telemetry enabled):** +Configuration sections are parsed as partial overrides and normalized against +defaults. Unknown keys, invalid ranges, non-integer limits, malformed dates, +and unsafe service endpoints are rejected instead of being retained and +re-emitted. -- Anonymous client ID -- Hashed repository ID -- Usage statistics (commands, LOC counts) -- System information (Node.js version, OS type) +If GuardScan cannot resolve a safe home directory it fails closed and asks for +an absolute `GUARDSCAN_HOME`; it does not silently use the shared `/tmp` root. -**What GuardScan Does NOT Collect:** +Cache entries expire according to the configured TTL and are pruned from disk. `--no-cache` disables exact, semantic, and vulnerability snapshot reads and writes for that invocation. -- Source code -- File contents -- API keys -- Personal information -- Repository paths -- File names +Delete cached source-derived data with: -**Your Control:** +```bash +guardscan cache clear --repo --force +guardscan cache clear --all --force +``` -- ✅ Disable telemetry anytime -- ✅ Use completely offline -- ✅ Control your API keys -- ✅ Your code never leaves your machine (for static analysis) +`cache clear` does not delete the telemetry outbox. Use `telemetry clear` for that data, or `guardscan reset --all --force` when you intend to reset all GuardScan configuration and state. ---- +## Your controls -**GuardScan is committed to privacy-first development. Your code stays yours.** +You can: ---- +- keep GuardScan offline; +- choose a local or cloud AI provider; +- omit AI features entirely; +- disable cache reads and writes per invocation; +- inspect and clear repository or global caches; +- leave telemetry disabled; and +- inspect, explicitly synchronize, or delete queued telemetry. -*Last Updated: 2025-11-24* +For a sensitive privacy question, contact . Do not include source code, credentials, or proprietary findings in a public issue. diff --git a/QUICKSTART.md b/QUICKSTART.md index 7ed0ba9..181088d 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -2,7 +2,7 @@ **Get started with GuardScan in under 2 minutes!** -GuardScan is a privacy-first, open-source security scanning and AI code review CLI. All static analysis features work **100% free and offline** - no API keys required! +GuardScan is a privacy-first, open-source security scanning and AI code review CLI. Local static analysis and SBOM inventory work offline without an API key. Current CVE data requires OSV access or a fresh matching local snapshot. --- @@ -18,8 +18,8 @@ guardscan --version **Requirements:** -- Node.js >= 18.0.0 -- npm or yarn +- Node.js >= 22.0.0 +- npm (pnpm, Yarn, and Bun compatibility is release-gated before those install paths are advertised) --- @@ -31,25 +31,25 @@ guardscan --version guardscan init ``` -This creates a local configuration file and generates a client ID for optional telemetry. +This creates a private local configuration file. GuardScan does not generate or transmit an installation identifier. ### Step 2: Run Your First Security Scan ```bash -# Scan your current project (100% FREE, works offline) -guardscan security +# Scan your current project with local checks only +guardscan security --offline --no-cve ``` This will: - ✅ Detect secrets in your code (API keys, passwords, tokens) -- ✅ Scan dependencies for known vulnerabilities +- ✅ Scan dependencies for known vulnerabilities when OSV or a matching snapshot is available - ✅ Check Dockerfiles for security issues - ✅ Analyze Infrastructure as Code (Terraform, CloudFormation, K8s) - ✅ Detect OWASP Top 10 vulnerabilities - ✅ Generate a comprehensive markdown report -**No API key needed** - all security scanning works completely offline! +**No API key needed** - local security scanning and SBOM inventory work offline. Current CVE results need OSV access or a fresh matching local snapshot. ### Step 3: (Optional) Configure AI Provider @@ -71,15 +71,18 @@ Follow the prompts to set: ## 📋 Available Commands -GuardScan provides **21 commands** organized by category: +GuardScan provides **29 top-level commands** organized by category: ### Setup & Configuration ```bash -guardscan init # Initialize GuardScan (generates client_id for telemetry) +guardscan init # Initialize local GuardScan configuration guardscan config # Configure AI provider and settings (OpenAI, Claude, Gemini, Ollama) -guardscan status # Show current status (credits, provider, repo info) +guardscan status # Show provider, repo, and local config status guardscan reset # Clear local cache and config +guardscan cache # Inspect or clear repository/global cache +guardscan telemetry # Inspect, explicitly sync, or clear telemetry +guardscan capabilities # Inspect optional runtime capabilities and safe fallbacks ``` ### Security & Scanning (Offline-Capable, 100% FREE) @@ -90,6 +93,7 @@ guardscan scan # Comprehensive scan (all security and quality guardscan test # Run tests and code quality analysis guardscan sbom # Generate Software Bill of Materials (SBOM) guardscan rules # Run custom YAML-based rules engine +guardscan vuln # Audit exact dependency versions with OSV ``` ### Testing & Performance @@ -129,6 +133,15 @@ guardscan migrate # AI-powered code migration assistant guardscan chat # Interactive AI chat about your codebase (RAG feature) ``` +### AI Model Operations + +```bash +guardscan models # Inspect supported AI models +guardscan routing # Configure task-to-model routing +guardscan budget # Inspect and configure local budget limits +guardscan metrics # Inspect locally recorded AI metrics +``` + --- ## 💡 Common Use Cases @@ -140,12 +153,20 @@ guardscan chat # Interactive AI chat about your codebase (RAG guardscan security # Check for dependency vulnerabilities -guardscan security --licenses +guardscan config --offline=false +guardscan vuln . --ci --format json --output vulnerabilities.json # Generate SBOM for compliance guardscan sbom --format spdx +guardscan sbom --format cyclonedx ``` +SBOM output is validated against the official SPDX 2.3 and CycloneDX 1.7 JSON schemas. + +For the combined security, quality, and SBOM workflow, `guardscan scan` remains +static-safe by default. Only add `--run-project-code` for a repository you trust; +add `--isolate-project-network` when you also require an available OS network sandbox. + ### AI-Powered Code Review ```bash @@ -195,21 +216,23 @@ guardscan chat ### What GuardScan Does - ✅ Scans your code **locally** on your machine -- ✅ Never uploads source code to any server -- ✅ Works **completely offline** for static analysis +- ✅ Does not upload source code to GuardScan servers +- ✅ Runs local static analysis offline +- ✅ Does not execute repository tests or linters during `guardscan scan` unless explicitly enabled - ✅ Uses your own AI API keys (BYOK - Bring Your Own Key) -### What GuardScan Sends (Optional Telemetry) +### Optional telemetry -- Client ID (anonymous identifier) -- Repository ID (hashed, anonymous) -- Lines of code count -- Command usage statistics +- Telemetry is disabled by default. +- After consent, GuardScan queues only action, aggregate LOC, duration, coarse execution mode, event ID, and timestamp. +- Source, paths, prompts, responses, findings, dependency names, and errors are excluded. +- Delivery happens only through `guardscan telemetry sync` to an HTTPS endpoint you configure. -**You can disable telemetry:** +Inspect or suppress it: ```bash guardscan --no-telemetry security +guardscan telemetry status ``` --- @@ -218,23 +241,36 @@ guardscan --no-telemetry security ### Offline-First Architecture -**Static Analysis** (Works completely offline, 100% FREE): +**Static Analysis** (Offline-first, 100% FREE): - Secrets detection (20+ patterns) -- Dependency vulnerability scanning +- Dependency inventory and vulnerability evaluation from a fresh matching snapshot - Code metrics and complexity analysis - LOC counting (20+ languages) - OWASP Top 10 detection - Docker security scanning - Infrastructure as Code analysis +OSV lookups and snapshot refreshes require online mode: + +```bash +guardscan config --offline=false +guardscan vuln db update . +guardscan vuln . +``` + +See [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for supported lockfiles, limitations, and CI policy. + **AI-Enhanced** (Optional, requires your API key): - OpenAI GPT-4, GPT-3.5 - Anthropic Claude (Opus, Sonnet, Haiku) - Google Gemini -- Ollama (local/offline AI) -- LM Studio (local AI) +- Ollama (offline only at literal `127/8` or `::1` endpoints) +- LM Studio (offline only at literal `127/8` or `::1` endpoints) + +Remote self-hosted Ollama or LM Studio endpoints require online mode and the +explicit `allowRemoteSelfHosted: true` configuration approval. --- @@ -313,7 +349,7 @@ guardscan init 1. ✅ **Install**: `npm install -g guardscan` 2. ✅ **Initialize**: `guardscan init` -3. ✅ **Scan**: `guardscan security` (works offline, 100% free!) +3. ✅ **Scan**: `guardscan security --offline --no-cve` (local checks, 100% free) 4. ✅ **Configure AI** (optional): `guardscan config` 5. ✅ **Explore**: Try `guardscan --help` to see all commands diff --git a/README.md b/README.md index c8bb648..ead578d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ``` [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org) +[![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org) --- @@ -25,8 +25,8 @@ GuardScan is **100% free and open source**! No credit system, no paywalls, no su - ✅ **Unlimited static analysis** - 9 security scanners + code quality tools - ✅ **AI-enhanced code review** - Bring your own API key (OpenAI, Claude, Gemini, Ollama) -- ✅ **Works fully offline** - No internet required for static analysis -- ✅ **Privacy-first** - Never uploads your source code +- ✅ **Offline-first static analysis** - Local scanners and SBOM inventory run without internet; CVEs can reuse a fresh matching snapshot +- ✅ **Privacy-first** - GuardScan does not upload your source code to GuardScan servers - ✅ **No usage limits** - Scan unlimited LOC, unlimited repositories --- @@ -40,9 +40,13 @@ npm install -g guardscan # Initialize GuardScan guardscan init -# Run comprehensive security scan (100% FREE, offline) +# Run comprehensive security scan (100% FREE, offline-first) guardscan security +# Audit exact dependency versions against OSV (online) +guardscan config --offline=false +guardscan vuln . + # Configure AI provider for enhanced review (optional, BYOK) guardscan config @@ -81,12 +85,12 @@ guardscan init ## 📋 Core Features -### 🔒 Security Scanning (FREE, Offline) +### 🔒 Security Scanning (FREE, Offline-First) GuardScan includes **comprehensive security scanners**: 1. **Secrets Detection** - Find hardcoded API keys, passwords, tokens (20+ patterns) -2. **Dependency Vulnerabilities** - Scan npm, pip, Maven, Cargo dependencies +2. **Dependency Vulnerabilities** - Scan exact npm, PyPI, Go, RubyGems, Cargo, and Maven versions with OSV; reuse fresh snapshots offline 3. **OWASP Top 10** - SQL injection, XSS, insecure configs, CSRF, XXE 4. **Docker Security** - Dockerfile and container scanning 5. **Infrastructure as Code** - Terraform, CloudFormation, Kubernetes security @@ -160,7 +164,7 @@ All commands are **100% FREE** with no limits! | Command | Description | | ------------------ | ------------------------------------- | -| `guardscan init` | Initialize config, generate client_id | +| `guardscan init` | Initialize local configuration | | `guardscan config` | Configure AI provider & settings | | `guardscan status` | Show configuration and repo info | | `guardscan reset` | Clear local cache & config | @@ -169,9 +173,27 @@ All commands are **100% FREE** with no limits! | Command | Description | | -------------------- | ----------------------------------------- | -| `guardscan security` | Run comprehensive security scan (offline) | -| `guardscan scan` | Quick security scan | -| `guardscan run` | AI-enhanced full code review (BYOK) | +| `guardscan security` | Run comprehensive local security scan | +| `guardscan scan` | Static-safe security, quality, and SBOM scan | +| `guardscan vuln` | Audit exact dependency versions with OSV | +| `guardscan run` | Required local review with optional AI enrichment | + +Dependency scanning details, offline snapshots, severity limitations, and CI examples are documented in [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md). + +### CI output and exit codes + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan security --format sarif --output guardscan.sarif +``` + +Structured scan output uses the `guardscan.scan.v1` envelope and includes security, quality, SBOM, AI, scanner status, errors, and policy state. Exit code `0` means the run and policy passed, `1` means findings violated policy, and `2` means scanner coverage or execution failed. `--allow-partial` is an explicit relaxation for incomplete coverage. + +`guardscan scan` does not execute repository-controlled tests or linters by default. Use +`--run-project-code` only for a trusted repository; reports record whether they are +`static-analysis` or `project-code-executed`. Child processes receive a scrubbed +environment and isolated home. `--isolate-project-network` additionally requests an +OS-backed network sandbox and fails the affected checks if the platform sandbox is unavailable. ### Testing & Quality Commands @@ -187,22 +209,25 @@ All commands are **100% FREE** with no limits! | Command | Description | | ----------------- | ----------------------------------- | -| `guardscan sbom` | Generate Software Bill of Materials | +| `guardscan sbom` | Generate schema-valid SPDX 2.3 or CycloneDX 1.7 | | `guardscan rules` | Custom YAML-based rule engine | +| `guardscan cache` | Inspect or clear local AI caches | +| `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | +| `guardscan capabilities` | Inspect optional runtime capabilities and safe fallbacks | ### AI-Powered Commands (BYOK) | Command | Description | | --------------------------- | ------------------------------------ | -| `guardscan explain ` | Explain how code works | -| `guardscan review ` | Comprehensive AI code review | -| `guardscan commit` | Generate commit messages | -| `guardscan docs ` | Auto-generate documentation | -| `guardscan test-gen ` | Generate unit tests | -| `guardscan refactor ` | Get refactoring suggestions | -| `guardscan threat-model` | Security architecture analysis | -| `guardscan migrate` | Framework/language migration help | -| `guardscan chat` | Interactive Q&A about codebase (RAG) | +| `guardscan explain ` | Explain how code works | +| `guardscan review --file ` | Comprehensive AI code review | +| `guardscan commit` | Generate commit messages | +| `guardscan docs --type ` | Auto-generate documentation | +| `guardscan test-gen --file ` | Generate unit tests | +| `guardscan refactor --file ` | Get refactoring suggestions | +| `guardscan threat-model` | Security architecture analysis | +| `guardscan migrate` | Framework/language migration help | +| `guardscan chat` | Interactive Q&A about codebase (RAG) | --- @@ -210,7 +235,7 @@ All commands are **100% FREE** with no limits! We take privacy seriously: -### ❌ Never Stored or Transmitted +### ❌ No GuardScan-Hosted Processing - Your source code - File paths or file names @@ -218,25 +243,32 @@ We take privacy seriously: - API keys or secrets - Proprietary information -### ✅ Optional Telemetry (Anonymized) +Local AI/RAG features can store prompts, responses, file paths, and code-derived snippets in your machine's `~/.guardscan/cache` for caching and retrieval. Use `guardscan cache clear --repo --force` or `guardscan cache clear --all --force` to remove cached source-derived data. + +### ✅ Opt-In Aggregate Telemetry - Command usage (e.g., "security" command ran) - Execution duration - LOC count (aggregate number only) -- AI model used (e.g., "gpt-4") +- Coarse execution mode (static, local AI, or cloud AI) **Telemetry is:** -- Optional (easily disabled: `guardscan config --telemetry=false`) -- Completely anonymized -- Only used to improve GuardScan -- Never sold or shared +- Disabled by default and explicitly enabled with `guardscan config --telemetry=true` +- Queued locally only after consent +- Sent only by `guardscan telemetry sync` to a user-operated endpoint selected + with `GUARDSCAN_TELEMETRY_URL` +- Recording and delivery are suppressed by offline mode or `--no-telemetry` +- Never sent to a GuardScan-hosted collector; GuardScan operates no telemetry + ingestion service + +See the [Privacy Policy](./PRIVACY.md) for the exact event allowlist and local retention behavior. --- ## 🎯 How It Works -### Static Analysis (Offline, No AI) +### Static Analysis (Offline-First, No AI) ```bash guardscan security @@ -246,7 +278,7 @@ Runs **9 security scanners** locally: - Scans your codebase - Generates markdown report -- **100% offline** - no internet needed +- **Offline-first** - local scanners and local SBOM inventory run without internet; CVE results require either OSV access or a fresh matching snapshot - **100% free** - no limits ### AI-Enhanced Review (Your API Key) @@ -263,10 +295,11 @@ guardscan run How it works: -1. GuardScan analyzes your code locally -2. Sends anonymized context to **your AI provider** (using **your API key**) -3. AI provides insights and suggestions -4. Report saved locally +1. GuardScan runs its required local security and quality analysis. +2. If configured, it sends selected source-derived context to **your AI provider** using **your API key**. +3. AI findings enrich the local report; they never replace scanner coverage. +4. Incomplete required coverage is recorded in the report and exits with code `2`. +5. The report is saved locally. **You pay your AI provider directly** - GuardScan is free! @@ -280,18 +313,12 @@ No credit system. No subscriptions. No paywalls. ### AI Providers (If You Use AI Features) -**You pay them directly (not GuardScan):** - -- **OpenAI GPT-4**: ~$0.01-0.03 per 1K tokens -- **Claude Sonnet**: ~$0.003 per 1K tokens -- **Gemini Pro**: Free tier available -- **Ollama**: 100% free (runs locally) - -**Example costs for 10K LOC codebase:** +**You pay providers directly (not GuardScan):** -- Static analysis only: **$0** -- With OpenAI GPT-4: **~$2-5** (paid to OpenAI) -- With Ollama (local): **$0** +- Cloud providers bill through your own account and pricing plan. +- Ollama and LM Studio are local in offline mode only at literal `127/8` or `::1` endpoints. + Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Static analysis only does not require an AI provider. --- @@ -306,7 +333,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ ┌─────────────────────────────────────────────────────┐ │ │ │ GuardScan CLI (Node.js/TypeScript) │ │ │ │ │ │ -│ │ • 21 Commands (security, run, test, explain...) │ │ +│ │ • 29 Commands (security, vuln, run, test...) │ │ │ │ • 30 Core Modules (scanners, parsers, metrics) │ │ │ │ • 9 AI Features (explain, review, test-gen, etc.) │ │ │ │ • 7 Language Parsers (Python, Java, Go, Rust...) │ │ @@ -316,7 +343,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ │ Cache: ~/.guardscan/cache/ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ -│ │ Optional telemetry only │ +│ │ Explicit network actions only │ │ ▼ │ └───────────────────────────────────────────────────────────────┘ │ @@ -324,35 +351,35 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ │ ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────┐ -│ User's AI Provider │ │ GuardScan Backend │ -│ (User pays directly) │ │ (Optional telemetry) │ +│ User's AI Provider │ │ User-operated HTTPS │ +│ (User pays directly) │ │ telemetry endpoint │ │ │ │ │ -│ • OpenAI (GPT-4) │ │ Cloudflare Workers │ -│ • Anthropic (Claude) │ │ + Supabase │ -│ • Google (Gemini) │ │ │ -│ • Ollama (Local) │ │ • Health checks │ -│ │ │ • Anonymous telemetry │ +│ • OpenAI │ │ • Explicit sync only │ +│ • Anthropic │ │ • Aggregate allowlist │ +│ • Google Gemini │ │ • No default endpoint │ +│ • Ollama/LM Studio │ │ • No source or findings │ │ User's API Key → │ │ • NO source code │ -│ User's billing → │ │ • NO credit validation │ +│ User controls billing │ │ • Optional telemetry │ └──────────────────────────┘ └──────────────────────────┘ ``` ### Technology Stack -**CLI (34,213 LOC):** +**CLI:** - Language: TypeScript 5.3+ (strict mode) -- Runtime: Node.js 18+ +- Runtime: Node.js 22+ - Framework: Commander.js -- Testing: Jest (70%+ coverage) +- Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Monitoring service (separate repo, optional):** +**Self-hosted telemetry collector (optional):** -- Lives in [ntanwir10/GuardScan-Monitoring](https://github.com/ntanwir10/GuardScan-Monitoring) -- Platform: Cloudflare Workers + Supabase PostgreSQL -- Purpose: Anonymous telemetry and error/usage analytics -- The CLI talks to it over HTTP only; override the URL with `GUARDSCAN_API_URL` +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. +- Operators may deploy their own compatible HTTPS collector. +- Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. +- The payload is the strict aggregate event allowlist described in [PRIVACY.md](./PRIVACY.md); errors and findings are excluded. --- @@ -392,18 +419,16 @@ GuardScan is **open source** and we welcome contributions! - **Report bugs**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) - **Request features**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) -- **Submit PRs**: See [CONTRIBUTING.md](docs/CONTRIBUTING.md) +- **Submit PRs**: Open a focused pull request with tests and a clear rationale --- ## 📚 Documentation - [Installation Guide](docs/GETTING_STARTED.md) -- [Configuration Guide](docs/CONFIGURATION.md) - [Chat Guide](docs/CHAT_GUIDE.md) - [API Documentation](docs/API.md) -- [Security Scanners](docs/SECURITY_SCANNERS.md) -- [Contributing Guidelines](docs/CONTRIBUTING.md) +- [Dependency Vulnerability Scanning](docs/VULNERABILITY_SCANNING.md) --- @@ -422,10 +447,10 @@ A: Only if you want AI-enhanced review. Static analysis (9 security scanners) wo A: Your choice! OpenAI (powerful), Claude (balanced), Gemini (affordable), Ollama (free, local). **Q: Does GuardScan upload my code?** -A: **Never**. GuardScan only uploads anonymized metadata for optional telemetry. +A: GuardScan does not upload source code to GuardScan servers. If you enable AI features, prompts are sent directly to your configured provider; local caches may store prompts/responses on your machine. **Q: Can I disable telemetry?** -A: Yes! Run `guardscan config --telemetry=false` or set `telemetryEnabled: false` in `~/.guardscan/config.yml`. +A: Yes. Telemetry is disabled by default. `guardscan config --telemetry=false` persists the setting and deletes queued events, while `--no-telemetry` suppresses one invocation. **Q: How do I support this project?** A: Star the repo on GitHub, contribute code, report bugs, or sponsor the project! @@ -445,8 +470,7 @@ GuardScan is built with these amazing open-source tools: - [Commander.js](https://github.com/tj/commander.js) - CLI framework - [Chalk](https://github.com/chalk/chalk) - Terminal styling - [Axios](https://github.com/axios/axios) - HTTP client -- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless backend -- [Supabase](https://supabase.com/) - Open-source Firebase alternative +- [OSV](https://osv.dev/) - Open vulnerability data and package-version queries --- diff --git a/SECURITY.md b/SECURITY.md index 5caa0b3..37c3865 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,6 +6,7 @@ We actively support the following versions of GuardScan with security updates: | Version | Supported | | ------- | ------------------ | +| 1.1.x | :white_check_mark: | | 1.0.x | :white_check_mark: | | < 1.0 | :x: | @@ -76,8 +77,9 @@ We follow responsible disclosure practices: 5. **Network Security** - GuardScan works offline for static analysis - - Only AI features require network access - - Use `--no-telemetry` if you prefer not to send any data + - Cloud AI and fresh CVE lookups require network access; local AI is offline-only at literal loopback IP endpoints + - Use `--offline` to block GuardScan cloud, advisory, update, and telemetry clients + - Telemetry is opt-in and delivered only by an explicit `guardscan telemetry sync` to a user-operated endpoint selected with `GUARDSCAN_TELEMETRY_URL` ### For Developers @@ -108,6 +110,9 @@ GuardScan executes code analysis **locally** on your machine. This means: - ✅ No risk of code exposure through network transmission - ⚠️ GuardScan has read access to files you scan - ⚠️ Ensure you trust the codebase you're scanning +- ✅ `guardscan scan` does not execute repository-controlled code by default +- ⚠️ `--run-project-code` can run tests and linters that read files, change the repository, or use the network +- ✅ Child environments are scrubbed; `--isolate-project-network` requests an OS-backed network sandbox and fails if it is unavailable ### AI Provider Integration @@ -117,25 +122,31 @@ When using AI features: - API keys are sent directly to your chosen AI provider (OpenAI, Anthropic, etc.) - GuardScan does not store or log your API keys - Review your AI provider's privacy policy +- Offline Ollama and LM Studio endpoints must use literal `127/8` or `::1`; remote self-hosted endpoints require online mode plus `allowRemoteSelfHosted: true` ### Telemetry (Optional) If telemetry is enabled: -- Only metadata is sent (client_id, repo_id hash, LOC counts) -- No source code is transmitted -- You can disable with `--no-telemetry` flag -- Data is sent to our Cloudflare Workers backend (see PRIVACY.md) +- Only action, aggregate LOC, duration, coarse execution mode, event ID, and timestamp are queued +- Source, paths, prompts, responses, findings, dependency names, and errors are excluded +- GuardScan operates no hosted telemetry collector and provides no default endpoint +- Events remain local until `guardscan telemetry sync` is run against a user-operated HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL` +- You can suppress an invocation with `--no-telemetry` and inspect or clear the queue with `guardscan telemetry` ### Dependency Scanning GuardScan scans your dependencies for known vulnerabilities: -- Uses public vulnerability databases (npm audit, etc.) +- Uses exact local dependency inventory, the public OSV database, and optional CISA KEV enrichment +- Offline results require a fresh snapshot matching the current inventory +- Missing or incomplete coverage fails closed unless `--allow-partial` is explicit - Results are based on publicly available CVE data - May have false positives or miss zero-day vulnerabilities - Always verify critical findings independently +See [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for CVSS, CISA KEV, lockfile, and coverage limitations. + --- ## Security Features @@ -184,4 +195,4 @@ We appreciate the security research community's efforts to keep GuardScan secure --- -**Last Updated**: 2025-11-24 +**Last Updated**: 2026-07-13 diff --git a/catalog/homebrew-tap/.github/workflows/verify.yml b/catalog/homebrew-tap/.github/workflows/verify.yml new file mode 100644 index 0000000..e2b178b --- /dev/null +++ b/catalog/homebrew-tap/.github/workflows/verify.yml @@ -0,0 +1,306 @@ +name: Verify GuardScan channel catalog + +on: + pull_request: + paths: + - Formula/** + - bucket/** + - channel-lock.json + - .github/workflows/verify.yml + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: channel-catalog-${{ github.ref }} + cancel-in-progress: true + +env: + RELEASE_NODE_VERSION: 22.23.1 + SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed + SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939 + +jobs: + integrity: + name: Source and lock integrity + runs-on: ubuntu-24.04 + outputs: + published: ${{ steps.lock.outputs.published }} + source_commit: ${{ steps.lock.outputs.source_commit }} + manifest_url: ${{ steps.lock.outputs.manifest_url }} + manifest_sha256: ${{ steps.lock.outputs.manifest_sha256 }} + generator_repository: ${{ steps.lock.outputs.generator_repository }} + generator_commit: ${{ steps.lock.outputs.generator_commit }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Validate the untrusted lock before using it + id: lock + shell: bash + run: | + node <<'NODE' + const fs = require('fs'); + const fail = message => { throw new Error(message); }; + const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); + const commit = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); + const catalogFiles = [ + 'channel-lock.json', + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + ]; + const present = catalogFiles.filter(file => fs.existsSync(file)); + if (present.length === 0) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'published=false\n'); + process.stdout.write('Catalog is initialized but has no published GuardScan release.\n'); + process.exit(0); + } + if (present.length !== catalogFiles.length) { + fail(`catalog metadata is partially initialized: ${present.join(', ')}`); + } + const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1') fail('unsupported channel lock'); + if (lock.source?.repository !== 'ntanwir10/GuardScan') fail('unexpected source repository'); + if (lock.generator?.repository !== 'ntanwir10/GuardScan') fail('unexpected generator repository'); + if (!commit(lock.source?.commit) || !commit(lock.generator?.commit)) fail('invalid commit identity'); + if (lock.source.commit !== lock.generator.commit) fail('generator must come from the release source commit'); + if (lock.source?.tag !== `v${lock.source?.version}`) fail('tag and version do not match'); + const expectedUrl = + `https://github.com/ntanwir10/GuardScan/releases/download/${lock.source.tag}/release-manifest.json`; + if (lock.source?.manifestUrl !== expectedUrl) fail('manifest URL is not canonical'); + if (!sha(lock.source?.manifestSha256)) fail('invalid manifest digest'); + const expectedFiles = ['Formula/guardscan.rb', 'bucket/guardscan.json']; + if (JSON.stringify(Object.keys(lock.files || {}).sort()) !== JSON.stringify(expectedFiles)) { + fail('channel lock has unexpected generated files'); + } + for (const filename of expectedFiles) { + if (!sha(lock.files[filename]?.sha256)) fail(`invalid digest for ${filename}`); + } + const output = [ + 'published=true', + `source_commit=${lock.source.commit}`, + `manifest_url=${lock.source.manifestUrl}`, + `manifest_sha256=${lock.source.manifestSha256}`, + `generator_repository=${lock.generator.repository}`, + `generator_commit=${lock.generator.commit}`, + ].join('\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output}\n`); + NODE + + - name: Download and authenticate the immutable release manifest + if: steps.lock.outputs.published == 'true' + env: + MANIFEST_URL: ${{ steps.lock.outputs.manifest_url }} + MANIFEST_SHA256: ${{ steps.lock.outputs.manifest_sha256 }} + SOURCE_COMMIT: ${{ steps.lock.outputs.source_commit }} + shell: bash + run: | + curl --fail --location --proto '=https' --tlsv1.2 \ + --output release-manifest.json "$MANIFEST_URL" + node <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const manifestBytes = fs.readFileSync('release-manifest.json'); + const actual = crypto.createHash('sha256').update(manifestBytes).digest('hex'); + if (actual !== process.env.MANIFEST_SHA256) throw new Error('release manifest digest mismatch'); + const manifest = JSON.parse(manifestBytes); + const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); + if (manifest.version !== lock.source.version + || manifest.tag !== lock.source.tag + || manifest.commit !== process.env.SOURCE_COMMIT) { + throw new Error('release manifest source identity mismatch'); + } + NODE + + - name: Check out the exact GuardScan generator + if: steps.lock.outputs.published == 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ntanwir10/GuardScan + ref: ${{ steps.lock.outputs.source_commit }} + path: .guardscan-source + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: steps.lock.outputs.published == 'true' + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + cache: npm + cache-dependency-path: .guardscan-source/cli/package-lock.json + + - name: Reproduce every generated byte + if: steps.lock.outputs.published == 'true' + working-directory: .guardscan-source/cli + env: + CATALOG_ROOT: ${{ github.workspace }} + GENERATOR_REPOSITORY: ${{ steps.lock.outputs.generator_repository }} + GENERATOR_COMMIT: ${{ steps.lock.outputs.generator_commit }} + MANIFEST_URL: ${{ steps.lock.outputs.manifest_url }} + MANIFEST_SHA256: ${{ steps.lock.outputs.manifest_sha256 }} + shell: bash + run: | + npm ci + node scripts/release/index.js catalog \ + --manifest "$CATALOG_ROOT/release-manifest.json" \ + --output-dir "$CATALOG_ROOT" \ + --manifest-url "$MANIFEST_URL" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GENERATOR_REPOSITORY" \ + --generator-commit "$GENERATOR_COMMIT" \ + --check + cmp "$CATALOG_ROOT/.github/workflows/verify.yml" \ + "$CATALOG_ROOT/.guardscan-source/catalog/homebrew-tap/.github/workflows/verify.yml" + + homebrew: + name: Homebrew lifecycle + needs: integrity + runs-on: macos-15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Confirm the catalog is awaiting its first stable release + if: needs.integrity.outputs.published != 'true' + run: echo "Homebrew lifecycle will activate with the first generated catalog release." + - name: Style, audit, install, invoke, test, and uninstall + if: needs.integrity.outputs.published == 'true' + shell: bash + run: | + brew style Formula/guardscan.rb + brew audit --strict Formula/guardscan.rb + brew install --formula "$GITHUB_WORKSPACE/Formula/guardscan.rb" + guardscan --version + brew test guardscan + brew uninstall guardscan + + scoop: + name: Scoop lifecycle + needs: integrity + runs-on: windows-2025 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Confirm the catalog is awaiting its first stable release + if: needs.integrity.outputs.published != 'true' + shell: pwsh + run: Write-Output 'Scoop lifecycle will activate with the first generated catalog release.' + - name: Check out the pinned Scoop installer + if: needs.integrity.outputs.published == 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ScoopInstaller/Install + ref: ${{ env.SCOOP_INSTALL_COMMIT }} + path: .scoop-installer + persist-credentials: false + - name: Install the pinned Scoop CLI + if: needs.integrity.outputs.published == 'true' + shell: pwsh + run: | + $scoopRoot = Join-Path $env:RUNNER_TEMP 'scoop' + & .\.scoop-installer\install.ps1 -RunAsAdmin -ScoopDir $scoopRoot + if (-not $?) { throw 'Pinned Scoop installer failed' } + git -C "$scoopRoot\apps\scoop\current" fetch origin $env:SCOOP_COMMIT --depth 1 + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch the pinned Scoop CLI' } + git -C "$scoopRoot\apps\scoop\current" checkout --detach $env:SCOOP_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Unable to select the pinned Scoop CLI' } + "$scoopRoot\shims" | Out-File $env:GITHUB_PATH -Append + - name: Validate, install, invoke, and remove the portable archive + if: needs.integrity.outputs.published == 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifest = Get-Content -Raw -LiteralPath bucket/guardscan.json | ConvertFrom-Json + if ($manifest.version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + throw 'Invalid Scoop version' + } + $download = Join-Path $env:RUNNER_TEMP 'guardscan.zip' + Invoke-WebRequest -Uri $manifest.architecture.'64bit'.url -OutFile $download + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $download).Hash.ToLowerInvariant() + if ($actual -ne $manifest.architecture.'64bit'.hash) { + throw 'Scoop archive digest mismatch' + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($download) + try { + foreach ($entry in $archive.Entries) { + $name = $entry.FullName.Replace('\', '/') + if ($name.StartsWith('/') -or $name -match '(^|/)\.\.(/|$)' -or $name.Contains(':')) { + throw "Unsafe archive entry: $name" + } + } + } finally { + $archive.Dispose() + } + $install = Join-Path $env:RUNNER_TEMP 'guardscan-scoop-install' + Expand-Archive -LiteralPath $download -DestinationPath $install + $executables = @(Get-ChildItem -LiteralPath $install -Recurse -Filter guardscan.exe) + if ($executables.Count -ne 1) { throw 'Expected one guardscan.exe' } + $reported = & $executables[0].FullName --version + if ($LASTEXITCODE -ne 0 -or $reported -notmatch [regex]::Escape($manifest.version)) { + throw 'Installed Scoop executable failed its version check' + } + Remove-Item -LiteralPath $install -Recurse -Force + - name: Install, update-check, invoke, and uninstall through Scoop + if: needs.integrity.outputs.published == 'true' + shell: pwsh + run: | + scoop bucket add guardscan-catalog $env:GITHUB_WORKSPACE + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the local GuardScan bucket' } + scoop install guardscan-catalog/guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop installation failed' } + guardscan --version + if ($LASTEXITCODE -ne 0) { throw 'Scoop invocation failed' } + scoop update guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop update check failed' } + scoop uninstall guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop uninstall failed' } + scoop bucket rm guardscan-catalog + if ($LASTEXITCODE -ne 0) { throw 'Unable to remove the local GuardScan bucket' } + + notify: + name: Notify authoritative release ledger + if: >- + github.event_name == 'push' + && github.ref == 'refs/heads/main' + && needs.integrity.outputs.published == 'true' + needs: [integrity, homebrew, scoop] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Create a short-lived cross-repository app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - name: Send a non-authoritative catalog update hint + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + CATALOG_COMMIT: ${{ github.sha }} + shell: bash + run: | + node <<'NODE' > dispatch.json + const crypto = require('crypto'); + const fs = require('fs'); + const lock = fs.readFileSync('channel-lock.json'); + const lockDocument = JSON.parse(lock); + process.stdout.write(JSON.stringify({ + event_type: 'catalog_updated', + client_payload: { + repository: process.env.GITHUB_REPOSITORY, + version: lockDocument.source.version, + catalog_commit: process.env.CATALOG_COMMIT, + lock_sha256: crypto.createHash('sha256').update(lock).digest('hex'), + }, + })); + NODE + gh api --method POST repos/ntanwir10/GuardScan/dispatches --input dispatch.json diff --git a/catalog/homebrew-tap/README.md b/catalog/homebrew-tap/README.md new file mode 100644 index 0000000..0a18ee3 --- /dev/null +++ b/catalog/homebrew-tap/README.md @@ -0,0 +1,25 @@ +# GuardScan channel catalog + +This directory is the canonical bootstrap scaffold for +[`ntanwir10/homebrew-tap`](https://github.com/ntanwir10/homebrew-tap). +The public catalog repository contains both supported first-party package-manager +adapters: + +- `Formula/guardscan.rb` for Homebrew +- `bucket/guardscan.json` for Scoop +- `channel-lock.json`, which binds both generated files to one immutable + GuardScan release manifest and source commit + +GuardScan release automation owns catalog contents. Do not edit generated +formulae, manifests, or the lock by hand. Catalog pull requests must reproduce +byte-for-byte from the exact GuardScan commit named in `channel-lock.json`. + +The catalog is a generated projection, not a source mirror or a second release +authority. Its verification workflow is also sourced from this scaffold so that +catalog policy changes are reviewed with the release generator. + +Before the first verified stable native release, the catalog intentionally +contains only this README and its verification workflow. The workflow treats +that exact empty publication state as healthy; partial metadata is rejected. +The release train creates the formula, Scoop manifest, and cryptographic lock +together in one pull request. diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index b6eb35b..d0b9a12 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,6 +5,84 @@ All notable changes to GuardScan will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.1.0] - 2026-07-13 + +### Added + +- Static-safe `guardscan scan` execution with explicit `--run-project-code` trust capability, scrubbed child environments, report execution-mode metadata, and optional OS-backed network isolation. +- Official SPDX 2.3 and CycloneDX 1.7 serializers with vendored schema validation. +- Exact vulnerability snapshot validation and tri-state CISA KEV coverage metadata. +- Versioned release-manifest and release-state contracts with deterministic planning, dry-run, preparation, status, and resume tooling. +- Manifest-bound stable-promotion approval evidence, atomic publication-ledger transitions, and deterministic native package adapter validation. +- A fail-closed host-native Node.js SEA prototype builder and five-target non-publishable CI feasibility matrix. +- Packed-artifact compatibility gates for npm, pnpm, Yarn Modern, Yarn Classic, and Bun. +- Native OSV-backed dependency vulnerability scanning with `guardscan vuln`, `cve`, and `audit` commands. +- Exact-version inventory support across JavaScript, Python, Go, Rust, Ruby, and Maven projects. +- Offline vulnerability coverage snapshots and explicit database status/update/clear commands. +- Stable finding fingerprints, deterministic scanner execution, comprehensive versioned JSON, and schema-valid SARIF. +- Explicit telemetry status, sync, and clear commands with a strict privacy allowlist. +- Repository-scoped and global cache clearing. +- Windows and expanded Node.js CI coverage plus installed-package smoke tests. +- A machine-readable `guardscan capabilities --json` diagnostic that exercises + optional token-counting and chart-rendering boundaries and reports observed + safe fallback behavior. + +### Changed + +- `guardscan run` now always executes required local scanners before optional AI enrichment and records incomplete coverage as operational exit code `2`. +- Offline Ollama and LM Studio endpoints now require literal loopback IPs. Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Telemetry and metrics retention, clearing, status, and migration now process the complete state directory with journaled rollback. +- Self-hosted telemetry delivery now requires an explicit + `GUARDSCAN_TELEMETRY_URL`; consent alone only queues allowlisted events + locally until the user runs `guardscan telemetry sync`. +- Configuration parsing is size-, depth-, node-, alias-, and schema-bounded; read-modify-write updates are lease-serialized across processes. +- The supported Node.js runtime floor is now 22, with package smoke coverage on Node 22 and 24 across Linux, macOS, and Windows. +- Scanner failures and incomplete vulnerability coverage now fail closed with typed exit code `2` unless partial execution is explicitly allowed. +- CVE scanning is enabled by default when supported dependency manifests are present. +- Offline policy is enforced centrally for cloud AI, embeddings, update checks, telemetry, and advisory lookups. +- Environment-only cloud credentials and keyless Ollama/LM Studio configurations are supported. +- LM Studio endpoints are normalized to the documented `/v1` base path. +- Telemetry is opt-in and delivered only through an explicit sync command. +- License, SBOM, and vulnerability features share local dependency inventory. + +### Removed + +- The GuardScan-hosted Cloudflare telemetry service at + `api.guardscancli.com`, its implicit default endpoint, and the legacy + `GUARDSCAN_API_URL` setting. After `1.1.0` is published, the retired endpoint + returns `410 Gone` without accepting, storing, or redirecting data for seven + days and is then deleted. Users of `1.0.5` and earlier should upgrade or run + `guardscan config --telemetry=false`; self-hosted operators must use + `GUARDSCAN_TELEMETRY_URL`. Existing queues remain local. + +### Fixed + +- Telemetry clearing and retention leaving events stranded beyond the former directory scan limit. +- Stale lease owners deleting a newer owner’s lease under normal ownership checks. +- Partially committed telemetry or metrics migrations after metadata or identity conflicts. +- Redirect-following provider and telemetry transports. +- Deleted lint-baseline source files bypassing baseline review. +- Yarn installation failing on an unresolvable stale optional tokenizer package. +- Persisted metrics can no longer poison aggregation or crash `metrics show` through malformed optional fields. +- Telemetry status, retention, and synchronization now quarantine invalid or future-dated events before use. +- Legacy metrics and telemetry migrations validate completely before publishing any migrated event. +- Local metrics, telemetry, vulnerability, and CISA state now share bounded reads, atomic private writes, and bounded quarantine retention. +- Concurrent telemetry synchronization and conflicting same-identity metrics writes are serialized or rejected explicitly. +- Partial configuration sections are normalized against defaults, while unknown keys and unsafe values are rejected. +- Clean-checkout lint ratchet packaging. +- Fail-open dependency and security scanners. +- Empty offline SBOMs. +- Incomplete comprehensive JSON reports. +- Invalid SARIF pseudo-fixes. +- Semantic caching continuing under `--no-cache`. +- Expired cache entries remaining on disk. +- Release publishing without every quality gate. +- Windows `npm`/`npx` command resolution and shell-dependent child processes. + +The strict ESLint gate remains a per-file non-regression ratchet; this release does not claim that the existing codebase is lint-clean. + ## [1.0.5] - 2025-12-09 ### Added diff --git a/cli/README.md b/cli/README.md index afdf27f..50cc782 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,7 +13,7 @@ ``` [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org) +[![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org) --- @@ -25,8 +25,8 @@ GuardScan is **100% free and open source**! No credit system, no paywalls, no su - ✅ **Unlimited static analysis** - 9 security scanners + code quality tools - ✅ **AI-enhanced code review** - Bring your own API key (OpenAI, Claude, Gemini, Ollama) -- ✅ **Works fully offline** - No internet required for static analysis -- ✅ **Privacy-first** - Never uploads your source code +- ✅ **Offline-first static analysis** - Local scanners and SBOM inventory run without internet; CVEs can reuse a fresh matching snapshot +- ✅ **Privacy-first** - GuardScan does not upload your source code to GuardScan servers - ✅ **No usage limits** - Scan unlimited LOC, unlimited repositories --- @@ -40,9 +40,13 @@ npm install -g guardscan # Initialize GuardScan guardscan init -# Run comprehensive security scan (100% FREE, offline) +# Run comprehensive security scan (100% FREE, offline-first) guardscan security +# Audit exact dependency versions against OSV (online) +guardscan config --offline=false +guardscan vuln . + # Configure AI provider for enhanced review (optional, BYOK) guardscan config @@ -57,12 +61,12 @@ guardscan status ## 📋 Core Features -### 🔒 Security Scanning (FREE, Offline) +### 🔒 Security Scanning (FREE, Offline-First) GuardScan includes **comprehensive security scanners**: 1. **Secrets Detection** - Find hardcoded API keys, passwords, tokens (20+ patterns) -2. **Dependency Vulnerabilities** - Scan npm, pip, Maven, Cargo dependencies +2. **Dependency Vulnerabilities** - Scan exact npm, PyPI, Go, RubyGems, Cargo, and Maven versions with OSV; reuse fresh snapshots offline 3. **OWASP Top 10** - SQL injection, XSS, insecure configs, CSRF, XXE 4. **Docker Security** - Dockerfile and container scanning 5. **Infrastructure as Code** - Terraform, CloudFormation, Kubernetes security @@ -134,7 +138,7 @@ All commands are **100% FREE** with no limits! | Command | Description | | ------------------ | ------------------------------------- | -| `guardscan init` | Initialize config, generate client_id | +| `guardscan init` | Initialize local configuration | | `guardscan config` | Configure AI provider & settings | | `guardscan status` | Show configuration and repo info | | `guardscan reset` | Clear local cache & config | @@ -143,9 +147,27 @@ All commands are **100% FREE** with no limits! | Command | Description | | -------------------- | ----------------------------------------- | -| `guardscan security` | Run comprehensive security scan (offline) | -| `guardscan scan` | Quick security scan | -| `guardscan run` | AI-enhanced full code review (BYOK) | +| `guardscan security` | Run comprehensive security scanning | +| `guardscan scan` | Static-safe security, quality, and SBOM scan | +| `guardscan vuln` | Audit exact dependency versions with OSV | +| `guardscan run` | Required local review with optional AI enrichment | + +Dependency scanning details, offline snapshots, severity limitations, and CI examples are documented in [Dependency Vulnerability Scanning](../docs/VULNERABILITY_SCANNING.md). + +### CI output and exit codes + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan security --format sarif --output guardscan.sarif +``` + +Structured scan output uses the `guardscan.scan.v1` envelope and includes security, quality, SBOM, AI, scanner status, errors, and policy state. Exit code `0` means the run and policy passed, `1` means findings violated policy, and `2` means scanner coverage or execution failed. `--allow-partial` is an explicit relaxation for incomplete coverage. + +`guardscan scan` does not execute repository-controlled tests or linters by default. Use +`--run-project-code` only for a trusted repository; reports record whether they are +`static-analysis` or `project-code-executed`. Child processes receive a scrubbed +environment and isolated home. `--isolate-project-network` additionally requests an +OS-backed network sandbox and fails the affected checks if the platform sandbox is unavailable. ### Testing & Quality Commands @@ -159,22 +181,25 @@ All commands are **100% FREE** with no limits! | Command | Description | | ----------------- | ----------------------------------- | -| `guardscan sbom` | Generate Software Bill of Materials | +| `guardscan sbom` | Generate schema-valid SPDX 2.3 or CycloneDX 1.7 | | `guardscan rules` | Custom YAML-based rule engine | +| `guardscan cache` | Inspect or clear local AI caches | +| `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | +| `guardscan capabilities` | Inspect optional runtime capabilities and safe fallbacks | ### AI-Powered Commands (BYOK) | Command | Description | | --------------------------- | ------------------------------------ | -| `guardscan explain ` | Explain how code works | -| `guardscan review ` | Comprehensive AI code review | -| `guardscan commit` | Generate commit messages | -| `guardscan docs ` | Auto-generate documentation | -| `guardscan test-gen ` | Generate unit tests | -| `guardscan refactor ` | Get refactoring suggestions | -| `guardscan threat-model` | Security architecture analysis | -| `guardscan migrate` | Framework/language migration help | -| `guardscan chat` | Interactive Q&A about codebase (RAG) | +| `guardscan explain ` | Explain how code works | +| `guardscan review --file ` | Comprehensive AI code review | +| `guardscan commit` | Generate commit messages | +| `guardscan docs --type ` | Auto-generate documentation | +| `guardscan test-gen --file ` | Generate unit tests | +| `guardscan refactor --file ` | Get refactoring suggestions | +| `guardscan threat-model` | Security architecture analysis | +| `guardscan migrate` | Framework/language migration help | +| `guardscan chat` | Interactive Q&A about codebase (RAG) | --- @@ -182,7 +207,7 @@ All commands are **100% FREE** with no limits! We take privacy seriously: -### ❌ Never Stored or Transmitted +### ❌ No GuardScan-Hosted Processing - Your source code - File paths or file names @@ -190,25 +215,35 @@ We take privacy seriously: - API keys or secrets - Proprietary information -### ✅ Optional Telemetry (Anonymized) +Local AI/RAG features can store prompts, responses, file paths, and code-derived snippets in your machine's `~/.guardscan/cache` for caching and retrieval. Use `guardscan cache clear --repo --force` or `guardscan cache clear --all --force` to remove cached source-derived data. + +### ✅ Opt-In Aggregate Telemetry - Command usage (e.g., "security" command ran) - Execution duration - LOC count (aggregate number only) -- AI model used (e.g., "gpt-4") +- Coarse execution mode (static, local AI, or cloud AI) **Telemetry is:** -- Optional (easily disabled: `guardscan config --telemetry=false`) -- Completely anonymized -- Only used to improve GuardScan -- Never sold or shared +- Disabled by default and explicitly enabled with `guardscan config --telemetry=true` +- Queued locally only after consent +- Sent only by `guardscan telemetry sync` to a user-operated endpoint selected + with `GUARDSCAN_TELEMETRY_URL` +- Recording and delivery are suppressed by offline mode or `--no-telemetry` +- Never sent to a GuardScan-hosted collector; GuardScan operates no telemetry + ingestion service +- Strictly allowlisted; malformed or unknown persisted fields are quarantined locally and never sent +- Retained through bounded maintenance (newest 1,000 events, 30 days, 20 quarantine artifacts) +- Serialized across processes during explicit synchronization + +See the [Privacy Policy](../PRIVACY.md) for the exact event allowlist and local retention behavior. --- ## 🎯 How It Works -### Static Analysis (Offline, No AI) +### Static Analysis (Offline-First, No AI) ```bash guardscan security @@ -218,7 +253,7 @@ Runs **9 security scanners** locally: - Scans your codebase - Generates markdown report -- **100% offline** - no internet needed +- **Offline-first** - local scanners and local SBOM inventory run without internet; CVE results require either OSV access or a fresh matching snapshot - **100% free** - no limits ### AI-Enhanced Review (Your API Key) @@ -235,10 +270,11 @@ guardscan run How it works: -1. GuardScan analyzes your code locally -2. Sends anonymized context to **your AI provider** (using **your API key**) -3. AI provides insights and suggestions -4. Report saved locally +1. GuardScan runs its required local security and quality analysis. +2. If configured, it sends selected source-derived context to **your AI provider** using **your API key**. +3. AI findings enrich the local report; they never replace scanner coverage. +4. Incomplete required coverage is recorded in the report and exits with code `2`. +5. The report is saved locally. **You pay your AI provider directly** - GuardScan is free! @@ -252,18 +288,12 @@ No credit system. No subscriptions. No paywalls. ### AI Providers (If You Use AI Features) -**You pay them directly (not GuardScan):** - -- **OpenAI GPT-4**: ~$0.01-0.03 per 1K tokens -- **Claude Sonnet**: ~$0.003 per 1K tokens -- **Gemini Pro**: Free tier available -- **Ollama**: 100% free (runs locally) - -**Example costs for 10K LOC codebase:** +**You pay providers directly (not GuardScan):** -- Static analysis only: **$0** -- With OpenAI GPT-4: **~$2-5** (paid to OpenAI) -- With Ollama (local): **$0** +- Cloud providers bill through your own account and pricing plan. +- Ollama and LM Studio are local in offline mode only at literal `127/8` or `::1` endpoints. + Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Static analysis only does not require an AI provider. --- @@ -278,7 +308,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ ┌─────────────────────────────────────────────────────┐ │ │ │ GuardScan CLI (Node.js/TypeScript) │ │ │ │ │ │ -│ │ • 21 Commands (security, run, test, explain...) │ │ +│ │ • 29 Commands (security, vuln, run, test...) │ │ │ │ • 30 Core Modules (scanners, parsers, metrics) │ │ │ │ • 9 AI Features (explain, review, test-gen, etc.) │ │ │ │ • 7 Language Parsers (Python, Java, Go, Rust...) │ │ @@ -288,7 +318,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ │ Cache: ~/.guardscan/cache/ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ -│ │ Optional telemetry only │ +│ │ Explicit network actions only │ │ ▼ │ └───────────────────────────────────────────────────────────────┘ │ @@ -296,35 +326,35 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a │ │ ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────┐ -│ User's AI Provider │ │ GuardScan Backend │ -│ (User pays directly) │ │ (Optional telemetry) │ +│ User's AI Provider │ │ User-operated HTTPS │ +│ (User pays directly) │ │ telemetry endpoint │ │ │ │ │ -│ • OpenAI (GPT-4) │ │ Cloudflare Workers │ -│ • Anthropic (Claude) │ │ + Supabase │ -│ • Google (Gemini) │ │ │ -│ • Ollama (Local) │ │ • Health checks │ -│ │ │ • Anonymous telemetry │ +│ • OpenAI │ │ • Explicit sync only │ +│ • Anthropic │ │ • Aggregate allowlist │ +│ • Google Gemini │ │ • No default endpoint │ +│ • Ollama/LM Studio │ │ • No source or findings │ │ User's API Key → │ │ • NO source code │ -│ User's billing → │ │ • NO credit validation │ +│ User controls billing │ │ • Optional telemetry │ └──────────────────────────┘ └──────────────────────────┘ ``` ### Technology Stack -**CLI (34,213 LOC):** +**CLI:** - Language: TypeScript 5.3+ (strict mode) -- Runtime: Node.js 18+ +- Runtime: Node.js 22+ - Framework: Commander.js -- Testing: Jest (70%+ coverage) +- Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Backend (913 LOC - Optional):** +**Self-hosted telemetry collector (optional):** -- Platform: Cloudflare Workers (serverless) -- Database: Supabase PostgreSQL (optional) -- Purpose: Anonymous telemetry only -- Cost: $0-5/month (Cloudflare free tier) +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. +- Operators may deploy their own compatible HTTPS collector. +- Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. +- The payload is the strict aggregate event allowlist described in [PRIVACY.md](../PRIVACY.md); errors and findings are excluded. --- @@ -380,17 +410,16 @@ GuardScan is **open source** and we welcome contributions! - **Report bugs**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) - **Request features**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) -- **Submit PRs**: See [CONTRIBUTING.md](docs/CONTRIBUTING.md) +- **Submit PRs**: Open a focused pull request with tests and a clear rationale --- ## 📚 Documentation -- [Installation Guide](docs/GETTING_STARTED.md) -- [Configuration Guide](docs/CONFIGURATION.md) -- [API Documentation](docs/API.md) -- [Security Scanners](docs/SECURITY_SCANNERS.md) -- [Contributing Guidelines](docs/CONTRIBUTING.md) +- [Installation Guide](../docs/GETTING_STARTED.md) +- [AI Features](../docs/AI_QUICK_REFERENCE.md) +- [API Documentation](../docs/API.md) +- [Dependency Vulnerability Scanning](../docs/VULNERABILITY_SCANNING.md) --- @@ -409,10 +438,10 @@ A: Only if you want AI-enhanced review. Static analysis (9 security scanners) wo A: Your choice! OpenAI (powerful), Claude (balanced), Gemini (affordable), Ollama (free, local). **Q: Does GuardScan upload my code?** -A: **Never**. GuardScan only uploads anonymized metadata for optional telemetry. +A: GuardScan does not upload source code to GuardScan servers. If you enable AI features, prompts are sent directly to your configured provider; local caches may store prompts/responses on your machine. **Q: Can I disable telemetry?** -A: Yes! Run `guardscan config --telemetry=false` or set `telemetryEnabled: false` in `~/.guardscan/config.yml`. +A: Yes. Telemetry is disabled by default. `guardscan config --telemetry=false` persists the setting and deletes queued events, while `--no-telemetry` suppresses one invocation. **Q: How do I support this project?** A: Star the repo on GitHub, contribute code, report bugs, or sponsor the project! @@ -432,8 +461,7 @@ GuardScan is built with these amazing open-source tools: - [Commander.js](https://github.com/tj/commander.js) - CLI framework - [Chalk](https://github.com/chalk/chalk) - Terminal styling - [Axios](https://github.com/axios/axios) - HTTP client -- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless backend -- [Supabase](https://supabase.com/) - Open-source Firebase alternative +- [OSV](https://osv.dev/) - Open vulnerability data and package-version queries --- diff --git a/cli/__tests__/contracts/documentation-command-contracts.test.ts b/cli/__tests__/contracts/documentation-command-contracts.test.ts new file mode 100644 index 0000000..db23aef --- /dev/null +++ b/cli/__tests__/contracts/documentation-command-contracts.test.ts @@ -0,0 +1,116 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const repositoryRoot = path.resolve(__dirname, '../../..'); +const cliPath = path.join(repositoryRoot, 'cli', 'dist', 'index.js'); + +function runHelp(command?: string): string { + const args = command ? [cliPath, command, '--help'] : [cliPath, '--help']; + const result = spawnSync(process.execPath, args, { + cwd: repositoryRoot, + encoding: 'utf8', + shell: false, + env: { + ...process.env, + GUARDSCAN_HOME: path.join(os.tmpdir(), 'guardscan-documentation-contract'), + GUARDSCAN_NO_TELEMETRY: 'true', + GUARDSCAN_OFFLINE: 'true', + NO_COLOR: '1', + FORCE_COLOR: '0', + }, + windowsHide: true, + }); + + if (result.error) throw result.error; + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + return result.stdout; +} + +function documentedQuickstartCommands(quickstart: string): string[] { + const inventory = quickstart.match( + /## 📋 Available Commands([\s\S]*?)\n---/ + )?.[1]; + if (!inventory) throw new Error('QUICKSTART.md has no bounded Available Commands section'); + return Array.from(inventory.matchAll(/^guardscan\s+([a-z][a-z-]*)/gm), match => match[1]); +} + +function actualTopLevelCommands(help: string): string[] { + const inventory = help.match(/^Commands:\s*$([\s\S]*)/m)?.[1]; + if (!inventory) throw new Error('guardscan --help has no Commands section'); + return Array.from( + inventory.matchAll(/^ ([a-z][a-z-]*)(?:\|[a-z-]+)?(?:\s|\[|<)/gm), + match => match[1] + ).filter(command => command !== 'help'); +} + +function documentedAcceptanceCommands(acceptance: string): string[] { + const inventory = acceptance.match( + /## Command acceptance matrix([\s\S]*?)(?=\n## )/ + )?.[1]; + if (!inventory) { + throw new Error('docs/FUNCTIONAL_ACCEPTANCE.md has no bounded command acceptance matrix'); + } + return Array.from( + inventory.matchAll(/^\| `guardscan ([a-z][a-z-]*)` \|/gm), + match => match[1] + ); +} + +describe('public command documentation contracts', () => { + const readme = fs.readFileSync(path.join(repositoryRoot, 'README.md'), 'utf8'); + const cliReadme = fs.readFileSync(path.join(repositoryRoot, 'cli', 'README.md'), 'utf8'); + const quickstart = fs.readFileSync(path.join(repositoryRoot, 'QUICKSTART.md'), 'utf8'); + const changelog = fs.readFileSync(path.join(repositoryRoot, 'cli', 'CHANGELOG.md'), 'utf8'); + + it('keeps all prepared 1.1.0 changes in one release section', () => { + const headings = changelog.match(/^## \[1\.1\.0\](?:\s|$)/gm) || []; + const release = changelog.match( + /^## \[1\.1\.0\][\s\S]*?(?=^## \[1\.0\.5\])/m + )?.[0]; + + expect(headings).toHaveLength(1); + expect(release).toContain('Static-safe `guardscan scan` execution'); + expect(release).toContain('Native OSV-backed dependency vulnerability scanning'); + expect(release).toContain('The GuardScan-hosted Cloudflare telemetry service'); + }); + + it('keeps the documented 29-command inventory identical to installed CLI help', () => { + const documented = documentedQuickstartCommands(quickstart); + const actual = actualTopLevelCommands(runHelp()); + + expect(new Set(documented).size).toBe(29); + expect(documented).toHaveLength(29); + expect(new Set(actual).size).toBe(29); + expect(actual).toHaveLength(29); + expect([...documented].sort()).toEqual([...actual].sort()); + }); + + it('classifies every installed command exactly once in the functional acceptance matrix', () => { + const acceptance = fs.readFileSync( + path.join(repositoryRoot, 'docs', 'FUNCTIONAL_ACCEPTANCE.md'), + 'utf8' + ); + const documented = documentedAcceptanceCommands(acceptance); + const actual = actualTopLevelCommands(runHelp()); + + expect(documented).toHaveLength(29); + expect(new Set(documented).size).toBe(29); + expect([...documented].sort()).toEqual([...actual].sort()); + }); + + it.each([ + ['review', 'guardscan review --file ', '--file ', 'guardscan review '], + ['docs', 'guardscan docs --type ', '--type ', 'guardscan docs '], + ['test-gen', 'guardscan test-gen --file ', '--file ', 'guardscan test-gen '], + ['refactor', 'guardscan refactor --file ', '--file ', 'guardscan refactor '], + ])('documents supported %s option syntax', (command, documented, helpOption, obsolete) => { + for (const document of [readme, cliReadme]) { + expect(document).toContain(`\`${documented}\``); + expect(document).not.toContain(`\`${obsolete}\``); + } + expect(runHelp(command)).toContain(helpOption); + }); +}); diff --git a/cli/__tests__/contracts/release-contracts.test.ts b/cli/__tests__/contracts/release-contracts.test.ts index 9d42250..1df592f 100644 --- a/cli/__tests__/contracts/release-contracts.test.ts +++ b/cli/__tests__/contracts/release-contracts.test.ts @@ -88,6 +88,22 @@ function makeManifest(): JsonDocument { sha256: 'd'.repeat(64), source, capabilities, + optionalCapabilities: { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }, platform: { os: 'linux', arch: 'x64', libc: 'glibc' }, archiveFormat: 'tar.gz', entrypoint: 'guardscan', @@ -197,6 +213,40 @@ function makeState(): JsonDocument { }; } +function makeStateV2(): JsonDocument { + return { + schemaVersion: 'guardscan.release-state.v2', + version: '1.2.0-rc.1', + tag: 'v1.2.0-rc.1', + commit, + updatedAt: timestamp, + lastSequence: 2, + lastEventHash: 'c'.repeat(64), + manifestSha256: digest, + channels: { + npm: { + status: 'published', + artifactIds: ['npm:guardscan@1.2.0-rc.1'], + updatedAt: timestamp, + remoteIdentity: 'npm:guardscan@1.2.0-rc.1', + remoteDigest: digest, + publication: { + schemaVersion: 'guardscan.provider-publication.v1', + channel: 'npm', + version: '1.2.0-rc.1', + tag: 'v1.2.0-rc.1', + remoteIdentity: 'npm:guardscan@1.2.0-rc.1', + aggregateSha256: digest, + files: {'guardscan-1.2.0-rc.1.tgz': digest}, + }, + }, + }, + canaries: {}, + incidents: {}, + actionRequired: [], + }; +} + function makeApproval(): JsonDocument { return { schemaVersion: 'guardscan.release-approval.v1', @@ -220,8 +270,11 @@ function makeApproval(): JsonDocument { describe('release contract schemas', () => { const validateApproval = loadValidator('guardscan.release-approval.v1.schema.json'); + const validateCatalog = loadValidator('guardscan.channel-catalog.v1.schema.json'); + const validateEvent = loadValidator('guardscan.release-event.v1.schema.json'); const validateManifest = loadValidator('guardscan.release-manifest.v1.schema.json'); const validateState = loadValidator('guardscan.release-state.v1.schema.json'); + const validateStateV2 = loadValidator('guardscan.release-state.v2.schema.json'); it('accepts representative release manifest and state documents', () => { const manifest = makeManifest(); @@ -233,6 +286,172 @@ describe('release contract schemas', () => { expect(validateState.errors).toBeNull(); expect(validateApproval(makeApproval())).toBe(true); expect(validateApproval.errors).toBeNull(); + expect(validateStateV2(makeStateV2())).toBe(true); + expect(validateStateV2.errors).toBeNull(); + }); + + it('requires provider evidence for published primary v2 channels and rejects unknown channels', () => { + const missingPublication = clone(makeStateV2()) as { + channels: {npm: Record}; + }; + delete missingPublication.channels.npm.publication; + expect(validateStateV2(missingPublication)).toBe(false); + + const unknownChannel = clone(makeStateV2()) as {channels: Record}; + unknownChannel.channels.unknown = { + status: 'planned', artifactIds: [], updatedAt: timestamp, + }; + expect(validateStateV2(unknownChannel)).toBe(false); + }); + + it('validates exact known-good and first-release recovery state shapes', () => { + const knownGood = clone(makeStateV2()) as Record; + knownGood.recovery = { + status: 'started', + startedAt: timestamp, + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }; + expect(validateStateV2(knownGood)).toBe(true); + + const firstRelease = clone(makeStateV2()) as Record; + firstRelease.recovery = { + status: 'provider-actions-pending', + startedAt: timestamp, + repositoryCompletedAt: timestamp, + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + externalActionsPending: ['npm'], + evidence: {schemaVersion: 'guardscan.rollback-repository-evidence.v1'}, + }; + firstRelease.incidents = { + 'first-release-withdrawal-v1.2.0': { + kind: 'recovery', + status: 'open', + openedAt: timestamp, + summary: 'The first stable release requires provider withdrawal', + }, + }; + expect(validateStateV2(firstRelease)).toBe(true); + + firstRelease.recovery.knownGoodVersion = '1.1.9'; + expect(validateStateV2(firstRelease)).toBe(false); + delete firstRelease.recovery.knownGoodVersion; + delete firstRelease.recovery.mode; + expect(validateStateV2(firstRelease)).toBe(false); + firstRelease.recovery.mode = 'first-release-withdrawal'; + firstRelease.incidents['first-release-withdrawal-v1.2.0'].kind = 'deployment'; + expect(validateStateV2(firstRelease)).toBe(false); + }); + + it('binds the shared channel catalog to GuardScan source, generator, and file digests', () => { + const catalog = { + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.3', + tag: 'v1.2.3', + commit, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.3/release-manifest.json', + manifestSha256: digest, + }, + generator: { + repository: 'ntanwir10/GuardScan', + commit, + }, + files: { + 'Formula/guardscan.rb': {sha256: 'c'.repeat(64)}, + 'bucket/guardscan.json': {sha256: 'd'.repeat(64)}, + }, + }; + expect(validateCatalog(catalog)).toBe(true); + expect(validateCatalog.errors).toBeNull(); + + const circular = clone(catalog) as Record; + circular.catalogCommit = commit; + expect(validateCatalog(circular)).toBe(false); + + const unknownFile = clone(catalog) as {files: Record}; + unknownFile.files['unmanaged.txt'] = {sha256: 'e'.repeat(64)}; + expect(validateCatalog(unknownFile)).toBe(false); + }); + + it('models Homebrew Core as an explicit release event channel', () => { + expect(validateEvent({ + schemaVersion: 'guardscan.release-event.v1', + version: '1.2.3', + tag: 'v1.2.3', + commit, + sequence: 2, + previousHash: 'c'.repeat(64), + timestamp, + type: 'channel_submitted', + channel: 'homebrew-core', + idempotencyKey: 'homebrew-core:v1.2.3', + payload: {}, + eventHash: 'd'.repeat(64), + })).toBe(true); + }); + + it('validates both rollback modes and exact first-release repository evidence', () => { + const base = { + schemaVersion: 'guardscan.release-event.v1', + version: '1.2.0', + tag: 'v1.2.0', + commit, + sequence: 2, + previousHash: 'c'.repeat(64), + timestamp, + idempotencyKey: 'rollback:v1.2.0', + eventHash: 'd'.repeat(64), + }; + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })).toBe(true); + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: { + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })).toBe(true); + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: { + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + knownGoodVersion: '1.1.9', + }, + })).toBe(false); + expect(validateEvent({ + ...base, + type: 'rollback_repository_completed', + idempotencyKey: 'rollback-repository-completed:1.2.0', + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: ['npm'], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 0, + commit: 'e'.repeat(40), + }, + completedAt: timestamp, + }, + })).toBe(true); }); it('rejects broad, malformed, and untrusted promotion approvals', () => { @@ -287,6 +506,22 @@ describe('release contract schemas', () => { expect(validateManifest(unknownSignature)).toBe(false); }); + it('requires observed reduced-capability evidence on standalone artifacts', () => { + const missing = clone(makeManifest()) as { + artifacts: Array<{optionalCapabilities?: Record}>; + }; + delete missing.artifacts[1].optionalCapabilities; + expect(validateManifest(missing)).toBe(false); + + const inconsistent = clone(makeManifest()) as { + artifacts: Array<{ + optionalCapabilities?: {tokenCounting: {dependencyAvailable: boolean}}; + }>; + }; + inconsistent.artifacts[1].optionalCapabilities!.tokenCounting.dependencyAvailable = true; + expect(validateManifest(inconsistent)).toBe(false); + }); + it('rejects invalid state transitions, channel names, and unknown fields', () => { const badStatus = clone(makeState()) as { channels: Record; diff --git a/cli/__tests__/contracts/monitoring-api.contract.test.ts b/cli/__tests__/contracts/telemetry-collector.contract.test.ts similarity index 67% rename from cli/__tests__/contracts/monitoring-api.contract.test.ts rename to cli/__tests__/contracts/telemetry-collector.contract.test.ts index 1d502f7..fdb53d0 100644 --- a/cli/__tests__/contracts/monitoring-api.contract.test.ts +++ b/cli/__tests__/contracts/telemetry-collector.contract.test.ts @@ -1,18 +1,18 @@ /** - * Monitoring API contract tests. + * Telemetry collector contract tests. * * These tests pin the request paths and payload envelopes emitted by the CLI * telemetry client. */ import axios from 'axios'; -import { APIClient, TelemetryRequest } from '../../src/utils/api-client'; +import { TelemetryClient, TelemetryRequest } from '../../src/utils/telemetry-client'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; -describe('Monitoring API contracts', () => { +describe('Telemetry collector contracts', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -22,10 +22,9 @@ describe('Monitoring API contracts', () => { const post = jest.fn().mockResolvedValue({ status: 202 }); mockedAxios.create.mockReturnValue({ post, - get: jest.fn(), } as any); - const client = new APIClient('https://monitoring.example'); + const client = new TelemetryClient('https://telemetry.example'); const payload: TelemetryRequest = { schemaVersion: 'guardscan.telemetry.v1', batchId: '00000000-0000-4000-8000-000000000001', @@ -56,24 +55,11 @@ describe('Monitoring API contracts', () => { expect(mockedAxios.create).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: 'https://monitoring.example', + baseURL: 'https://telemetry.example', headers: { 'Content-Type': 'application/json' }, }) ); expect(post).toHaveBeenCalledWith('/api/telemetry', payload); }); - - it('checks API health with GET /health', async () => { - const get = jest.fn().mockResolvedValue({ status: 200 }); - mockedAxios.create.mockReturnValue({ - post: jest.fn(), - get, - } as any); - - const client = new APIClient('https://monitoring.example'); - await expect(client.ping()).resolves.toBe(true); - - expect(get).toHaveBeenCalledWith('/health', { timeout: 3000 }); - }); }); }); diff --git a/cli/__tests__/core/telemetry.test.ts b/cli/__tests__/core/telemetry.test.ts index ee43f2d..4ca8f1e 100644 --- a/cli/__tests__/core/telemetry.test.ts +++ b/cli/__tests__/core/telemetry.test.ts @@ -28,6 +28,7 @@ describe('TelemetryManager', () => { let stateDir: string; let legacyCacheDir: string; const oldUrl = process.env.GUARDSCAN_TELEMETRY_URL; + const oldLegacyApiUrl = process.env.GUARDSCAN_API_URL; const oldOffline = process.env.GUARDSCAN_OFFLINE; const oldNoTelemetry = process.env.GUARDSCAN_NO_TELEMETRY; @@ -37,6 +38,7 @@ describe('TelemetryManager', () => { stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-telemetry-')); legacyCacheDir = path.join(stateDir, 'cache'); process.env.GUARDSCAN_TELEMETRY_URL = 'https://telemetry.example'; + delete process.env.GUARDSCAN_API_URL; delete process.env.GUARDSCAN_NO_TELEMETRY; delete process.env.GUARDSCAN_OFFLINE; }); @@ -45,6 +47,8 @@ describe('TelemetryManager', () => { fs.rmSync(stateDir, { recursive: true, force: true }); if (oldUrl === undefined) delete process.env.GUARDSCAN_TELEMETRY_URL; else process.env.GUARDSCAN_TELEMETRY_URL = oldUrl; + if (oldLegacyApiUrl === undefined) delete process.env.GUARDSCAN_API_URL; + else process.env.GUARDSCAN_API_URL = oldLegacyApiUrl; if (oldOffline === undefined) delete process.env.GUARDSCAN_OFFLINE; else process.env.GUARDSCAN_OFFLINE = oldOffline; if (oldNoTelemetry === undefined) delete process.env.GUARDSCAN_NO_TELEMETRY; @@ -89,6 +93,36 @@ describe('TelemetryManager', () => { await expect(manager.sync()).rejects.toThrow('disabled for this command'); }); + it('rejects the removed legacy endpoint alias without creating a network transport', async () => { + delete process.env.GUARDSCAN_TELEMETRY_URL; + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + const manager = new TelemetryManager(config(), stateDir); + + expect(manager.getStats().endpointConfigured).toBe(false); + await expect(manager.sync()).rejects.toThrow( + 'Set GUARDSCAN_TELEMETRY_URL to your self-hosted collector URL before syncing.' + ); + expect(mockedAxios.create).not.toHaveBeenCalled(); + }); + + it('uses only the explicit telemetry endpoint when the removed alias is also present', async () => { + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + const post = jest.fn().mockImplementation((_url, request) => Promise.resolve({ + status: 202, + data: { status: 'accepted', batchId: request.batchId, accepted: request.events.length }, + })); + mockedAxios.create.mockReturnValue({ post } as any); + const manager = new TelemetryManager(config(), stateDir); + await manager.record({ action: 'scan', loc: 1, durationMs: 2 }); + + await expect(manager.sync()).resolves.toEqual({ sent: 1, remaining: 0 }); + + expect(mockedAxios.create).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://telemetry.example', + })); + expect(post).toHaveBeenCalledTimes(1); + }); + it('does not lose events recorded by concurrent managers', async () => { const managers = Array.from({ length: 25 }, () => new TelemetryManager(config(), stateDir)); await Promise.all(managers.map((manager, index) => manager.record({ diff --git a/cli/__tests__/integration/ai-providers-enhanced.test.ts b/cli/__tests__/integration/ai-providers-enhanced.test.ts index 8f41c56..0907958 100644 --- a/cli/__tests__/integration/ai-providers-enhanced.test.ts +++ b/cli/__tests__/integration/ai-providers-enhanced.test.ts @@ -4,21 +4,31 @@ * Tests the full decorator stack and end-to-end scenarios. */ -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { ProviderFactory } from '../../src/providers/factory'; import { AIProvider, AIMessage } from '../../src/providers/base'; import { MetricsCollector } from '../../src/core/metrics-collector'; import { Config } from '../../src/core/config'; +import { RetryProvider } from '../../src/providers/decorators/retry-provider'; +import { + CircuitBreakerProvider, + CircuitState, +} from '../../src/providers/decorators/circuit-breaker-provider'; +import { CachedProvider } from '../../src/providers/decorators/cached-provider'; +import { RateLimitedProvider } from '../../src/providers/decorators/rate-limited-provider'; +import { ObservableProvider } from '../../src/providers/decorators/observable-provider'; // Mock provider for testing class TestProvider extends AIProvider { private failureCount = 0; private maxFailures: number; private callCount = 0; + private providerName: string; - constructor(maxFailures: number = 0) { + constructor(maxFailures: number = 0, providerName: string = 'Test') { super(); this.maxFailures = maxFailures; + this.providerName = providerName; } async chat(messages: AIMessage[]) { @@ -55,7 +65,7 @@ class TestProvider extends AIProvider { } getName() { - return 'Test'; + return this.providerName; } async testConnection() { @@ -77,6 +87,15 @@ class TestProvider extends AIProvider { } describe('Enhanced AI Provider Integration', () => { + const uniqueId = (prefix: string) => ( + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const messages: AIMessage[] = [{ role: 'user', content: 'Explain this code path.' }]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('decorator stack', () => { it('should create provider with all decorators', () => { const config: Config = { @@ -130,37 +149,137 @@ describe('Enhanced AI Provider Integration', () => { describe('retry with circuit breaker', () => { it('should retry failures and track in circuit breaker', async () => { - // This test verifies retry and circuit breaker work together - // In practice, this would use mocked providers - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const base = new TestProvider(1, uniqueId('retry-circuit')); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const circuit = new CircuitBreakerProvider(retry, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + halfOpenSuccessThreshold: 1, + monitoredErrors: ['500'], + }); + + await expect(circuit.chat(messages)).resolves.toMatchObject({ content: 'Success' }); + expect(base.getCallCount()).toBe(2); + expect(circuit.getState()).toBe(CircuitState.CLOSED); + expect(circuit.getStats()).toMatchObject({ + totalFailures: 0, + totalSuccesses: 1, + circuitOpenCount: 0, + }); + expect(warning).toHaveBeenCalledTimes(1); }); }); describe('caching with observability', () => { it('should track cache hits in metrics', async () => { - // This test verifies cache hits are properly tracked by observability - expect(true).toBe(true); // Placeholder + const id = uniqueId('cache-observability'); + const base = new TestProvider(0, id); + const cached = new CachedProvider(base, id, undefined, { + useSemanticSimilarity: false, + }); + const metrics = new MetricsCollector(id); + const observable = new ObservableProvider(cached, metrics); + + const first = await observable.chat(messages); + const second = await observable.chat(messages); + + expect(first.content).toBe('Success'); + expect(second.model).toContain('cached'); + expect(base.getCallCount()).toBe(1); + expect(cached.getCacheStats()).toMatchObject({ hits: 1, misses: 1 }); + expect(metrics.getMetrics()).toMatchObject({ + totalCalls: 2, + successRate: 100, + cacheHitRate: 50, + }); + expect(metrics.getSpans().map(span => span.cacheHit)).toEqual([false, true]); + + await cached.clearCache(); }); }); describe('rate limiting with retry', () => { it('should rate limit and retry on failures', async () => { - // This test verifies rate limiting and retry work together - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const base = new TestProvider(1, uniqueId('rate-retry')); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const rateLimited = new RateLimitedProvider(retry, { + maxTokens: 100, + refillRate: 1, + costMultiplier: 1, + }); + + await expect(rateLimited.chat(messages)).resolves.toMatchObject({ content: 'Success' }); + expect(base.getCallCount()).toBe(2); + expect(rateLimited.getStats()).toMatchObject({ + maxTokens: 100, + totalWaits: 0, + }); + expect(rateLimited.getStats().currentTokens).toBeLessThan(100); + expect(warning).toHaveBeenCalledTimes(1); }); }); describe('full stack end-to-end', () => { it('should handle complex scenario with all features', async () => { - // This test runs a full scenario: - // 1. Request made - // 2. Rate limited (waits) - // 3. Fails first time - // 4. Retries successfully - // 5. Cached for next request - // 6. All tracked by observability - // 7. Circuit breaker stays closed - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const id = uniqueId('full-stack'); + const base = new TestProvider(1, id); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const rateLimited = new RateLimitedProvider(retry, { + maxTokens: 1_000, + refillRate: 1, + costMultiplier: 1, + }); + const circuit = new CircuitBreakerProvider(rateLimited, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + halfOpenSuccessThreshold: 1, + monitoredErrors: ['500'], + }); + const cached = new CachedProvider(circuit, id, undefined, { + useSemanticSimilarity: false, + }); + const metrics = new MetricsCollector(id); + const enhanced = new ObservableProvider(cached, metrics); + + const first = await enhanced.chat(messages); + const second = await enhanced.chat(messages); + + expect(first.content).toBe('Success'); + expect(second.model).toContain('cached'); + expect(base.getCallCount()).toBe(2); + expect(rateLimited.getStats().currentTokens).toBeLessThan(1_000); + expect(circuit.getStats()).toMatchObject({ + state: CircuitState.CLOSED, + totalFailures: 0, + totalSuccesses: 1, + }); + expect(cached.getCacheStats()).toMatchObject({ hits: 1, misses: 1 }); + expect(metrics.getMetrics()).toMatchObject({ + totalCalls: 2, + successRate: 100, + cacheHitRate: 50, + }); + expect(warning).toHaveBeenCalledTimes(1); + + await cached.clearCache(); }); }); }); diff --git a/cli/__tests__/integration/rag-e2e.test.ts b/cli/__tests__/integration/rag-e2e.test.ts index 4bb92a6..9126a8c 100644 --- a/cli/__tests__/integration/rag-e2e.test.ts +++ b/cli/__tests__/integration/rag-e2e.test.ts @@ -1,403 +1,247 @@ /** - * rag-e2e.test.ts - End-to-End Integration Tests for RAG & Chat System + * Deterministic end-to-end coverage for the local RAG pipeline. * - * These tests verify the entire RAG pipeline works correctly from - * indexing to search to context building to chat responses. + * The provider doubles only the external embedding/chat boundaries. Real + * repository indexing, chunking, persistence, retrieval, context budgeting, + * and conversation state are exercised without network access or API keys. */ -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; - -import { describe, expect, it, beforeEach, afterAll } from "@jest/globals"; - -describe("RAG System End-to-End", () => { - let testRepoDir: string; - let testCacheDir: string; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {afterEach, beforeEach, describe, expect, it} from '@jest/globals'; + +import {ChatbotEngine} from '../../src/core/chatbot-engine'; +import {CodebaseIndexer} from '../../src/core/codebase-indexer'; +import {EmbeddingChunker} from '../../src/core/embedding-chunker'; +import {EmbeddingIndexer} from '../../src/core/embedding-indexer'; +import {EmbeddingSearchEngine} from '../../src/core/embedding-search'; +import {FileBasedEmbeddingStore} from '../../src/core/embedding-store'; +import {EmbeddingProvider} from '../../src/core/embeddings'; +import {RAGContextBuilder} from '../../src/core/rag-context'; +import {AIMessage, AIProvider, AIResponse, ProviderCapabilities} from '../../src/providers/base'; + +class DeterministicEmbeddingProvider implements EmbeddingProvider { + getName(): string { return 'deterministic-local'; } + getDimensions(): number { return 4; } + getModel(): string { return 'keyword-v1'; } + estimateCost(): number { return 0; } + isAvailable(): boolean { return true; } + async testConnection(): Promise { return true; } + + async generateEmbedding(text: string): Promise { + const normalized = text.toLowerCase(); + const score = (terms: string[]) => terms.reduce( + (total, term) => total + (normalized.match(new RegExp(term, 'g')) || []).length, + 0 + ); + return [ + score(['auth', 'login', 'credential', 'password', 'verify']) + 0.01, + score(['user', 'account', 'profile', 'create']) + 0.01, + score(['database', 'lookup', 'save', 'persist']) + 0.01, + score(['readme', 'getting started', 'install', 'documentation']) + 0.01, + ]; + } - beforeEach(() => { - // Create a test repository with sample code - testRepoDir = path.join(os.tmpdir(), `test-repo-${Date.now()}`); - testCacheDir = path.join(os.tmpdir(), `test-cache-${Date.now()}`); + async generateBulkEmbeddings(texts: string[]): Promise { + return Promise.all(texts.map(text => this.generateEmbedding(text))); + } +} - fs.mkdirSync(testRepoDir, { recursive: true }); - fs.mkdirSync(path.join(testRepoDir, "src"), { recursive: true }); +class DeterministicChatProvider extends AIProvider { + readonly prompts: string[] = []; - // Create sample files - fs.writeFileSync( - path.join(testRepoDir, "src", "auth.ts"), - ` -export class AuthService { - /** - * Authenticate user with username and password - */ - async authenticate(username: string, password: string): Promise { - // Validate credentials - const user = await this.findUser(username); - if (!user) return false; - - return this.verifyPassword(user, password); + getCapabilities(): ProviderCapabilities { + return { + supportsChat: true, + supportsEmbeddings: false, + supportsStreaming: false, + maxContextTokens: 4096, + }; } - private async findUser(username: string) { - // Database lookup - return null; + async chat(messages: AIMessage[]): Promise { + const prompt = messages.map(message => message.content).join('\n'); + this.prompts.push(prompt); + return { + content: prompt.includes('AuthService') + ? 'Authentication is implemented by AuthService.' + : 'The indexed context was used.', + model: 'deterministic-chat-v1', + usage: {promptTokens: 20, completionTokens: 8, totalTokens: 28}, + }; } - private verifyPassword(user: any, password: string): boolean { - // Password verification logic - return true; + async *stream(): AsyncGenerator { + yield 'unused'; } -} - ` - ); - fs.writeFileSync( - path.join(testRepoDir, "src", "user.ts"), - ` -export interface User { - id: string; - username: string; - email: string; - createdAt: Date; + isAvailable(): boolean { return true; } + getName(): string { return 'deterministic-chat'; } + async testConnection(): Promise { return true; } } +describe('RAG system end to end', () => { + let repository: string; + let stateRoot: string; + let indexer: CodebaseIndexer; + let store: FileBasedEmbeddingStore; + let embeddingIndexer: EmbeddingIndexer; + let search: EmbeddingSearchEngine; + let contextBuilder: RAGContextBuilder; + + beforeEach(async () => { + repository = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-rag-repo-')); + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-rag-state-')); + fs.mkdirSync(path.join(repository, 'src'), {recursive: true}); + fs.writeFileSync(path.join(repository, 'src', 'auth.ts'), ` +export class AuthService { + async authenticate(username: string, password: string): Promise { + const user = await this.findUser(username); + return user ? this.verifyPassword(user, password) : false; + } + private async findUser(username: string) { return { username }; } + private verifyPassword(_user: unknown, password: string) { return password.length > 0; } +} +`); + fs.writeFileSync(path.join(repository, 'src', 'user.ts'), ` +export interface User { id: string; username: string; email: string; } export class UserService { async createUser(username: string, email: string): Promise { - const user: User = { - id: this.generateId(), - username, - email, - createdAt: new Date(), - }; - - await this.saveUser(user); - return user; - } - - private generateId(): string { - return Math.random().toString(36).substring(7); - } - - private async saveUser(user: User): Promise { - // Save to database + return this.saveUser({id: 'user-1', username, email}); } + private async saveUser(user: User): Promise { return user; } } - ` - ); - - fs.writeFileSync( - path.join(testRepoDir, "README.md"), - ` +`); + fs.writeFileSync(path.join(repository, 'README.md'), ` # Test Project -This is a test project for the RAG system. - -## Features - -- User authentication -- User management -- Database integration - ## Getting Started -1. Install dependencies: \`npm install\` -2. Run the server: \`npm start\` - ` +Install dependencies with npm install. The project provides user authentication +and user management backed by a database. +`); + + const repoId = `rag-${path.basename(repository)}`; + const embeddingProvider = new DeterministicEmbeddingProvider(); + indexer = new CodebaseIndexer(repository, repoId); + store = new FileBasedEmbeddingStore(repoId, stateRoot); + embeddingIndexer = new EmbeddingIndexer( + indexer, + new EmbeddingChunker(indexer, repository), + embeddingProvider, + store, + repository ); + search = new EmbeddingSearchEngine(embeddingProvider, store); + contextBuilder = new RAGContextBuilder(search); }); - afterAll(() => { - // Cleanup - if (fs.existsSync(testRepoDir)) { - fs.rmSync(testRepoDir, { recursive: true, force: true }); - } - if (fs.existsSync(testCacheDir)) { - fs.rmSync(testCacheDir, { recursive: true, force: true }); - } + afterEach(async () => { + await indexer?.clearCache(); + fs.rmSync(repository, {recursive: true, force: true}); + fs.rmSync(stateRoot, {recursive: true, force: true}); }); - describe("Full RAG Workflow", () => { - it("should complete the full workflow: index -> search -> chat", async () => { - // This test demonstrates the complete RAG pipeline - // In a real implementation, you would: - // - // 1. Initialize components - // 2. Index the codebase - // 3. Perform semantic search - // 4. Build RAG context - // 5. Generate chat response - // - // For now, this is a placeholder test structure - - expect(fs.existsSync(testRepoDir)).toBe(true); - expect(fs.existsSync(path.join(testRepoDir, "src", "auth.ts"))).toBe( - true - ); - expect(fs.existsSync(path.join(testRepoDir, "src", "user.ts"))).toBe( - true - ); - }); - - it("should find relevant code for authentication queries", async () => { - // This would test that when asking "How does authentication work?", - // the system finds the AuthService class and related code - - // Placeholder: In real implementation, would: - // - Index the codebase - // - Search for "authentication" - // - Verify AuthService is in top results - // - Verify relevance scores are high - - expect(true).toBe(true); - }); - - it("should find relevant code for user management queries", async () => { - // This would test that when asking "How do I create a user?", - // the system finds the UserService.createUser method - - // Placeholder: In real implementation, would: - // - Index the codebase - // - Search for "create user" - // - Verify UserService.createUser is in top results - // - Verify the context includes the User interface - - expect(true).toBe(true); - }); - - it("should combine code and documentation in context", async () => { - // This would test that RAG context includes both: - // - Code snippets (functions, classes) - // - Documentation (README content) - - // Placeholder: In real implementation, would: - // - Build RAG context for "Getting Started" - // - Verify README.md content is included - // - Verify related code is also included - // - Verify token budget is respected - - expect(true).toBe(true); - }); - - it("should handle incremental updates", async () => { - // This would test that when files change: - // - Only changed files are re-indexed - // - Existing embeddings are preserved - // - Search results are updated - - // Placeholder: In real implementation, would: - // - Initial indexing - // - Modify one file - // - Incremental update - // - Verify only 1 file was re-processed - // - Verify search results reflect changes - - expect(true).toBe(true); - }); - - it("should maintain chat context across turns", async () => { - // This would test multi-turn conversations: - // - First question: "What authentication methods are available?" - // - Follow-up: "How do I use them?" - // - The system should understand "them" refers to authentication methods - - // Placeholder: In real implementation, would: - // - Create chat session - // - Ask first question - // - Ask follow-up with pronoun reference - // - Verify context maintains conversation history - // - Verify AI understands the reference - - expect(true).toBe(true); - }); + it('indexes, persists, retrieves, builds context, and answers a chat turn', async () => { + const indexed = await embeddingIndexer.indexCodebase({ + incremental: false, + showProgress: false, + validateEmbeddings: true, + batchSize: 10, + }); + expect(indexed.success).toBe(true); + expect(indexed.stats.filesAnalyzed).toBe(2); + expect(indexed.stats.embeddingsGenerated).toBeGreaterThan(2); + expect(await store.exists()).toBe(true); + + const persistedEmbeddings = await store.loadEmbeddings(); + expect(persistedEmbeddings.every(embedding => !path.isAbsolute(embedding.source))).toBe(true); + expect(persistedEmbeddings.every(embedding => !embedding.source.includes('\\'))).toBe(true); + expect(persistedEmbeddings.every(embedding => !embedding.content.includes(repository))).toBe(true); + + const auth = await search.search('How does user login authentication verify a password?', { + k: 5, + minSimilarity: 0.2, + }); + expect(auth.results[0].embedding.source).toContain('auth.ts'); + expect(auth.results.some(result => result.embedding.content.includes('AuthService'))).toBe(true); + + const context = await contextBuilder.buildContext( + 'Explain authentication and the getting started installation', + [], + {maxTokens: 1200, codeWeight: 0.6, docsWeight: 0.3, historyWeight: 0.1} + ); + expect(context.relevantCode.some(snippet => snippet.source.includes('auth.ts'))).toBe(true); + expect(context.relevantDocs.some(snippet => snippet.source.endsWith('README.md'))).toBe(true); + expect(context.tokensUsed).toBeLessThanOrEqual(context.tokenBudget); + + const chatProvider = new DeterministicChatProvider(); + const chatbot = new ChatbotEngine( + chatProvider, + contextBuilder, + search, + indexer, + 'test-repo', + repository + ); + const session = await chatbot.createSession({projectName: 'Test Project'}); + const response = await chatbot.chat(session.id, 'How does authentication work?'); + expect(response.message.content).toContain('AuthService'); + expect(response.message.metadata?.relevantFiles).toContain('src/auth.ts'); + expect(response.stats.relevantSnippets).toBeGreaterThan(0); }); - describe("Performance and Scalability", () => { - it("should handle large codebases efficiently", async () => { - // This would test performance with many files - - // Placeholder: In real implementation, would: - // - Create 100+ sample files - // - Measure indexing time - // - Measure search time - // - Verify performance is acceptable - - expect(true).toBe(true); - }); - - it("should cache embeddings effectively", async () => { - // This would test that: - // - Embeddings are saved to disk - // - Subsequent loads use cache - // - Cache invalidation works - - // Placeholder: In real implementation, would: - // - Index codebase - // - Verify embeddings saved - // - Load embeddings (should be fast) - // - Measure cache hit rate - - expect(true).toBe(true); - }); - - it("should respect token budgets", async () => { - // This would test that context building: - // - Respects maxTokens limit - // - Allocates tokens correctly (60% code, 20% docs, 20% history) - // - Truncates content when necessary - - // Placeholder: In real implementation, would: - // - Build context with 1000 token limit - // - Verify total tokens <= 1000 - // - Verify allocation percentages are correct + it('preserves unchanged embeddings and refreshes changed source incrementally', async () => { + await embeddingIndexer.indexCodebase({incremental: false, showProgress: false}); + fs.appendFileSync( + path.join(repository, 'src', 'auth.ts'), + '\nexport const resetPassword = (user: string) => `reset:${user}`;\n' + ); + await indexer.clearCache(); - expect(true).toBe(true); + const updated = await embeddingIndexer.indexCodebase({ + incremental: true, + showProgress: false, }); + expect(updated.success).toBe(true); + expect(updated.stats.chunksCached).toBeGreaterThan(0); + expect(updated.stats.embeddingsGenerated).toBeGreaterThan(0); + const stored = await store.loadEmbeddings(); + expect(stored.some(embedding => embedding.content.includes('resetPassword'))).toBe(true); }); - describe("Error Handling and Edge Cases", () => { - it("should handle empty repositories gracefully", async () => { - const emptyDir = path.join(os.tmpdir(), `empty-repo-${Date.now()}`); - fs.mkdirSync(emptyDir, { recursive: true }); - - try { - // Should not crash when indexing empty repo - expect(fs.existsSync(emptyDir)).toBe(true); - - // Placeholder: In real implementation, would: - // - Attempt to index empty repo - // - Verify no errors thrown - // - Verify graceful handling - } finally { - fs.rmSync(emptyDir, { recursive: true, force: true }); - } - }); - - it("should handle files with parsing errors", async () => { - // Create file with syntax errors - const invalidFile = path.join(testRepoDir, "invalid.ts"); - fs.writeFileSync( - invalidFile, - "function broken( { this is invalid syntax" - ); - - try { - // Should skip invalid files but continue indexing - - // Placeholder: In real implementation, would: - // - Attempt to index repo with invalid file - // - Verify indexing continues for valid files - // - Verify error is logged but not fatal - - expect(true).toBe(true); - } finally { - if (fs.existsSync(invalidFile)) { - fs.unlinkSync(invalidFile); - } - } - }); - - it("should handle very long files", async () => { - // Create file with 10,000 lines - const longFile = path.join(testRepoDir, "long.ts"); - const longContent = 'console.log("line");\n'.repeat(10000); - fs.writeFileSync(longFile, longContent); - - try { - // Should chunk large files appropriately - - // Placeholder: In real implementation, would: - // - Index repo with very long file - // - Verify file is chunked into multiple embeddings - // - Verify no memory issues - - expect(true).toBe(true); - } finally { - if (fs.existsSync(longFile)) { - fs.unlinkSync(longFile); - } - } - }); - - it("should handle special characters in code", async () => { - // Create file with unicode, emojis, special chars - const specialFile = path.join(testRepoDir, "special.ts"); - fs.writeFileSync( - specialFile, - ` -// Comment with emoji 🚀 -const greeting = "Hello 世界"; -const symbol = "©️ 2024"; - ` - ); - - try { - // Should handle special characters correctly - - // Placeholder: In real implementation, would: - // - Index file with special characters - // - Verify embeddings generated correctly - // - Verify search works with special chars - - expect(true).toBe(true); - } finally { - if (fs.existsSync(specialFile)) { - fs.unlinkSync(specialFile); - } - } - }); + it('combines recent conversation history without exceeding its token budget', async () => { + await embeddingIndexer.indexCodebase({incremental: false, showProgress: false}); + const history = [ + {role: 'user' as const, content: 'Which service authenticates users?', timestamp: new Date()}, + {role: 'assistant' as const, content: 'AuthService authenticates users.', timestamp: new Date()}, + ]; + const context = await contextBuilder.buildContext('How do I use it?', history, { + maxTokens: 300, + codeWeight: 0.5, + docsWeight: 0.2, + historyWeight: 0.3, + }); + expect(context.conversationHistory).toEqual(history); + expect(context.tokensUsed).toBeLessThanOrEqual(300); + expect(contextBuilder.formatContextForPrompt(context)).toContain('Recent Conversation'); }); - describe("Search Quality", () => { - it("should rank exact matches highest", async () => { - // When searching for "AuthService", - // the AuthService class should be ranked first - - // Placeholder: In real implementation, would: - // - Search for "AuthService" - // - Verify first result is AuthService class - // - Verify similarity score is high - - expect(true).toBe(true); - }); - - it("should find semantically similar code", async () => { - // When searching for "login user", - // should find authenticate() method even though - // it doesn't contain the word "login" - - // Placeholder: In real implementation, would: - // - Search for "login user" - // - Verify authenticate() is in results - // - Verify semantic similarity works - - expect(true).toBe(true); - }); - - it("should apply diversity to results", async () => { - // Results should not all come from the same file - - // Placeholder: In real implementation, would: - // - Search for broad term like "user" - // - Verify results come from multiple files - // - Verify diversity threshold is applied + it('continues indexing valid files when another source file cannot be parsed', async () => { + fs.writeFileSync(path.join(repository, 'src', 'invalid.ts'), 'function broken( {'); + const index = await indexer.buildIndex(); + expect(index.files.has('src/auth.ts')).toBe(true); + expect(index.files.has('src/user.ts')).toBe(true); + }); - expect(true).toBe(true); - }); + it('handles an empty repository without creating fake embeddings', async () => { + fs.rmSync(path.join(repository, 'src'), {recursive: true, force: true}); + fs.unlinkSync(path.join(repository, 'README.md')); + const result = await embeddingIndexer.indexCodebase({showProgress: false}); + expect(result).toMatchObject({success: true}); + expect(result.stats.filesAnalyzed).toBe(0); + expect(result.stats.totalChunks).toBe(0); + expect(await store.count()).toBe(0); }); }); - -/** - * NOTE: The tests above are placeholder structures demonstrating - * what a comprehensive E2E test suite should cover. - * - * To make these tests fully functional, you would need to: - * - * 1. Use real (or mock) embedding providers - * 2. Initialize the full RAG pipeline - * 3. Perform actual indexing and search operations - * 4. Measure and verify results - * - * The current implementation provides the structure and test cases - * without requiring API keys or external services for CI/CD. - */ diff --git a/cli/__tests__/providers/decorators/rate-limited-provider.test.ts b/cli/__tests__/providers/decorators/rate-limited-provider.test.ts index 4759c60..ba11ab7 100644 --- a/cli/__tests__/providers/decorators/rate-limited-provider.test.ts +++ b/cli/__tests__/providers/decorators/rate-limited-provider.test.ts @@ -364,6 +364,7 @@ describe('RateLimitedProvider – edge cases', () => { describe('costMultiplier', () => { it('should apply costMultiplier to token consumption', async () => { + useControlledTime(); const mock = new MockProvider(); // countMessagesTokens = 1000 const rateLimited = new RateLimitedProvider(mock, { maxTokens: 3000, diff --git a/cli/__tests__/scripts/package-manager-smoke.test.ts b/cli/__tests__/scripts/package-manager-smoke.test.ts index 2141445..91298ba 100644 --- a/cli/__tests__/scripts/package-manager-smoke.test.ts +++ b/cli/__tests__/scripts/package-manager-smoke.test.ts @@ -3,11 +3,13 @@ const { installArgs, parseManager, parseTarball, + scanArgsFor, } = require('../../scripts/package-manager-smoke') as { execArgs: (manager: string, args: string[]) => string[]; installArgs: (manager: string, tarball: string) => string[]; parseManager: (args: string[]) => string; parseTarball: (args: string[]) => string | undefined; + scanArgsFor: (manager: string, output: string) => string[]; }; describe('package-manager smoke command contracts', () => { @@ -24,10 +26,23 @@ describe('package-manager smoke command contracts', () => { expect(installArgs(manager, '/tmp/guardscan.tgz')).toContain('--ignore-scripts'); }); + it('asks Bun to emit the supported Yarn inventory alongside bun.lock', () => { + expect(installArgs('bun', '/tmp/guardscan.tgz')).toEqual([ + 'add', + '--ignore-scripts', + '--yarn', + '/tmp/guardscan.tgz', + ]); + }); + it('uses Yarn configuration rather than an unsupported install flag', () => { expect(installArgs('yarn', '/tmp/guardscan.tgz')).toEqual(['add', '/tmp/guardscan.tgz']); }); + it.each(['npm', 'pnpm', 'yarn', 'bun'])('keeps the %s scan canary strict', manager => { + expect(scanArgsFor(manager, '/tmp/scan.json')).not.toContain('--allow-partial'); + }); + it('rejects omitted and unknown managers', () => { expect(() => parseManager([])).toThrow(/--manager must be one of/); expect(() => parseManager(['--manager', 'pip'])).toThrow(/--manager must be one of/); diff --git a/cli/__tests__/scripts/release-credential-monitor.test.ts b/cli/__tests__/scripts/release-credential-monitor.test.ts new file mode 100644 index 0000000..09203dc --- /dev/null +++ b/cli/__tests__/scripts/release-credential-monitor.test.ts @@ -0,0 +1,126 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; + +const workflowPath = path.resolve( + __dirname, + '../../../.github/workflows/release-credential-health.yml' +); + +type Workflow = { + on: Record; + permissions: Record; + jobs: Record; + steps?: Array<{name?: string; uses?: string; run?: string}>; + }>; +}; + +function loadWorkflow(): {source: string; workflow: Workflow} { + const source = fs.readFileSync(workflowPath, 'utf8'); + return {source, workflow: yaml.load(source) as Workflow}; +} + +describe('release credential and provider health workflow', () => { + it('is scheduled and manually runnable but inert until explicitly enabled', () => { + const {workflow} = loadWorkflow(); + expect(workflow.on).toHaveProperty('schedule'); + expect(workflow.on).toHaveProperty('workflow_dispatch'); + expect(workflow.on).not.toHaveProperty('push'); + expect(workflow.on).not.toHaveProperty('pull_request'); + + for (const job of Object.values(workflow.jobs)) { + expect(job.if).toContain("vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true'"); + expect(job.if).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + } + }); + + it('uses least privilege, protected environments, and only commit-pinned actions', () => { + const {source, workflow} = loadWorkflow(); + expect(workflow.permissions).toEqual({contents: 'read'}); + expect(workflow.jobs.apple.environment).toBe('apple-notarization'); + expect(workflow.jobs.azure.environment).toBe('windows-signing'); + expect(workflow.jobs.winget.environment).toBe('winget'); + expect(workflow.jobs.chocolatey.environment).toBe('chocolatey'); + expect(workflow.jobs.azure.permissions).toMatchObject({ + contents: 'read', + 'id-token': 'write', + }); + + const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)/gm)].map(match => match[1]); + expect(uses.length).toBeGreaterThan(0); + for (const action of uses) { + expect(action).toMatch(/^[^@\s]+@[0-9a-f]{40}$/); + } + + for (const job of Object.values(workflow.jobs)) { + expect(job['timeout-minutes']).toBeGreaterThan(0); + expect(job['timeout-minutes']).toBeLessThanOrEqual(15); + } + }); + + it('covers provider authentication and explicitly records unsupported states as unknown', () => { + const {source, workflow} = loadWorkflow(); + expect(Object.keys(workflow.jobs)).toEqual(expect.arrayContaining([ + 'github-catalog', + 'trusted-publishers', + 'apple', + 'azure', + 'winget', + 'chocolatey', + 'report', + ])); + expect(source).toContain('guardscan.credential-health.v1'); + expect(source).toContain('ntanwir10/homebrew-tap'); + expect(source).toContain('permission-workflows: write'); + expect(source).toContain("run('xcrun', ["); + expect(source).toContain("'notarytool', 'history'"); + expect(source).toContain('APPLE_CERTIFICATE_P12'); + expect(source).toContain('azure/login@'); + expect(source).toContain('AZURE_SIGNING_PROFILE'); + expect(source).toContain('github-authentication-token-expiration'); + expect(source).toContain('WINGET_GITHUB_TOKEN'); + expect(source).toContain('CHOCO_API_KEY'); + expect(source).toContain("claStatus: {status: 'unknown'"); + expect(source).toContain("expiry: {status: 'unknown'"); + expect(source).toContain("signingProfile = {status: 'unknown'"); + expect(source).toContain("apiKeyAuthentication: {status: 'unknown'"); + }); + + it('is non-publishing and does not contain secret-printing constructs', () => { + const {source} = loadWorkflow(); + for (const forbidden of [ + 'npm publish', + 'npm dist-tag', + 'twine upload', + 'gh release create', + 'git push', + 'wingetcreate submit', + 'choco push', + 'repository_dispatch', + 'set -x', + ]) { + expect(source.toLowerCase()).not.toContain(forbidden); + } + expect(source).not.toMatch(/echo[^\n]*(RELEASE_APP_PRIVATE_KEY|APPLE_CERTIFICATE_P12|APPLE_NOTARY_PRIVATE_KEY|WINGET_GITHUB_TOKEN|CHOCO_API_KEY)/i); + expect(source).not.toMatch(/Write-(Host|Output)[^\n]*(WINGET_GITHUB_TOKEN|CHOCO_API_KEY)/i); + }); + + it('keeps every embedded Node evidence generator syntactically valid', () => { + const {workflow} = loadWorkflow(); + let generators = 0; + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps || []) { + if (!step.run) continue; + for (const match of step.run.matchAll(/node - <<'NODE'\n([\s\S]*?)\nNODE/g)) { + expect(() => new Function('require', 'process', 'console', match[1])).not.toThrow(); + generators += 1; + } + } + } + expect(generators).toBeGreaterThanOrEqual(7); + }); +}); diff --git a/cli/__tests__/scripts/release-please-config.test.ts b/cli/__tests__/scripts/release-please-config.test.ts index 9b4166a..1997bbd 100644 --- a/cli/__tests__/scripts/release-please-config.test.ts +++ b/cli/__tests__/scripts/release-please-config.test.ts @@ -46,6 +46,7 @@ describe('release pull request automation', () => { expect(source).toContain('googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071'); expect(source).toContain('secrets.RELEASE_APP_PRIVATE_KEY'); expect(source).toContain('skip-github-release: true'); + expect(source).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); expect(source).not.toContain('secrets.GITHUB_TOKEN'); expect(source).not.toContain('github.token'); }); diff --git a/cli/__tests__/scripts/release-renderers.test.ts b/cli/__tests__/scripts/release-renderers.test.ts index f0a7d4d..6329992 100644 --- a/cli/__tests__/scripts/release-renderers.test.ts +++ b/cli/__tests__/scripts/release-renderers.test.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -5,8 +6,11 @@ import yaml from 'js-yaml'; const { MARKER_FILE, + classifyChannelCatalog, renderAdapters, + renderChannelCatalog, toPep440, + writeChannelCatalogOutput, writeRenderedOutput, } = require('../../scripts/release/renderers') as { MARKER_FILE: string; @@ -14,7 +18,20 @@ const { manifest: Record, channels?: string ) => {channels: string[]; files: Record}; + renderChannelCatalog: ( + manifest: Record, + options: Record + ) => {files: Record; lock: Record}; + classifyChannelCatalog: ( + rendered: {files: Record; lock: Record}, + catalogRoot: string + ) => Record; toPep440: (version: string) => string; + writeChannelCatalogOutput: ( + rendered: {files: Record; lock: Record}, + outputDir: string, + checkOnly?: boolean + ) => {changed: boolean; checked: boolean; files: string[]; lockSha256: string}; writeRenderedOutput: ( manifest: Record, rendered: {channels: string[]; files: Record}, @@ -140,6 +157,13 @@ function makeManifest(): Record { } describe('release adapter rendering', () => { + const catalogOptions = { + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.3/release-manifest.json', + manifestSha256: '9'.repeat(64), + generatorRepository: 'ntanwir10/GuardScan', + generatorCommit: commit, + }; + it('renders deterministic native adapters and a fail-closed PyPI publication descriptor', () => { const manifest = makeManifest(); const rendered = renderAdapters(manifest, 'pypi,chocolatey,winget,scoop,homebrew'); @@ -194,6 +218,154 @@ describe('release adapter rendering', () => { expect(() => renderAdapters(noWheels, 'pypi')).toThrow(/requires at least one prebuilt/); }); + it('renders one cryptographically bound shared Homebrew and Scoop catalog', () => { + const rendered = renderChannelCatalog(makeManifest(), catalogOptions); + expect(Object.keys(rendered.files).sort()).toEqual([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', + ]); + expect(rendered.lock).toMatchObject({ + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.3', + tag: 'v1.2.3', + commit, + manifestSha256: '9'.repeat(64), + }, + generator: {repository: 'ntanwir10/GuardScan', commit}, + }); + expect(rendered.lock).not.toHaveProperty('catalogCommit'); + for (const catalogPath of ['Formula/guardscan.rb', 'bucket/guardscan.json']) { + expect(rendered.lock.files[catalogPath].sha256).toBe( + crypto.createHash('sha256').update(rendered.files[catalogPath]).digest('hex') + ); + } + }); + + it('detects missing, older, newer, and manually edited catalog projections', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-catalog-')); + try { + const rendered = renderChannelCatalog(makeManifest(), catalogOptions); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'missing', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + }); + expect(writeChannelCatalogOutput(rendered, root)).toMatchObject({changed: true}); + expect(writeChannelCatalogOutput(rendered, root, true)).toMatchObject({ + changed: false, + checked: true, + }); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'exact', + integrityIncident: false, + action: 'record-verified', + }); + + fs.writeFileSync(path.join(root, 'Formula/evil.rb'), 'system "curl", "https://evil.invalid"\n'); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + }); + expect(() => writeChannelCatalogOutput(rendered, root, true)).toThrow(/unmanaged generated paths/); + fs.rmSync(path.join(root, 'Formula/evil.rb')); + + fs.appendFileSync(path.join(root, 'Formula/guardscan.rb'), '# human drift\n'); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + }); + expect(() => writeChannelCatalogOutput(rendered, root, true)).toThrow(/manually edited/); + + writeChannelCatalogOutput(rendered, root); + const lockPath = path.join(root, 'channel-lock.json'); + const newer = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + newer.source.version = '9.0.0'; + newer.source.tag = 'v9.0.0'; + newer.source.manifestUrl = + 'https://github.com/ntanwir10/GuardScan/releases/download/v9.0.0/release-manifest.json'; + fs.writeFileSync(lockPath, `${JSON.stringify(newer, null, 2)}\n`); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'unexpected-newer', + integrityIncident: true, + action: 'stop', + }); + + const older = { + ...newer, + source: { + ...newer.source, + version: '1.2.2', + tag: 'v1.2.2', + manifestUrl: + 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.2/release-manifest.json', + }, + }; + fs.writeFileSync(lockPath, `${JSON.stringify(older, null, 2)}\n`); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'older', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + }); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects catalog identity drift and renders a stable source formula for Homebrew Core', () => { + expect(() => renderChannelCatalog(makeManifest(), { + ...catalogOptions, + generatorCommit: 'b'.repeat(40), + })).toThrow(/exact release source commit/); + expect(() => renderChannelCatalog(makeManifest(), { + ...catalogOptions, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/latest/download/release-manifest.json', + })).toThrow(/canonical immutable/); + + const manifest = makeManifest(); + manifest.artifacts.push({ + id: 'npm:guardscan@1.2.3', + kind: 'npm-tarball', + filename: 'guardscan-1.2.3.tgz', + size: 1024, + sha256: '8'.repeat(64), + source, + integrity: `sha512-${'A'.repeat(86)}==`, + url: 'https://registry.npmjs.org/guardscan/-/guardscan-1.2.3.tgz', + provenance: { + type: 'slsa', + url: 'https://github.com/ntanwir10/GuardScan/attestations/npm', + verified: true, + }, + }); + const core = renderAdapters(manifest, 'homebrew-core'); + expect(core.files['homebrew-core/Formula/guardscan.rb']).toContain('depends_on "node"'); + expect(core.files['homebrew-core/Formula/guardscan.rb']).toContain('*std_npm_args'); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-homebrew-core-')); + try { + const output = path.join(root, 'adapters'); + writeRenderedOutput(manifest, core, output); + expect(validateAdapters(manifest, output, 'homebrew-core')).toMatchObject({ + valid: true, + structural: {'homebrew-core': {valid: true, files: 1}}, + }); + expect(nativeValidationPlan(output, ['homebrew-core'], 'darwin')).toEqual([ + expect.objectContaining({ + channel: 'homebrew-core', + command: 'brew', + args: expect.arrayContaining(['audit', '--new-formula']), + }), + ]); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('writes owned output atomically, detects drift, and refuses unmanaged replacement', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-render-')); try { diff --git a/cli/__tests__/scripts/release-tool.test.ts b/cli/__tests__/scripts/release-tool.test.ts index 67c2e58..f9609c1 100644 --- a/cli/__tests__/scripts/release-tool.test.ts +++ b/cli/__tests__/scripts/release-tool.test.ts @@ -9,6 +9,7 @@ const { createInitialState, createPlan, prepareRelease, + releaseTrainChannels, summarizeState, validateSource, } = require('../../scripts/release/lib') as { @@ -31,6 +32,10 @@ const { source: Record, options: Record ) => {created: boolean; outputDir: string}; + releaseTrainChannels: ( + channel: 'rc' | 'stable', + options?: {homebrewCoreEnabled?: boolean} + ) => string[]; summarizeState: (state: Record) => Record; validateSource: (options: Record) => Record; }; @@ -61,6 +66,19 @@ const { nextState: Record ) => void; }; +const { + createEvent, + materializeReleaseState, +} = require('../../scripts/release/events') as { + createEvent: ( + input: Record, + previous?: Record + ) => Record; + materializeReleaseState: (events: Array>) => Record; +}; +const {reconcileRelease} = require('../../scripts/release/reconcile') as { + reconcileRelease: (state: Record) => Record; +}; const COMMIT = 'a'.repeat(40); let root: string; @@ -116,6 +134,24 @@ describe('release source validation', () => { expect(() => validateSource({packageRoot, repositoryRoot: root, commit: 'not-a-sha'})) .toThrow(/commit must be a 40-character lowercase git SHA/); }); + + it('rejects split or duplicated stable release notes', () => { + fs.writeFileSync( + path.join(packageRoot, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n\n### Added\n\n- A 1.2.3 feature.\n\n' + + '## [1.2.3] - 2026-07-20\n\n- Older notes.\n' + ); + expect(() => validateSource({packageRoot, repositoryRoot: root, commit: COMMIT})) + .toThrow(/Unreleased section must be empty for stable release 1\.2\.3/); + + fs.writeFileSync( + path.join(packageRoot, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n\n## [1.2.3] - 2026-07-20\n\n' + + '## [1.2.3] - 2026-07-21\n' + ); + expect(() => validateSource({packageRoot, repositoryRoot: root, commit: COMMIT})) + .toThrow(/exactly one release section for 1\.2\.3/); + }); }); describe('release planning and state summaries', () => { @@ -134,6 +170,10 @@ describe('release planning and state summaries', () => { expect(channels.find(channel => channel.id === 'npm')?.status).toBe('planned'); expect(channels.find(channel => channel.id === 'github')?.status).toBe('deferred'); expect(channels.find(channel => channel.id === 'pypi')?.status).toBe('deferred'); + expect(channels.find(channel => channel.id === 'homebrew-core')).toMatchObject({ + status: 'deferred', + required: false, + }); }); it('reports failed and remaining channels without treating them as complete', () => { @@ -163,6 +203,90 @@ describe('release planning and state summaries', () => { expect(Object.keys(state.channels)).toEqual(['npm', 'github']); }); + it('tracks optional Homebrew Core work without blocking release completion', () => { + expect(reconcileRelease({ + incidents: {}, + channels: { + npm: {status: 'verified'}, + homebrew: {status: 'verified'}, + scoop: {status: 'verified'}, + 'homebrew-core': {status: 'submitted'}, + }, + })).toMatchObject({ + complete: true, + blocked: false, + blocking: [], + actions: [{ + channel: 'homebrew-core', + required: false, + currentStatus: 'submitted', + action: 'poll-acceptance', + }], + }); + }); + + it('selects Homebrew Core only when optional stable submission is enabled', () => { + expect(releaseTrainChannels('rc')).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + ]); + expect(releaseTrainChannels('stable')).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + 'winget', 'chocolatey', + ]); + expect(releaseTrainChannels('stable', {homebrewCoreEnabled: true})).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + 'homebrew-core', 'winget', 'chocolatey', + ]); + }); + + it('materializes catalog publication evidence and its immutable remote identity', () => { + const first = createEvent({ + version: '1.2.3', + tag: 'v1.2.3', + commit: COMMIT, + timestamp: '2026-07-20T12:00:00.000Z', + type: 'train_started', + idempotencyKey: 'train:v1.2.3', + payload: {channels: ['homebrew']}, + }); + const catalogCommit = 'c'.repeat(40); + const fileDigest = 'd'.repeat(64); + const second = createEvent({ + version: '1.2.3', + tag: 'v1.2.3', + commit: COMMIT, + timestamp: '2026-07-20T12:01:00.000Z', + type: 'channel_verified', + channel: 'homebrew', + idempotencyKey: 'catalog:homebrew:v1.2.3', + payload: { + remoteIdentity: `github:ntanwir10/homebrew-tap@${catalogCommit}#Formula/guardscan.rb`, + remoteDigest: fileDigest, + catalog: { + repository: 'ntanwir10/homebrew-tap', + commit: catalogCommit, + pullRequest: 42, + lockDigest: 'e'.repeat(64), + manifestDigest: 'f'.repeat(64), + path: 'Formula/guardscan.rb', + fileDigest, + }, + }, + }, first); + expect(materializeReleaseState([first, second]).channels.homebrew).toMatchObject({ + status: 'verified', + remoteIdentity: `github:ntanwir10/homebrew-tap@${catalogCommit}#Formula/guardscan.rb`, + remoteDigest: fileDigest, + catalog: { + repository: 'ntanwir10/homebrew-tap', + commit: catalogCommit, + pullRequest: 42, + path: 'Formula/guardscan.rb', + fileDigest, + }, + }); + }); + it('prepares one atomic ledger and treats an identical retry as a no-op', () => { const source = validateSource({packageRoot, repositoryRoot: root, commit: COMMIT}); const outputDir = path.join(root, 'evidence', 'v1.2.3'); diff --git a/cli/__tests__/scripts/release-train.test.ts b/cli/__tests__/scripts/release-train.test.ts index ef7a915..c0f0adb 100644 --- a/cli/__tests__/scripts/release-train.test.ts +++ b/cli/__tests__/scripts/release-train.test.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; +import {execFileSync} from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -5,10 +7,15 @@ import zlib from 'zlib'; const { appendEvent, + createEvent, materializeReleaseState, readEvents, } = require('../../scripts/release/events') as { appendEvent: (file: string, input: Record) => Record; + createEvent: ( + input: Record, + previous?: Record + ) => Record; materializeReleaseState: (events: Array>) => Record; readEvents: (file: string) => Array>; }; @@ -32,6 +39,23 @@ const { } = require('../../scripts/release/promotion') as { createPromotionDecision: (input: Record) => Record; }; +const { + createPublicationEvidence, +} = require('../../scripts/release/publication-evidence') as { + createPublicationEvidence: (input: Record) => Record; +}; +const { + handlePublication, + main, +} = require('../../scripts/release/index') as { + handlePublication: ( + command: string, + source: Record, + manifest: Record, + options: Record + ) => Record; + main: (argv: string[]) => Promise; +}; const { classifyRemoteArtifact, } = require('../../scripts/release/remote') as { @@ -41,12 +65,50 @@ const { ) => Record; }; const { + planFirstReleaseWithdrawal, planRollback, reconcileRelease, } = require('../../scripts/release/reconcile') as { - planRollback: (state: Record, knownGood?: string) => Record; + planFirstReleaseWithdrawal: ( + state: Record, + authority: Record + ) => Record; + planRollback: ( + state: Record, + knownGoodVersion?: string, + knownGoodCommit?: string + ) => Record; reconcileRelease: (state: Record) => Record; }; +const { + assertFirstReleaseWithdrawal, + prepareFirstReleaseCatalogWithdrawal, +} = require('../../scripts/release/first-release-withdrawal') as { + assertFirstReleaseWithdrawal: ( + ledgerRoot: string, + defectiveVersion: string, + ledgerCommit: string + ) => Record; + prepareFirstReleaseCatalogWithdrawal: ( + catalogRoot: string, + defectiveVersion: string, + defectiveCommit: string + ) => Record; +}; +const {releaseTrainChannels} = require('../../scripts/release/lib') as { + releaseTrainChannels: ( + channel: string, + options?: {homebrewCoreEnabled?: boolean} + ) => string[]; +}; +const { + prepareForwardFixSource, +} = require('../../scripts/release/recovery-source') as { + prepareForwardFixSource: ( + repositoryRoot: string, + input: Record + ) => Record; +}; const { createReleaseManifest, } = require('../../scripts/release/manifest') as { @@ -86,7 +148,7 @@ function eventInput( timestamp: new Date(Date.parse(timestamp) + sequence * 60_000).toISOString(), type, idempotencyKey: `${type}:${sequence}`, - payload: {}, + payload: type === 'train_started' ? {channels: ['npm']} : {}, ...overrides, }; } @@ -126,6 +188,22 @@ function standalone( chartRendering: false, accurateTokenCounting: false, }, + optionalCapabilities: { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }, platform: {os: osName, arch, ...(libc ? {libc} : {})}, archiveFormat: osName === 'windows' ? 'zip' : 'tar.gz', entrypoint, @@ -167,6 +245,179 @@ function wheel(native: Record, digest: string): Record } describe('append-only release train', () => { + it('canonicalizes provider files with the same code-point ordering used by release workflows', () => { + const files = { + 'guardscan-linux-x64.tar.gz': 'c'.repeat(64), + 'SHA256SUMS.sigstore.json': 'b'.repeat(64), + SHA256SUMS: 'a'.repeat(64), + }; + const evidence = createPublicationEvidence({ + channel: 'github', + version: source.version, + tag: source.tag, + remoteIdentity: `github:ntanwir10/GuardScan/releases/tag/${source.tag}`, + files, + }); + expect(Object.keys(evidence.files)).toEqual(Object.keys(files).sort()); + expect(createPublicationEvidence({...evidence, files: {...files}})).toEqual(evidence); + }); + + it('requires exact provider evidence for primary publication events and the CLI', () => { + const first = createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + const artifact = { + id: `npm:guardscan@${source.version}`, + kind: 'npm-tarball', + filename: `guardscan-${source.version}.tgz`, + sha256: 'b'.repeat(64), + }; + const remoteIdentity = `npm:guardscan@${source.version}`; + expect(() => createEvent(eventInput('channel_published', 1, { + channel: 'npm', + payload: {remoteIdentity, remoteDigest: artifact.sha256}, + }), first)).toThrow(/requires provider-bound file evidence/); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-publication-evidence-')); + const ledger = path.join(root, 'ledger.jsonl'); + const evidenceFile = path.join(root, 'npm-publication-evidence.json'); + try { + fs.writeFileSync(ledger, `${JSON.stringify(first)}\n`); + const publication = createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity, + files: {[artifact.filename]: artifact.sha256}, + }); + fs.writeFileSync(evidenceFile, `${JSON.stringify(publication, null, 2)}\n`); + const options = { + manifest: path.join(root, 'release-manifest.json'), + ledger, + channel: 'npm', + artifactId: artifact.id, + remoteIdentity, + remoteDigest: publication.aggregateSha256, + timestamp: eventInput('channel_published', 1).timestamp, + idempotencyKey: 'published:npm:test', + }; + expect(() => handlePublication('publish', source, {artifacts: [artifact]}, options)) + .toThrow(/requires --publication-evidence/); + expect(handlePublication('publish', source, {artifacts: [artifact]}, { + ...options, + publicationEvidence: evidenceFile, + }).event.payload.publication).toEqual(publication); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects unknown train channels and terminal or backward channel transitions', () => { + expect(() => createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm', 'npm']}, + }))).toThrow(/non-empty unique supported channel list/); + expect(() => createEvent(eventInput('train_started', 0, { + payload: {channels: ['unknown']}, + }))).toThrow(/non-empty unique supported channel list/); + + const first = createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + const publication = createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity: `npm:guardscan@${source.version}`, + files: {[`guardscan-${source.version}.tgz`]: 'b'.repeat(64)}, + }); + const publishedInput = eventInput('channel_published', 1, { + channel: 'npm', + payload: { + remoteIdentity: publication.remoteIdentity, + remoteDigest: publication.aggregateSha256, + publication, + }, + }); + const published = createEvent(publishedInput, first); + const verified = createEvent(eventInput('channel_verified', 2, { + channel: 'npm', + }), published); + const regression = createEvent(eventInput('channel_published', 3, { + channel: 'npm', + payload: publishedInput.payload, + }), verified); + expect(() => materializeReleaseState([first, published, verified, regression])) + .toThrow(/cannot move npm from verified to published/); + + const superseded = createEvent(eventInput('superseded', 3, { + channel: 'npm', + }), verified); + const reopened = createEvent(eventInput('channel_published', 4, { + channel: 'npm', + payload: publishedInput.payload, + }), superseded); + expect(() => materializeReleaseState([first, published, verified, superseded, reopened])) + .toThrow(/cannot move npm from superseded to published/); + + const catalogStarted = createEvent(eventInput('train_started', 0, { + payload: {channels: ['homebrew']}, + })); + const catalogVerified = createEvent(eventInput('channel_verified', 1, { + channel: 'homebrew', + }), catalogStarted); + expect(materializeReleaseState([catalogStarted, catalogVerified]).channels.homebrew.status) + .toBe('verified'); + }); + + it('prepares deterministic forward-fix source from the exact known-good tree', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-forward-fix-')); + const cli = path.join(root, 'cli'); + fs.mkdirSync(cli); + fs.writeFileSync(path.join(cli, 'package.json'), `${JSON.stringify({ + name: 'guardscan', + version: '1.1.9', + }, null, 2)}\n`); + fs.writeFileSync(path.join(cli, 'package-lock.json'), `${JSON.stringify({ + name: 'guardscan', + version: '1.1.9', + lockfileVersion: 3, + packages: {'': {name: 'guardscan', version: '1.1.9'}}, + }, null, 2)}\n`); + fs.writeFileSync(path.join(cli, 'CHANGELOG.md'), [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [1.1.9]', + '', + '- Known good.', + '', + ].join('\n')); + try { + const input = { + knownGoodVersion: '1.1.9', + defectiveVersion: '1.2.0', + forwardFixVersion: '1.2.1', + }; + const first = prepareForwardFixSource(root, input); + const second = prepareForwardFixSource(root, input); + expect(first).toMatchObject({ + changed: true, + version: '1.2.1', + files: ['cli/CHANGELOG.md', 'cli/package-lock.json', 'cli/package.json'], + }); + expect(second).toMatchObject({changed: false, version: '1.2.1'}); + expect(JSON.parse(fs.readFileSync(path.join(cli, 'package.json'), 'utf8')).version) + .toBe('1.2.1'); + expect(JSON.parse(fs.readFileSync(path.join(cli, 'package-lock.json'), 'utf8'))) + .toMatchObject({version: '1.2.1', packages: {'': {version: '1.2.1'}}}); + expect(fs.readFileSync(path.join(cli, 'CHANGELOG.md'), 'utf8')) + .toContain('## [1.2.1]\n\n### Fixed\n\n- Restore verified v1.1.9 source'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('appends, replays, retries idempotently, and models rollback without backward mutation', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-ledger-')); const ledger = path.join(root, 'v1.2.0-rc.1.jsonl'); @@ -180,6 +431,13 @@ describe('append-only release train', () => { artifactIds: ['npm:guardscan@1.2.0-rc.1'], remoteIdentity: 'guardscan@1.2.0-rc.1', remoteDigest: 'b'.repeat(64), + publication: createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity: 'guardscan@1.2.0-rc.1', + files: {'guardscan-1.2.0-rc.1.tgz': 'b'.repeat(64)}, + }), }, }); expect(appendEvent(ledger, published).changed).toBe(true); @@ -193,35 +451,148 @@ describe('append-only release train', () => { payload: {}, })); appendEvent(ledger, eventInput('rollback_started', 3, { - payload: {knownGoodVersion: null, forwardFixVersion: '1.2.1'}, + payload: { + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })); + appendEvent(ledger, eventInput('action_required', 4, { + channel: 'npm', + payload: { + action: 'deprecate-and-forward-fix', + authority: 'npm-maintainer', + reason: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + }, })); - appendEvent(ledger, eventInput('superseded', 4, { + appendEvent(ledger, eventInput('superseded', 5, { channel: 'npm', - payload: published.payload, + payload: { + artifactIds: published.payload.artifactIds, + remoteIdentity: published.payload.remoteIdentity, + remoteDigest: published.payload.remoteDigest, + }, })); const state = materializeReleaseState(readEvents(ledger)); expect(state).toMatchObject({ schemaVersion: 'guardscan.release-state.v2', - lastSequence: 5, + lastSequence: 6, + actionRequired: [{ + channel: 'npm', + action: 'deprecate-and-forward-fix', + authority: 'npm-maintainer', + reason: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + requestedAt: '2026-07-20T00:04:00.000Z', + }], channels: { npm: { status: 'superseded', artifactIds: ['npm:guardscan@1.2.0-rc.1'], remoteDigest: 'b'.repeat(64), + publication: expect.objectContaining({ + schemaVersion: 'guardscan.provider-publication.v1', + aggregateSha256: 'b'.repeat(64), + }), }, }, }); expect(reconcileRelease(state)).toMatchObject({ complete: false, actions: expect.arrayContaining([ - {channel: 'github', currentStatus: 'planned', action: 'publish'}, - {channel: 'winget', currentStatus: 'planned', action: 'submit'}, + {channel: 'github', currentStatus: 'planned', action: 'publish', required: true}, + {channel: 'winget', currentStatus: 'planned', action: 'submit', required: true}, ]), }); - expect(planRollback(state)).toMatchObject({ + const rollbackInput = { + ...state, + channels: { + ...state.channels, + npm: {...state.channels.npm, status: 'verified'}, + }, + }; + expect(planRollback(rollbackInput, '1.1.9', 'b'.repeat(40))).toMatchObject({ + schemaVersion: 'guardscan.rollback-plan.v1', + knownGood: { + version: '1.1.9', + tag: 'v1.1.9', + commit: 'b'.repeat(40), + }, forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + repositoryActions: expect.arrayContaining([ + expect.objectContaining({id: 'deactivate-train'}), + expect.objectContaining({id: 'forward-fix-pr'}), + expect.objectContaining({id: 'shared-catalog-rollback'}), + ]), + actions: expect.arrayContaining([ + expect.objectContaining({ + channel: 'npm', + automation: 'external-action-required', + authority: 'npm-maintainer', + }), + ]), }); + expect(() => planRollback(rollbackInput)).toThrow(/verified known-good version is required/); + expect(() => planRollback(rollbackInput, '1.1.9')).toThrow(/known-good commit is required/); + + const firstRelease = { + ...rollbackInput, + version: '1.2.0', + tag: 'v1.2.0', + channels: { + npm: {...rollbackInput.channels.npm, status: 'verified'}, + homebrew: {status: 'verified'}, + scoop: {status: 'verified'}, + }, + }; + const firstReleaseAuthority = { + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.2.0', + defectiveTag: 'v1.2.0', + defectiveCommit: commit, + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + }; + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)).toMatchObject({ + schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + authority: { + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + }, + repositoryActions: expect.arrayContaining([ + expect.objectContaining({id: 'open-recovery-incident'}), + expect.objectContaining({id: 'shared-catalog-withdrawal'}), + expect.objectContaining({id: 'deactivate-train'}), + ]), + actions: expect.arrayContaining([ + expect.objectContaining({ + channel: 'npm', + action: 'deprecate-defective-release', + automation: 'external-action-required', + }), + expect.objectContaining({ + channel: 'homebrew', + action: 'remove-first-release-listing', + automation: 'repository-automated', + }), + ]), + }); + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)) + .not.toHaveProperty('knownGood'); + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)) + .not.toHaveProperty('forwardFixBranch'); + expect(() => planFirstReleaseWithdrawal(firstRelease, { + ...firstReleaseAuthority, + priorCompleteStableVersions: ['1.1.9'], + })).toThrow(/requires exact protected-ledger authority/); + expect(() => planFirstReleaseWithdrawal(rollbackInput, firstReleaseAuthority)) + .toThrow(/requires a stable defective release/); } finally { fs.rmSync(root, {recursive: true, force: true}); } @@ -238,6 +609,445 @@ describe('append-only release train', () => { fs.rmSync(root, {recursive: true, force: true}); } }); + + it('permits first-release withdrawal only without a verified stable predecessor', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-first-withdrawal-')); + const eventsRoot = path.join(root, 'events'); + fs.mkdirSync(eventsRoot); + const stableEvent = ( + version: string, + type: string, + sequence: number, + overrides: Record = {} + ) => eventInput(type, sequence, { + version, + tag: `v${version}`, + ...overrides, + }); + try { + const defectiveLedger = path.join(eventsRoot, 'v1.2.0.jsonl'); + const stableChannels = releaseTrainChannels('stable'); + appendEvent(defectiveLedger, stableEvent('1.2.0', 'train_started', 0, { + payload: {channels: stableChannels}, + })); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [{version: '1.2.0', releasePr: 32, channel: 'stable'}], + })}\n`); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toEqual({ + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.2.0', + defectiveTag: 'v1.2.0', + defectiveCommit: commit, + ledgerCommit: 'd'.repeat(40), + priorCompleteStableVersions: [], + alreadyCompleted: false, + }); + appendEvent(defectiveLedger, stableEvent('1.2.0', 'rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + let sequence = 2; + for (const channel of stableChannels) { + appendEvent(defectiveLedger, stableEvent('1.2.0', 'withdrawn', sequence, { + channel, + payload: {artifactIds: []}, + })); + sequence += 1; + } + appendEvent(defectiveLedger, stableEvent('1.2.0', 'rollback_repository_completed', sequence, { + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: [], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 0, + commit: 'd'.repeat(40), + }, + completedAt: new Date(Date.parse(timestamp) + sequence * 60_000).toISOString(), + }, + })); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + })}\n`); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toMatchObject({ + alreadyCompleted: true, + }); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [{version: '1.2.0', releasePr: 32, channel: 'stable'}], + })}\n`); + expect(() => assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))) + .toThrow(/not an active protected train/); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + })}\n`); + + const laterLedger = path.join(eventsRoot, 'v1.2.1.jsonl'); + appendEvent(laterLedger, stableEvent('1.2.1', 'train_started', 0, { + payload: {channels: ['pnpm']}, + })); + appendEvent(laterLedger, stableEvent('1.2.1', 'channel_verified', 1, { + channel: 'pnpm', + })); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toMatchObject({ + alreadyCompleted: true, + }); + + const priorLedger = path.join(eventsRoot, 'v1.1.9.jsonl'); + appendEvent(priorLedger, stableEvent('1.1.9', 'train_started', 0, { + payload: {channels: ['pnpm']}, + })); + appendEvent(priorLedger, stableEvent('1.1.9', 'channel_verified', 1, { + channel: 'pnpm', + })); + expect(() => assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))) + .toThrow(/verified stable predecessors exist \(1.1.9\)/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('removes only a complete catalog projection for the defective first release', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-catalog-withdrawal-')); + try { + fs.mkdirSync(path.join(root, 'Formula')); + fs.mkdirSync(path.join(root, 'bucket')); + const formula = 'class Guardscan < Formula\n version "1.2.0"\nend\n'; + const scoop = `${JSON.stringify({version: '1.2.0'})}\n`; + fs.writeFileSync(path.join(root, 'Formula/guardscan.rb'), formula); + fs.writeFileSync(path.join(root, 'bucket/guardscan.json'), scoop); + const lock = { + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.0', + tag: 'v1.2.0', + commit, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.0/release-manifest.json', + manifestSha256: 'f'.repeat(64), + }, + generator: {repository: 'ntanwir10/GuardScan', commit}, + files: { + 'Formula/guardscan.rb': { + sha256: crypto.createHash('sha256').update(formula).digest('hex'), + }, + 'bucket/guardscan.json': { + sha256: crypto.createHash('sha256').update(scoop).digest('hex'), + }, + }, + }; + fs.writeFileSync(path.join(root, 'channel-lock.json'), `${JSON.stringify({ + ...lock, + files: { + ...lock.files, + 'Formula/guardscan.rb': {sha256: '0'.repeat(64)}, + }, + })}\n`); + expect(() => prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toThrow(/does not exactly identify/); + fs.writeFileSync(path.join(root, 'channel-lock.json'), `${JSON.stringify(lock)}\n`); + expect(prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)).toEqual({ + changed: true, + state: 'removed', + removed: [ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', + ], + }); + expect(prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toEqual({changed: false, state: 'already-absent', removed: []}); + fs.writeFileSync(path.join(root, 'channel-lock.json'), '{}\n'); + expect(() => prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toThrow(/catalog is partial/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects malformed external recovery authority events', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-ledger-action-')); + const ledger = path.join(root, 'ledger.jsonl'); + try { + appendEvent(ledger, eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + expect(() => appendEvent(ledger, eventInput('action_required', 1, { + channel: 'npm', + payload: {action: 'deprecate-and-forward-fix'}, + }))).toThrow(/action_required payload/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects conflicting recovery starts and malformed withdrawal completion evidence', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-recovery-conflict-')); + const ledger = path.join(root, 'ledger.jsonl'); + const stable = (type: string, sequence: number, overrides: Record = {}) => ( + eventInput(type, sequence, { + version: '1.2.0', + tag: 'v1.2.0', + ...overrides, + }) + ); + try { + appendEvent(ledger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(ledger, stable('rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + appendEvent(ledger, stable('rollback_started', 2, { + payload: { + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })); + expect(() => materializeReleaseState(readEvents(ledger))) + .toThrow(/recovery cannot be started more than once/); + + const invalidLedger = path.join(root, 'invalid.jsonl'); + appendEvent(invalidLedger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(invalidLedger, stable('rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + expect(() => appendEvent(invalidLedger, stable('rollback_repository_completed', 2, { + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: [], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 9, + commit: 'd'.repeat(40), + }, + completedAt: new Date(Date.parse(timestamp) + 2 * 60_000).toISOString(), + }, + }))).toThrow(/first-release rollback repository evidence is invalid/); + + const legacyLedger = path.join(root, 'legacy.jsonl'); + appendEvent(legacyLedger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(legacyLedger, stable('rollback_started', 1, { + payload: { + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })); + expect(materializeReleaseState(readEvents(legacyLedger)).recovery.mode) + .toBe('known-good'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('runs first-release withdrawal through the CLI idempotently', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-withdrawal-cli-')); + const ledger = path.join(root, 'ledger.jsonl'); + const authorityFile = path.join(root, 'authority.json'); + const repositoryRoot = path.resolve(__dirname, '../../..'); + const packageRoot = path.join(repositoryRoot, 'cli'); + const actualCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + encoding: 'utf8', + }).trim(); + const cliSource = { + version: '1.1.0', + tag: 'v1.1.0', + commit: actualCommit, + }; + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + appendEvent(ledger, { + ...cliSource, + timestamp, + type: 'train_started', + idempotencyKey: 'train:v1.1.0', + payload: {channels: ['npm', 'homebrew']}, + }); + fs.writeFileSync(authorityFile, `${JSON.stringify({ + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.1.0', + defectiveTag: 'v1.1.0', + defectiveCommit: actualCommit, + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + })}\n`); + const args = [ + 'rollback', + '--package-root', packageRoot, + '--repository-root', repositoryRoot, + '--commit', actualCommit, + '--tag', 'v1.1.0', + '--ledger', ledger, + '--timestamp', '2026-07-20T00:01:00.000Z', + '--idempotency-key', 'rollback:1.1.0', + '--first-release-withdrawal', + '--first-release-authority', authorityFile, + ]; + await main(args); + const first = readEvents(ledger); + await main(args); + expect(readEvents(ledger)).toEqual(first); + expect(materializeReleaseState(first)).toMatchObject({ + recovery: { + status: 'started', + mode: 'first-release-withdrawal', + requiredNextVersion: '1.1.1', + }, + incidents: { + 'first-release-withdrawal-v1.1.0': { + kind: 'recovery', + status: 'open', + }, + }, + }); + } finally { + stdout.mockRestore(); + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('models moderated rejection, correction, and resubmission without losing provider identity', () => { + const wingetEvidence = ( + state: string, + pullRequest: number, + providerCommit: string, + digestCharacter: string, + reason?: string + ) => { + const files = { + 'NaumanTanwir.GuardScan.installer.yaml': digestCharacter.repeat(64), + 'NaumanTanwir.GuardScan.locale.en-US.yaml': digestCharacter.repeat(64), + 'NaumanTanwir.GuardScan.yaml': digestCharacter.repeat(64), + }; + const digestInput = Object.keys(files).sort() + .map(name => `${name}\0${files[name as keyof typeof files]}\n`).join(''); + const remoteDigest = crypto.createHash('sha256').update(digestInput).digest('hex'); + const providerPath = `manifests/n/NaumanTanwir/GuardScan/${source.version}`; + return { + schemaVersion: 'guardscan.moderated-submission.v1', + channel: 'winget', + version: source.version, + tag: source.tag, + state, + packageIdentity: `NaumanTanwir.GuardScan@${source.version}`, + remoteIdentity: `github:microsoft/winget-pkgs/pull/${pullRequest}@${providerCommit}#${providerPath}`, + remoteDigest, + files, + provider: { + repository: 'microsoft/winget-pkgs', + path: providerPath, + pullRequest, + commit: providerCommit, + publicBytesVerified: false, + pendingStateQuery: 'digest-bound open pull request, then protected release ledger', + }, + ...(reason ? {reason} : {}), + }; + }; + const first = eventInput('train_started', 0, { + payload: {channels: ['winget']}, + }); + const submittedEvidence = wingetEvidence('submitted', 42, 'b'.repeat(40), 'c'); + const submitted = eventInput('channel_submitted', 1, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: submittedEvidence.remoteIdentity, + remoteDigest: submittedEvidence.remoteDigest, + submission: submittedEvidence, + }, + }); + const rejectionReason = 'provider pull request closed without merge'; + const rejectedEvidence = { + ...submittedEvidence, + state: 'rejected', + reason: rejectionReason, + }; + const rejected = eventInput('channel_rejected', 2, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: submitted.payload.remoteIdentity, + remoteDigest: submitted.payload.remoteDigest, + reason: rejectionReason, + submission: rejectedEvidence, + }, + }); + const correctedEvidence = wingetEvidence('corrected', 42, 'd'.repeat(40), 'e'); + const corrected = eventInput('channel_corrected', 3, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: correctedEvidence.remoteIdentity, + remoteDigest: correctedEvidence.remoteDigest, + submission: correctedEvidence, + }, + }); + const resubmittedEvidence = wingetEvidence('resubmitted', 43, 'f'.repeat(40), 'e'); + const resubmitted = eventInput('channel_resubmitted', 4, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: resubmittedEvidence.remoteIdentity, + remoteDigest: resubmittedEvidence.remoteDigest, + submission: resubmittedEvidence, + }, + }); + const events = [createEvent(first)]; + for (const input of [submitted, rejected, corrected, resubmitted]) { + events.push(createEvent(input, events.at(-1))); + } + const state = materializeReleaseState(events); + expect(state.channels.winget).toMatchObject({ + status: 'resubmitted', + remoteIdentity: resubmitted.payload.remoteIdentity, + remoteDigest: resubmitted.payload.remoteDigest, + submission: resubmittedEvidence, + }); + expect(reconcileRelease(state)).toMatchObject({ + complete: false, + actions: [{ + channel: 'winget', + required: true, + currentStatus: 'resubmitted', + action: 'poll-resubmission', + }], + }); + expect(() => materializeReleaseState([events[0], events[1], createEvent( + eventInput('channel_resubmitted', 2, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: resubmittedEvidence.remoteIdentity, + remoteDigest: resubmittedEvidence.remoteDigest, + submission: resubmittedEvidence, + }, + }), + events[1] + )])).toThrow(/cannot move winget from submitted to resubmitted/); + }); }); describe('promotion policy and remote idempotency', () => { @@ -255,8 +1065,11 @@ describe('promotion policy and remote idempotency', () => { publishedAt: timestamp, sourcePr: 42, sourcePrHead: commit, + sourcePrBase: 'd'.repeat(40), + sourcePrTree: 'e'.repeat(40), }, currentSourcePrHead: commit, + currentSourcePrBase: 'd'.repeat(40), evaluatedAt: '2026-07-21T00:30:00.000Z', requiredChannels: channels, canaries, @@ -272,11 +1085,16 @@ describe('promotion policy and remote idempotency', () => { }); const changed = decisionInput(); changed.currentSourcePrHead = 'c'.repeat(40); + changed.currentSourcePrBase = 'f'.repeat(40); changed.incidents = [{incidentId: 'incident-1', kind: 'integrity', status: 'open'}]; expect(createPromotionDecision(changed)).toMatchObject({ eligible: false, result: 'denied', - reasons: expect.arrayContaining(['source_pr_head_changed', 'active_release_incident']), + reasons: expect.arrayContaining([ + 'source_pr_head_changed', + 'source_pr_base_changed', + 'active_release_incident', + ]), }); }); @@ -368,6 +1186,12 @@ describe('deterministic artifacts and manifest aggregation', () => { artifacts, }); expect(manifest.artifacts).toHaveLength(10); + expect(manifest.artifacts + .filter((artifact: Record) => artifact.kind === 'standalone') + .every((artifact: Record) => ( + artifact.optionalCapabilities?.schemaVersion + === 'guardscan.runtime-capabilities.v1' + ))).toBe(true); const mismatched = JSON.parse(JSON.stringify(artifacts)); mismatched[5].embeddedExecutableSha256 = 'f'.repeat(64); expect(() => createReleaseManifest(source, { diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index 04f779c..4848d15 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -4,16 +4,21 @@ import yaml from 'js-yaml'; const repositoryRoot = path.resolve(__dirname, '../../..'); const workflowRoot = path.join(repositoryRoot, '.github/workflows'); +const catalogWorkflow = path.join( + repositoryRoot, + 'catalog/homebrew-tap/.github/workflows/verify.yml' +); const releaseWorkflows = [ 'release-build.yml', 'release-canary.yml', + 'release-first-withdrawal.yml', 'release-please.yml', 'release-publish.yml', 'release-train.yml', ]; function workflowSource(filename: string): string { - return fs.readFileSync(path.join(workflowRoot, filename), 'utf8'); + return fs.readFileSync(path.join(workflowRoot, filename), 'utf8').replace(/\r\n?/g, '\n'); } describe('zero-touch release workflow contracts', () => { @@ -21,6 +26,9 @@ describe('zero-touch release workflow contracts', () => { const source = workflowSource('ci.yml'); expect(yaml.load(source)).toBeTruthy(); expect(source).not.toMatch(/^\s+tags:/m); + expect(source).not.toContain('"release/**"'); + expect(source).toContain('group: ci-${{ github.event.pull_request.number || github.ref }}'); + expect(source).toContain('cancel-in-progress: true'); expect(source).not.toContain('npm publish'); expect(source).not.toContain('gh release create'); expect(source).toContain('npm test -- --coverage --runInBand'); @@ -52,9 +60,53 @@ describe('zero-touch release workflow contracts', () => { } }); + it('passes an explicit package manager to the reusable release quality gate', () => { + expect(workflowSource('release-build.yml')) + .toContain('npm run test:package-manager -- --manager npm'); + }); + + it('keeps workflow heredoc terminators at shell column zero', () => { + const sources = [ + ...['ci.yml', ...releaseWorkflows].map(workflowSource), + fs.readFileSync(catalogWorkflow, 'utf8'), + ]; + for (const source of sources) { + const workflow = yaml.load(source) as { + jobs?: Record}>; + }; + for (const job of Object.values(workflow.jobs || {})) { + for (const step of job.steps || []) { + if (!step.run) continue; + const lines = step.run.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const opener = lines[index].match(/<<'?([A-Z][A-Z0-9_]*)'?/); + if (!opener) continue; + const terminatorIndex = lines.findIndex( + (line, candidate) => candidate > index && line.trim() === opener[1] + ); + const terminator = lines[terminatorIndex]; + expect(terminator).toBe(opener[1]); + if (/\bnode(?:\s+-)?\s+< new Function(body)).not.toThrow(); + } + } + } + } + } + }); + it('runs one concurrency-safe RC soak and machine-only promotion train', () => { const train = workflowSource('release-train.yml'); const canary = workflowSource('release-canary.yml'); + const ledgerSeed = JSON.parse(fs.readFileSync( + path.join(repositoryRoot, '.github/release-ledger/active-versions.json'), + 'utf8' + )); + expect(ledgerSeed).toEqual({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + }); expect(train).toContain('cron: "*/30 * * * *"'); expect(train).toContain("group: release-train-${{ inputs.version || 'scheduler' }}"); expect(train).toContain('actions/create-github-app-token@'); @@ -65,7 +117,198 @@ describe('zero-touch release workflow contracts', () => { expect(train).not.toContain('stable-promotion-approval'); expect(canary).toContain('cron: "7 * * * *"'); expect(canary).toContain('group: release-ledger'); + expect(canary).toContain('Install default-branch ledger tooling'); + expect(canary).toMatch( + /record:\n[\s\S]*?actions\/setup-node@[a-f0-9]+[\s\S]*?working-directory: cli\n\s+run: npm ci/ + ); expect(train).toContain('samples.length >= 24'); + expect(train).toContain("types: [catalog_updated]"); + expect(train).toContain('hinted-channel-lock.json'); + expect(train).toContain('channel-preview/v$RELEASE_VERSION'); + expect(train).toContain("type: 'channel_published'"); + expect(train).toContain("type: 'channel_submitted'"); + expect(train).toContain("releaseTrainChannels(process.env.RELEASE_CHANNEL)"); + expect(train).not.toContain("? ['homebrew-core', 'winget', 'chocolatey']"); + expect(train).toContain('cannot start or promote while a stable train remains incomplete'); + expect(canary).toContain("train.channel !== 'stable'"); + expect(canary).toContain('reconcileRelease(materializeReleaseState(readEvents(ledger))).complete'); + expect(train).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + expect(train).toMatch( + /build:\n[\s\S]*?permissions:\n\s+contents: read\n\s+id-token: write\n\s+attestations: write/ + ); + expect(train).toMatch( + /publish:\n[\s\S]*?permissions:\n\s+actions: read\n\s+contents: write\n\s+id-token: write/ + ); + }); + + it('fails closed and persists an idempotent repository-side rollback recovery', () => { + const train = workflowSource('release-train.yml'); + const publish = workflowSource('release-publish.yml'); + expect(train).toContain('repositories: GuardScan,homebrew-tap'); + expect(train).toContain("plan.schemaVersion !== 'guardscan.rollback-plan.v1'"); + expect(train).toContain('verified known-good release is required'); + expect(train).toContain('known-good release ledger is incomplete'); + expect(train).toContain('known-good manifest digest does not match its protected ledger'); + expect(train).toContain('DEFECTIVE_VERSION: ${{ inputs.version }}'); + expect(train).toContain('KNOWN_GOOD_VERSION: ${{ inputs.known_good }}'); + expect(train).toContain('require("./rollback-plan.json").forwardFixBranch'); + expect(train).toContain('Rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION'); + expect(train).toContain('gh pr create "${FORWARD_FIX_PR_ARGS[@]}"'); + expect(train).toContain('gh pr merge --repo ntanwir10/homebrew-tap'); + expect(train).toContain("event.type === 'action_required'"); + expect(train).toContain('active.trains = active.trains.filter'); + expect(train).toContain('rollback-plan-v${{ inputs.version }}'); + expect(train).toContain('rollback-plan.json'); + expect(train).toContain('rollback-evidence.json'); + expect(train).toContain('ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION"'); + expect(train).not.toContain('ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION:$GITHUB_RUN_ID"'); + expect(train).toContain('test "$(git rev-list -n 1 "v$KNOWN_GOOD_VERSION")" = "$KNOWN_GOOD_COMMIT"'); + expect(train).toContain('git worktree add forward-fix-source "v$KNOWN_GOOD_VERSION"'); + expect(train).toContain('FORWARD_FIX_CREATED_AT="$(git show -s --format=%cI "$KNOWN_GOOD_COMMIT")"'); + expect(train).toContain('merged forward-fix pull request does not match the deterministic trusted tree'); + expect(train).toContain('--force-with-lease="refs/heads/$FORWARD_FIX_BRANCH:$FORWARD_FIX_REMOTE_HEAD"'); + expect(train).toContain('forward-fix branch has a closed-unmerged pull request'); + expect(train).toContain('forward-fix pull request is not bound to the pushed head'); + expect(train).toContain('const expected = [\'cli/CHANGELOG.md\', \'cli/package-lock.json\', \'cli/package.json\'];'); + expect(train).toContain('git -C catalog-rollback fetch origin main'); + expect(train).toContain('git -C catalog-rollback switch -c "$CATALOG_BRANCH" "$CATALOG_BASE"'); + expect(train).toContain('--force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD"'); + expect(train).toContain('catalog rollback branch contains unreviewed paths'); + expect(train).toContain("const allowed = ['Formula/guardscan.rb', 'bucket/guardscan.json', 'channel-lock.json'];"); + expect(train).toContain('catalog already-restored path has no bound merged pull request'); + expect(train).toContain('catalog rollback pull request is not bound to the pushed head'); + expect(train).toContain('gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --squash'); + expect(train).not.toContain('gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --auto --squash'); + expect(train).toContain('test "$CATALOG_PR_STATE" = MERGED'); + expect(train).toContain('catalog rollback has no actual merge commit'); + expect(train).toContain('git -C catalog-rollback rev-parse "$CATALOG_MERGE_SHA:$CATALOG_PATH"'); + expect(train).toContain('echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT"'); + expect(train).toContain('EXPECTED_BASE="$(node -p \'require("./promotion-decision.json").stable.sourcePrBase\')"'); + expect(train).toContain('CURRENT_BASE="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)"'); + expect(train).toContain('--squash --match-head-commit "$EXPECTED_HEAD"'); + expect(train).not.toContain('--auto --squash --match-head-commit "$EXPECTED_HEAD"'); + expect(train).toContain('const canonicalEntries = entries => entries.sort(([left], [right])'); + expect(train).toContain('npm: Object.fromEntries(canonicalEntries('); + expect(train).toContain('pypi: Object.fromEntries(canonicalEntries('); + expect(publish).toMatch( + /channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted'/ + ); + expect(publish.match(/channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted'/g)) + .toHaveLength(2); + expect(train).not.toContain('NPM_TOKEN'); + expect(train).not.toContain('PYPI_TOKEN'); + }); + + it('withdraws the first stable release only with protected-ledger authority', () => { + const train = workflowSource('release-train.yml'); + const withdrawal = workflowSource('release-first-withdrawal.yml'); + expect(train).toContain("inputs.known_good == ''"); + expect(train).toContain("inputs.known_good != ''"); + expect(train).toContain('uses: ./.github/workflows/release-first-withdrawal.yml'); + expect(withdrawal).toContain('assertFirstReleaseWithdrawal'); + expect(withdrawal).toContain('first-release-authority.json'); + expect(withdrawal).toContain('--first-release-withdrawal'); + expect(withdrawal).toContain('--first-release-authority first-release-authority.json'); + expect(withdrawal).toContain("plan.mode !== 'first-release-withdrawal'"); + expect(withdrawal).toContain("event.payload.kind === 'recovery'"); + expect(withdrawal).toContain('prepareFirstReleaseCatalogWithdrawal'); + expect(withdrawal).toContain('PUBLISH_BRANCH="guardscan/v$DEFECTIVE_VERSION"'); + expect(withdrawal).toContain('gh pr close --repo ntanwir10/homebrew-tap'); + expect(withdrawal).toContain('completed withdrawal was republished at $CATALOG_PATH'); + expect(withdrawal).toContain("'D\\tFormula/guardscan.rb'"); + expect(withdrawal).toContain("'D\\tbucket/guardscan.json'"); + expect(withdrawal).toContain("'D\\tchannel-lock.json'"); + expect(withdrawal).toContain('catalog withdrawal left $CATALOG_PATH published'); + expect(withdrawal).toContain("github: 'superseded'"); + expect(withdrawal).toContain("homebrew: 'withdrawn'"); + expect(withdrawal).toContain("scoop: 'withdrawn'"); + expect(withdrawal).toContain('externalActionsPending'); + expect(withdrawal).toContain("'provider-actions-pending'"); + expect(withdrawal).not.toContain('git worktree add forward-fix-source'); + expect(withdrawal).not.toContain('known-good-release'); + expect(withdrawal).not.toContain('gh release delete'); + expect(withdrawal).not.toContain('npm unpublish'); + expect(train).toContain('sourcePrBase: process.env.SOURCE_PR_BASE'); + expect(train).toContain('sourcePrTree: process.env.SOURCE_PR_TREE'); + expect(train).not.toMatch(/Persist refetched catalog publication evidence\n\s+if:[^\n]+\n\s+env:\s*\n/); + }); + + it('uses default-branch canary tooling while every matrix entry verifies its own version', () => { + const canary = workflowSource('release-canary.yml'); + expect(canary).not.toContain('implementation_ref'); + expect(canary).not.toContain('trains[0]'); + expect(canary).toContain('ref: ${{ github.event.repository.default_branch }}'); + expect(canary).toContain('VERSION: ${{ matrix.train.version }}'); + expect(canary).toContain('version = \'${{ matrix.train.version }}\''); + expect(canary).toContain('assert_version guardscan --version'); + expect(canary).toContain('test "$ACTUAL_VERSION" = "$VERSION"'); + expect(canary).toContain('test "$(guardscan --version | tr -d \'\\r\')" = "$VERSION"'); + expect(canary).toContain('test "$("$PIPX_GUARDSCAN" --version | tr -d \'\\r\')" = "$VERSION"'); + expect(canary).toContain('$installedVersion -ne $env:VERSION'); + expect(canary).toContain('const expectedTargetCounts = {'); + expect(canary).toContain('resolved.length === expectedCount'); + expect(canary).toContain("type: 'channel_failed'"); + expect(canary).toContain("error: 'one or more current public canary targets failed'"); + expect(canary).toContain('const versionAggregateTimestamp = versionReports'); + expect(canary).not.toContain('{remoteIdentity: `${channel}:${version}`}'); + expect(canary).toContain("if: inputs.version == '' || vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + expect(canary).toMatch( + /record:\n[\s\S]*?if: >-\n\s+always\(\)\n\s+&& vars\.RELEASE_AUTOMATION_ENABLED == 'true'/ + ); + }); + + it('keeps undiscovered moderated packages pending and fails discovered lifecycle errors', () => { + const canary = workflowSource('release-canary.yml'); + expect(canary).toContain('$discovered = $false'); + expect(canary).toContain('$discovered = $true'); + expect(canary).toContain("if ($discovered) { $report.status = 'failed' }"); + expect(canary).not.toContain("throw 'WinGet package is not public yet'"); + expect(canary).not.toContain("throw 'Chocolatey package is not public yet'"); + expect(canary).not.toContain("-notmatch 'not found|No package found|Unable to find'"); + }); + + it('tests every TestPyPI wheel natively before production PyPI publication', () => { + const source = workflowSource('release-publish.yml'); + const workflow = yaml.load(source) as { + jobs: Record}}; + }>; + }; + const lifecycle = workflow.jobs['pypi-test-lifecycle']; + expect(lifecycle.needs).toEqual(['pypi-test']); + expect(lifecycle['runs-on']).toBe('${{ matrix.target.runner }}'); + expect(lifecycle.strategy?.matrix?.target).toEqual([ + {id: 'linux-x64-glibc', runner: 'ubuntu-24.04'}, + {id: 'linux-arm64-glibc', runner: 'ubuntu-24.04-arm'}, + {id: 'darwin-arm64', runner: 'macos-15'}, + {id: 'darwin-x64', runner: 'macos-15-intel'}, + {id: 'windows-x64', runner: 'windows-2025'}, + ]); + expect(workflow.jobs.pypi.needs).toEqual(['pypi-test-lifecycle']); + expect(source).toContain('Test TestPyPI wheel through pip and pipx on ${{ matrix.target.id }}'); + expect(source).toContain('python -m pipx environment --value PIPX_BIN_DIR'); + expect(source).toContain('exercise_guardscan guardscan pip'); + expect(source).toContain('exercise_guardscan "$GUARDSCAN" pipx'); + expect(source).toContain('"$executable" --help'); + expect(source).toContain('--no-telemetry scan --offline --no-cve --skip-tests --skip-ai'); + expect(source).toContain("scan.get('schemaVersion') != 'guardscan.scan.v1'"); + }); + + it('validates and readies the exact same-repository main release PR before candidate tagging', () => { + const train = workflowSource('release-train.yml'); + expect(train).toContain("pr.state !== 'open'"); + expect(train).toContain("pr.base?.ref !== 'main'"); + expect(train).toContain('pr.head?.repo?.full_name?.toLowerCase()'); + expect(train).toContain('process.env.GITHUB_REPOSITORY.toLowerCase()'); + expect(train).toContain('REQUEST_RELEASE_PR: ${{ inputs.release_pr }}'); + expect(train).toContain('EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }}'); + expect(train).toContain('gh pr ready "$REQUEST_RELEASE_PR"'); + expect(train.indexOf('gh pr ready "$REQUEST_RELEASE_PR"')) + .toBeLessThan(train.indexOf('Derive bot-owned RC commit from exact stable PR head')); + expect(train).toContain('--match-head-commit "$EXPECTED_HEAD"'); + expect(train).not.toContain('gh pr ready "${{ inputs.release_pr }}"'); }); it('builds signed artifacts and publishes through isolated provider environments', () => { @@ -87,13 +330,112 @@ describe('zero-touch release workflow contracts', () => { expect(build).toContain('cosign sign-blob --yes'); expect(build).toContain('xcrun notarytool submit'); expect(build).toContain('xcrun stapler staple'); + expect(build).toContain('security find-identity -v -p codesigning "$KEYCHAIN" | grep -F "$IDENTITY"'); + expect(build).toContain("if: always() && matrix.os == 'darwin'"); + expect(build).toContain('security delete-keychain "$RUNNER_TEMP/guardscan-signing.keychain-db" || true'); + expect(build).toContain('rm -f "$RUNNER_TEMP/certificate.p12" "$RUNNER_TEMP/AuthKey.p8"'); expect(build).toContain('Azure/artifact-signing-action@'); expect(build).toContain('actions/attest-build-provenance@'); expect(build).toContain('release-manifest.json'); + expect(build).toContain('npm run test:package'); + expect(build).toContain('npm run test:package-manager'); expect(publish).toContain('--provenance'); + expect(publish).toContain('RELEASE_NPM_VERSION: 11.5.2'); + expect(publish).toContain('npm install --global "npm@${RELEASE_NPM_VERSION}"'); expect(publish).toContain('pypa/gh-action-pypi-publish@'); - expect(publish).toContain('wingetcreate submit'); + expect(publish).toContain('cp payload/*.whl dist/'); + expect(publish).toContain('Verify complete TestPyPI file set'); + expect(publish).toContain('Verify complete PyPI file set'); + expect(publish).toContain('remote == local'); + expect(publish).not.toContain('try:\n try:'); + expect(publish).toContain('wingetcreate.exe submit'); expect(publish).toContain('choco push'); + expect(combined).toContain('/.github/workflows/release-train.yml@'); + expect(combined).not.toContain('/.github/workflows/release-build.yml@'); + }); + + it('binds moderated submissions to exact provider evidence and fail-closed preflights', () => { + const publish = workflowSource('release-publish.yml'); + const train = workflowSource('release-train.yml'); + expect(publish).toContain("schemaVersion = 'guardscan.moderated-submission.v1'"); + expect(publish).toContain('release-winget-evidence-${{ inputs.tag }}'); + expect(publish).toContain('release-chocolatey-evidence-${{ inputs.tag }}'); + expect(publish).toContain('RELEASE_WINGETCREATE_VERSION: 1.12.13.0'); + expect(publish).toContain( + 'RELEASE_WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea' + ); + expect(publish).not.toContain('dotnet tool install --global wingetcreate'); + expect(publish).toContain('Pinned wingetcreate executable failed SHA-256 verification'); + expect(publish).toContain('repos/microsoft/winget-pkgs/contents/${manifestPath}'); + expect(publish).toContain('repos/microsoft/winget-pkgs/pulls/$($pullRequest.number)/files'); + expect(publish).toContain('https://community.chocolatey.org/api/v2/package/guardscan/$version'); + expect(publish).toContain("throw 'WinGet public manifest integrity conflict'"); + expect(publish).toContain("throw 'Chocolatey public package integrity conflict'"); + expect(publish).toContain('protected release ledger prevents a blind duplicate'); + expect(publish.match(/throw 'Unable to fetch protected release ledger'/g)).toHaveLength(2); + expect(train).toContain('release-winget-evidence-${{ needs.prepare.outputs.tag }}'); + expect(train).toContain('release-chocolatey-evidence-${{ needs.prepare.outputs.tag }}'); + expect(train).toContain('readModeratedEvidence'); + expect(train).toContain("evidence.schemaVersion !== 'guardscan.moderated-submission.v1'"); + expect(train).toContain('evidence.remoteIdentity'); + expect(train).toContain('evidence.remoteDigest'); + expect(train).toContain('expectedRemoteIdentity'); + expect(train).toContain('submission: evidence'); + expect(train).toContain('releaseTrainChannels(process.env.RELEASE_CHANNEL)'); + expect(train).not.toMatch( + /for \(const channel of \['winget', 'chocolatey'\]\)[\s\S]*?remoteIdentity: `\$\{channel\}:\$\{tag\}`/ + ); + }); + + it('requires exact public moderated CLI versions after discovery', () => { + const canary = workflowSource('release-canary.yml'); + const publish = workflowSource('release-publish.yml'); + expect(canary).toContain('$installedVersion = (& guardscan --version | Out-String).Trim()'); + expect(canary).toContain('$installedVersion -ne \'${{ matrix.train.version }}\''); + expect(publish.match(/\$installedVersion -ne '\$\{\{ inputs\.tag \}\}'\.TrimStart\('v'\)/g)) + .toHaveLength(2); + expect(publish).toContain('WinGet is unavailable on the selected Windows runner'); + expect(publish).toContain('Chocolatey is unavailable on the selected Windows runner'); + }); + + it('uses one cryptographically bound shared Homebrew and Scoop catalog', () => { + const publish = workflowSource('release-publish.yml'); + const canary = workflowSource('release-canary.yml'); + const train = workflowSource('release-train.yml'); + const combined = `${publish}\n${canary}\n${train}`; + expect(combined).toContain('ntanwir10/homebrew-tap'); + expect(combined).not.toContain('scoop-bucket'); + expect(publish).toContain('Formula/guardscan.rb'); + expect(publish).toContain('bucket/guardscan.json'); + expect(publish).toContain('channel-lock.json'); + expect(publish).toContain('catalog/homebrew-tap/.github/workflows/verify.yml'); + expect(publish).toContain('channel-preview/$RELEASE_TAG'); + expect(publish).toContain('--generator-commit "$RELEASE_REF"'); + expect(train).toContain('github:${catalogResult.repository}@${catalogResult.commit}#${catalogPath}'); + expect(train).toContain('catalog: {'); + expect(train).toContain("type: 'channel_submitted'"); + }); + + it('ships a pinned, reproducible, native catalog verification workflow', () => { + const source = fs.readFileSync(catalogWorkflow, 'utf8'); + expect(yaml.load(source)).toBeTruthy(); + for (const match of source.matchAll(/^\s*uses:\s+([^./\s][^@\s]+)@([^\s#]+)/gm)) { + expect(match[2]).toMatch(/^[a-f0-9]{40}$/); + } + expect(source).toContain('manifest digest mismatch'); + expect(source).toContain('published=false'); + expect(source).toContain('catalog metadata is partially initialized'); + expect(source).toContain("needs.integrity.outputs.published == 'true'"); + expect(source).toContain('node scripts/release/index.js catalog'); + expect(source).toContain('--check'); + expect(source).toContain('runs-on: macos-15'); + expect(source).toContain('runs-on: windows-2025'); + expect(source).toContain('brew install --formula'); + expect(source).toContain('SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed'); + expect(source).toContain('SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939'); + expect(source).toContain('scoop install guardscan-catalog/guardscan'); + expect(source).toContain('guardscan.exe'); + expect(source).toContain("event_type: 'catalog_updated'"); }); it('exposes every required maintainer release interface', () => { diff --git a/cli/__tests__/scripts/standalone-artifact.test.ts b/cli/__tests__/scripts/standalone-artifact.test.ts new file mode 100644 index 0000000..f9af78d --- /dev/null +++ b/cli/__tests__/scripts/standalone-artifact.test.ts @@ -0,0 +1,125 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const { + buildStandaloneArtifact, +} = require('../../scripts/release/standalone-artifact') as { + buildStandaloneArtifact: ( + source: Record, + executableFile: string, + platform: Record, + outputDir: string, + timestamp: string, + evidenceFile: string + ) => {metadata: Record}; +}; + +const source = { + version: '1.2.3', + tag: 'v1.2.3', + commit: 'a'.repeat(40), +}; +const platform = {os: 'linux', arch: 'x64', libc: 'glibc'}; +const timestamp = '2026-08-02T12:00:00.000Z'; + +function optionalCapabilities(): Record { + return { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }; +} + +function evidence(): Record { + return { + schemaVersion: 'guardscan.standalone-evidence.v1', + ...source, + platform, + optionalCapabilities: optionalCapabilities(), + signatures: [{ + type: 'sigstore', + url: `https://github.com/ntanwir10/GuardScan/releases/download/${source.tag}/linux.sigstore.json`, + verified: true, + }], + sboms: [{ + type: 'spdx', + url: `https://github.com/ntanwir10/GuardScan/releases/download/${source.tag}/guardscan.spdx.json`, + verified: true, + }], + provenance: { + type: 'slsa', + url: 'https://github.com/ntanwir10/GuardScan/attestations/123', + verified: true, + }, + }; +} + +function withArtifact( + descriptor: Record, + assertion: (result: {metadata: Record}) => void +): void { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-standalone-artifact-')); + try { + const executable = path.join(root, 'guardscan'); + const evidenceFile = path.join(root, 'evidence.json'); + fs.writeFileSync(executable, 'signed executable'); + fs.writeFileSync(evidenceFile, `${JSON.stringify(descriptor)}\n`); + assertion(buildStandaloneArtifact( + source, + executable, + platform, + path.join(root, 'output'), + timestamp, + evidenceFile + )); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +} + +describe('production standalone artifact evidence', () => { + it('binds observed optional capability evidence into artifact metadata', () => { + const descriptor = evidence(); + + withArtifact(descriptor, result => { + expect(result.metadata.artifact.optionalCapabilities) + .toEqual(descriptor.optionalCapabilities); + expect(result.metadata.artifact.capabilities).toEqual({ + coreScan: true, + sbom: true, + chartRendering: false, + accurateTokenCounting: false, + }); + }); + }); + + it('rejects standalone evidence without observed optional capability evidence', () => { + const descriptor = evidence(); + delete descriptor.optionalCapabilities; + + expect(() => withArtifact(descriptor, () => undefined)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); + + it('rejects inconsistent optional capability evidence', () => { + const descriptor = evidence(); + descriptor.optionalCapabilities.tokenCounting.dependencyAvailable = true; + + expect(() => withArtifact(descriptor, () => undefined)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); +}); diff --git a/cli/__tests__/scripts/standalone-builder.test.ts b/cli/__tests__/scripts/standalone-builder.test.ts index f03d3d5..9eed22e 100644 --- a/cli/__tests__/scripts/standalone-builder.test.ts +++ b/cli/__tests__/scripts/standalone-builder.test.ts @@ -1,19 +1,43 @@ +import fs from 'fs'; +import os from 'os'; import path from 'path'; const { OPTIONAL_EXTERNALS, PROTOTYPE_SCHEMA, assertExternalAllowlist, + assertReducedCapabilityEvidence, bundleOptions, externalPackages, hostPlatform, + renameWithTransientRetry, } = require('../../scripts/release/standalone') as { OPTIONAL_EXTERNALS: string[]; PROTOTYPE_SCHEMA: string; assertExternalAllowlist: (metafile: Record) => string[]; + assertReducedCapabilityEvidence: (evidence: Record) => Record; bundleOptions: (entryPoint: string, outputFile: string) => Record; externalPackages: (metafile: Record) => string[]; hostPlatform: () => {os: string; arch: string}; + renameWithTransientRetry: ( + source: string, + destination: string, + options?: { + rename?: (source: string, destination: string) => Promise; + wait?: (delay: number) => Promise; + delays?: number[]; + } + ) => Promise; +}; + +const { + FORBIDDEN_RUNTIME_LITERALS, + assertCompiledRuntimeFilesClean, + assertRuntimeArtifactClean, +} = require('../../scripts/release/runtime-artifact-policy') as { + FORBIDDEN_RUNTIME_LITERALS: readonly string[]; + assertCompiledRuntimeFilesClean: (root: string, files: Iterable, label?: string) => string[]; + assertRuntimeArtifactClean: (content: Buffer | string, label: string) => void; }; function metafile(imports: Array<{path: string; external?: boolean}>): Record { @@ -25,6 +49,43 @@ function metafile(imports: Array<{path: string; external?: boolean}>): Record { + it('fails closed on retired API literals in runtime bytes', () => { + expect(FORBIDDEN_RUNTIME_LITERALS).toEqual([ + 'api.guardscancli.com', + 'GUARDSCAN_API_URL', + 'DEFAULT_API_BASE_URL', + ]); + expect(() => assertRuntimeArtifactClean(Buffer.from('clean runtime'), 'SEA payload')).not.toThrow(); + for (const literal of FORBIDDEN_RUNTIME_LITERALS) { + expect(() => assertRuntimeArtifactClean( + Buffer.from(`runtime contains ${literal}`), + 'SEA payload' + )).toThrow(`SEA payload contains forbidden retired runtime literals: ${literal}`); + } + }); + + it('scans packed compiled JavaScript without rejecting packaged historical documentation', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-runtime-policy-')); + try { + fs.mkdirSync(path.join(root, 'dist')); + fs.writeFileSync(path.join(root, 'dist', 'index.js'), 'module.exports = true;\n'); + fs.writeFileSync(path.join(root, 'README.md'), FORBIDDEN_RUNTIME_LITERALS.join('\n')); + fs.writeFileSync(path.join(root, 'CHANGELOG.md'), FORBIDDEN_RUNTIME_LITERALS.join('\n')); + const files = ['README.md', 'CHANGELOG.md', 'dist/index.js']; + + expect(() => assertCompiledRuntimeFilesClean(root, files.slice(0, 2), 'npm packed runtime')) + .toThrow('npm packed runtime contains no compiled JavaScript under dist/'); + expect(assertCompiledRuntimeFilesClean(root, files, 'npm packed runtime')) + .toEqual(['dist/index.js']); + + fs.writeFileSync(path.join(root, 'dist', 'index.js'), 'const url = "api.guardscancli.com";\n'); + expect(() => assertCompiledRuntimeFilesClean(root, files, 'npm packed runtime')) + .toThrow(/npm packed runtime dist\/index\.js contains forbidden retired runtime literals/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('creates a single CommonJS Node 22 bundle and externalizes only optional native capabilities', () => { const options = bundleOptions('/source/dist/index.js', '/output/guardscan.bundle.cjs'); expect(options).toMatchObject({ @@ -46,6 +107,56 @@ describe('standalone executable builder contract', () => { expect(PROTOTYPE_SCHEMA).toBe('guardscan.standalone-prototype.v1'); }); + it('accepts observed reduced-capability evidence from the standalone executable', () => { + const evidence = { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }; + + expect(assertReducedCapabilityEvidence(evidence)).toBe(evidence); + }); + + it.each([ + ['tiktoken was unexpectedly available', { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', dependencyAvailable: true, mode: 'accurate', + sampleTokenCount: 7, safeFallbackObserved: false, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', dependencyAvailable: false, + mode: 'unavailable', safeFallbackObserved: true, + }, + }], + ['chart fallback was not observed', { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', dependencyAvailable: false, mode: 'estimated', + sampleTokenCount: 7, safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', dependencyAvailable: false, + mode: 'unavailable', safeFallbackObserved: false, + }, + }], + ])('rejects standalone evidence when %s', (_label, evidence) => { + expect(() => assertReducedCapabilityEvidence(evidence)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); + it('ignores Node built-ins and rejects undeclared runtime package dependencies', () => { const allowed = metafile([ {path: 'node:fs', external: true}, @@ -68,4 +179,36 @@ describe('standalone executable builder contract', () => { expect(['arm64', 'x64']).toContain(platform.arch); expect(path.isAbsolute(process.execPath)).toBe(true); }); + + it.each(['EACCES', 'EBUSY', 'EPERM'])('retries a transient %s atomic publish failure', async code => { + const transient = Object.assign(new Error('temporarily locked'), {code}); + const rename = jest.fn() + .mockRejectedValueOnce(transient) + .mockResolvedValueOnce(undefined); + const wait = jest.fn().mockResolvedValue(undefined); + + await renameWithTransientRetry('/stage', '/release', { + rename, + wait, + delays: [25], + }); + + expect(rename).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(25); + }); + + it('does not retry a non-transient atomic publish failure', async () => { + const permanent = Object.assign(new Error('invalid target'), {code: 'EINVAL'}); + const rename = jest.fn().mockRejectedValue(permanent); + const wait = jest.fn().mockResolvedValue(undefined); + + await expect(renameWithTransientRetry('/stage', '/release', { + rename, + wait, + delays: [25], + })).rejects.toBe(permanent); + + expect(rename).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + }); }); diff --git a/cli/__tests__/utils/api-client.test.ts b/cli/__tests__/utils/api-client.test.ts deleted file mode 100644 index 39ff5d0..0000000 --- a/cli/__tests__/utils/api-client.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios'; -import { APIClient } from '../../src/utils/api-client'; - -jest.mock('axios', () => ({ - __esModule: true, - default: { - create: jest.fn(() => ({post: jest.fn()})), - isAxiosError: jest.fn(() => false), - }, -})); - -describe('telemetry API transport policy', () => { - it('disables HTTP redirects so telemetry cannot be forwarded to another origin', () => { - new APIClient('https://telemetry.example.test'); - - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({maxRedirects: 0})); - }); -}); diff --git a/cli/__tests__/utils/path-helper.test.ts b/cli/__tests__/utils/path-helper.test.ts index d0311c1..b03e031 100644 --- a/cli/__tests__/utils/path-helper.test.ts +++ b/cli/__tests__/utils/path-helper.test.ts @@ -1,9 +1,25 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { ensureDirectoryExists, getSafeHomeDir } from '../../src/utils/path-helper'; +import { + ensureDirectoryExists, + getSafeHomeDir, + repositoryRelativePath, +} from '../../src/utils/path-helper'; describe('path-helper private state handling', () => { + it('normalizes repository paths to portable POSIX separators', () => { + const repoRoot = path.join(os.tmpdir(), 'workspace', 'project'); + const authPath = path.join(repoRoot, 'src', 'auth.ts'); + + expect(repositoryRelativePath(repoRoot, authPath)) + .toBe('src/auth.ts'); + expect(repositoryRelativePath(repoRoot, 'src/auth.ts')) + .toBe('src/auth.ts'); + expect(repositoryRelativePath(repoRoot, 'src\\auth.ts')) + .toBe('src/auth.ts'); + }); + const originalEnv = { ...process.env }; afterEach(() => { diff --git a/cli/__tests__/utils/private-state.test.ts b/cli/__tests__/utils/private-state.test.ts index 66add52..7f16c03 100644 --- a/cli/__tests__/utils/private-state.test.ts +++ b/cli/__tests__/utils/private-state.test.ts @@ -73,10 +73,12 @@ describe('private state persistence', () => { for (let index = 0; index < 5; index++) { fs.writeFileSync(path.join(root, `${index}.json`), '{}'); } - expect(listDirectoryBounded(root, 3)).toEqual({ - names: ['0.json', '1.json', '2.json'], - truncated: true, - }); + const listing = listDirectoryBounded(root, 3); + expect(listing.truncated).toBe(true); + expect(listing.names).toHaveLength(3); + expect(listing.names).toEqual([...listing.names].sort((left, right) => left.localeCompare(right))); + expect(new Set(listing.names).size).toBe(3); + expect(listing.names.every(name => /^[0-4]\.json$/.test(name))).toBe(true); }); it('can process every directory entry with bounded callback memory', () => { diff --git a/cli/__tests__/utils/runtime-capabilities.test.ts b/cli/__tests__/utils/runtime-capabilities.test.ts new file mode 100644 index 0000000..21c9139 --- /dev/null +++ b/cli/__tests__/utils/runtime-capabilities.test.ts @@ -0,0 +1,77 @@ +import { + collectRuntimeCapabilities, + RUNTIME_CAPABILITY_SCHEMA, +} from '../../src/utils/runtime-capabilities'; + +function tokenCounter(available: boolean, mode: 'accurate' | 'estimated') { + return { + countTokens: jest.fn().mockReturnValue({count: 7, method: mode, model: 'gpt-4o'}), + getStatus: jest.fn().mockReturnValue({ + tiktokenAvailable: available, + claudeTokenizerAvailable: false, + recommendedDependencies: [], + }), + cleanup: jest.fn(), + }; +} + +describe('runtime capability evidence', () => { + it('records both safe reduced-capability paths when optional modules are unavailable', async () => { + const counter = tokenCounter(false, 'estimated'); + const missingChart = Object.assign( + new Error("Cannot find module 'chartjs-node-canvas'"), + {code: 'MODULE_NOT_FOUND'} + ); + + const evidence = await collectRuntimeCapabilities({ + createTokenCounter: () => counter, + loadChartRenderer: async () => { throw missingChart; }, + }); + + expect(evidence).toEqual({ + schemaVersion: RUNTIME_CAPABILITY_SCHEMA, + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }); + expect(counter.cleanup).toHaveBeenCalledTimes(1); + }); + + it('preserves native optional capabilities when the modules are installed', async () => { + const counter = tokenCounter(true, 'accurate'); + + const evidence = await collectRuntimeCapabilities({ + createTokenCounter: () => counter, + loadChartRenderer: async () => ({chartGenerator: {}}), + }); + + expect(evidence.tokenCounting).toMatchObject({ + dependencyAvailable: true, + mode: 'accurate', + safeFallbackObserved: false, + }); + expect(evidence.chartRendering).toEqual({ + dependency: 'chartjs-node-canvas', + dependencyAvailable: true, + mode: 'native', + safeFallbackObserved: false, + }); + }); + + it('fails closed when chart loading fails for an unexpected reason', async () => { + await expect(collectRuntimeCapabilities({ + createTokenCounter: () => tokenCounter(false, 'estimated'), + loadChartRenderer: async () => { throw new Error('chart initialization corrupted'); }, + })).rejects.toThrow('chart initialization corrupted'); + }); +}); diff --git a/cli/__tests__/utils/telemetry-client.test.ts b/cli/__tests__/utils/telemetry-client.test.ts new file mode 100644 index 0000000..e39e4ab --- /dev/null +++ b/cli/__tests__/utils/telemetry-client.test.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; +import { TelemetryClient } from '../../src/utils/telemetry-client'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn(() => ({post: jest.fn()})), + isAxiosError: jest.fn(() => false), + }, +})); + +describe('telemetry transport policy', () => { + const originalTelemetryUrl = process.env.GUARDSCAN_TELEMETRY_URL; + const originalLegacyApiUrl = process.env.GUARDSCAN_API_URL; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.GUARDSCAN_TELEMETRY_URL; + delete process.env.GUARDSCAN_API_URL; + }); + + afterAll(() => { + if (originalTelemetryUrl === undefined) delete process.env.GUARDSCAN_TELEMETRY_URL; + else process.env.GUARDSCAN_TELEMETRY_URL = originalTelemetryUrl; + if (originalLegacyApiUrl === undefined) delete process.env.GUARDSCAN_API_URL; + else process.env.GUARDSCAN_API_URL = originalLegacyApiUrl; + }); + + it('disables HTTP redirects so telemetry cannot be forwarded to another origin', () => { + new TelemetryClient('https://telemetry.example.test'); + + expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({maxRedirects: 0})); + }); + + it('does not create a transport from the removed GUARDSCAN_API_URL alias', () => { + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + + const client = new TelemetryClient(''); + + expect(client.getBaseUrl()).toBe(''); + expect(axios.create).not.toHaveBeenCalled(); + }); + + it('uses the caller-provided endpoint regardless of legacy process state', () => { + process.env.GUARDSCAN_TELEMETRY_URL = 'https://telemetry.example.test'; + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + + const client = new TelemetryClient('https://telemetry.example.test'); + + expect(client.getBaseUrl()).toBe('https://telemetry.example.test'); + expect(axios.create).toHaveBeenCalledTimes(1); + expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://telemetry.example.test', + })); + }); +}); diff --git a/cli/docs/DEBUGGING.md b/cli/docs/DEBUGGING.md index 6670ac7..a663003 100644 --- a/cli/docs/DEBUGGING.md +++ b/cli/docs/DEBUGGING.md @@ -150,9 +150,12 @@ Look for: - Branch detection - Repo ID generation -### 5. Backend API Issues +### 5. Self-Hosted Telemetry Collector Issues -For backend debugging, check Cloudflare Workers logs. Debug logging is automatically enabled in development mode. +GuardScan operates no hosted telemetry backend. If explicit telemetry sync +fails, verify `GUARDSCAN_TELEMETRY_URL`, inspect the logs and policies of the +user-operated collector, and run `guardscan telemetry status`. GuardScan debug +logging is automatically enabled in development mode. ## JSON Output for CI @@ -260,6 +263,6 @@ If debug logging doesn't help resolve your issue: For more information, see: -- [Testing Guide](./TESTING.md) +- [Testing Tools Guide](./TESTING_TOOLS.md) - [Performance Guide](./PERFORMANCE.md) - [Main README](../README.md) diff --git a/cli/docs/PERFORMANCE.md b/cli/docs/PERFORMANCE.md index 70c3be8..f1bda84 100644 --- a/cli/docs/PERFORMANCE.md +++ b/cli/docs/PERFORMANCE.md @@ -190,7 +190,7 @@ guardscan config **Solution**: -- Use `--no-cloud` flag to skip cloud dependency checks +- Enable `offlineMode` in config to skip online dependency and license advisory checks - Cache dependency results - Limit to production dependencies only diff --git a/cli/docs/TESTING_TOOLS.md b/cli/docs/TESTING_TOOLS.md new file mode 100644 index 0000000..10385ab --- /dev/null +++ b/cli/docs/TESTING_TOOLS.md @@ -0,0 +1,19 @@ +# Testing Tools Guide + +GuardScan's `perf` and `mutation` commands integrate with optional external tools. These tools are not bundled with GuardScan. + +## Performance + +- `guardscan perf --load` and `guardscan perf --stress` require `k6`. +- `guardscan perf --web ` requires Lighthouse. +- Install k6 from . +- Install Lighthouse with `npm install -g lighthouse`. + +## Mutation Testing + +- JavaScript and TypeScript mutation testing uses Stryker when available. +- Install Stryker with `npm install --save-dev @stryker-mutator/core`. + +## Offline Behavior + +These tools run locally, but the target application or audited URL may require network access. GuardScan passes URLs and generated script paths as process arguments rather than shell-interpolated strings. diff --git a/cli/package-lock.json b/cli/package-lock.json index 522aa0e..6b971d1 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -19,7 +19,7 @@ "fast-glob": "^3.3.2", "ignore": "^5.3.0", "inquirer": "^8.2.5", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.1", "marked": "^11.0.0", "openai": "^6.9.0", "ora": "^5.4.1", @@ -1836,9 +1836,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -2375,16 +2375,16 @@ } }, "node_modules/@stryker-mutator/core/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@stryker-mutator/core/node_modules/chalk": { @@ -3007,16 +3007,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3453,9 +3453,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4635,9 +4635,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -6064,9 +6064,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", diff --git a/cli/package.json b/cli/package.json index ce2af0d..7eaa5ee 100644 --- a/cli/package.json +++ b/cli/package.json @@ -89,7 +89,7 @@ "fast-glob": "^3.3.2", "ignore": "^5.3.0", "inquirer": "^8.2.5", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.1", "marked": "^11.0.0", "openai": "^6.9.0", "ora": "^5.4.1", @@ -127,7 +127,7 @@ "node": ">=22.0.0" }, "overrides": { - "brace-expansion@<1.1.16": "1.1.16", - "qs@<6.15.3": "6.15.3" + "brace-expansion@<1.1.18": "1.1.18", + "qs": "6.15.3" } } diff --git a/cli/schemas/guardscan.channel-catalog.v1.schema.json b/cli/schemas/guardscan.channel-catalog.v1.schema.json new file mode 100644 index 0000000..412a4cb --- /dev/null +++ b/cli/schemas/guardscan.channel-catalog.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://guardscancli.com/schemas/guardscan.channel-catalog.v1.schema.json", + "title": "GuardScan generated shared channel catalog lock", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "source", "generator", "files"], + "properties": { + "schemaVersion": { "const": "guardscan.channel-catalog.v1" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "tag", + "commit", + "manifestUrl", + "manifestSha256" + ], + "properties": { + "repository": { "const": "ntanwir10/GuardScan" }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" + }, + "tag": { + "type": "string", + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" + }, + "commit": { "$ref": "#/definitions/commit" }, + "manifestUrl": { + "type": "string", + "pattern": "^https://github\\.com/ntanwir10/GuardScan/releases/download/v[^/]+/release-manifest\\.json$" + }, + "manifestSha256": { "$ref": "#/definitions/sha256" } + } + }, + "generator": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "commit"], + "properties": { + "repository": { "const": "ntanwir10/GuardScan" }, + "commit": { "$ref": "#/definitions/commit" } + } + }, + "files": { + "type": "object", + "additionalProperties": false, + "required": ["Formula/guardscan.rb", "bucket/guardscan.json"], + "properties": { + "Formula/guardscan.rb": { "$ref": "#/definitions/file" }, + "bucket/guardscan.json": { "$ref": "#/definitions/file" } + } + } + }, + "definitions": { + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "file": { + "type": "object", + "additionalProperties": false, + "required": ["sha256"], + "properties": { + "sha256": { "$ref": "#/definitions/sha256" } + } + } + } +} diff --git a/cli/schemas/guardscan.promotion-decision.v1.schema.json b/cli/schemas/guardscan.promotion-decision.v1.schema.json index 6f80e36..59fd46b 100644 --- a/cli/schemas/guardscan.promotion-decision.v1.schema.json +++ b/cli/schemas/guardscan.promotion-decision.v1.schema.json @@ -62,7 +62,9 @@ "publishedAt", "eligibleAt", "sourcePr", - "sourcePrHead" + "sourcePrHead", + "sourcePrBase", + "sourcePrTree" ], "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[0-9]+$" }, @@ -72,18 +74,29 @@ "publishedAt": { "type": "string", "format": "date-time" }, "eligibleAt": { "type": "string", "format": "date-time" }, "sourcePr": { "type": "integer", "minimum": 1 }, - "sourcePrHead": { "$ref": "#/definitions/commit" } + "sourcePrHead": { "$ref": "#/definitions/commit" }, + "sourcePrBase": { "$ref": "#/definitions/commit" }, + "sourcePrTree": { "$ref": "#/definitions/commit" } } }, "stable": { "type": "object", "additionalProperties": false, - "required": ["version", "tag", "sourcePr", "sourcePrHead"], + "required": [ + "version", + "tag", + "sourcePr", + "sourcePrHead", + "sourcePrBase", + "sourcePrTree" + ], "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, "sourcePr": { "type": "integer", "minimum": 1 }, - "sourcePrHead": { "$ref": "#/definitions/commit" } + "sourcePrHead": { "$ref": "#/definitions/commit" }, + "sourcePrBase": { "$ref": "#/definitions/commit" }, + "sourcePrTree": { "$ref": "#/definitions/commit" } } }, "policy": { diff --git a/cli/schemas/guardscan.release-event.v1.schema.json b/cli/schemas/guardscan.release-event.v1.schema.json index d0c82b0..423da03 100644 --- a/cli/schemas/guardscan.release-event.v1.schema.json +++ b/cli/schemas/guardscan.release-event.v1.schema.json @@ -41,9 +41,14 @@ "channel_accepted", "channel_verified", "channel_failed", + "channel_rejected", + "channel_corrected", + "channel_resubmitted", "canary_recorded", "promotion_decided", "rollback_started", + "rollback_repository_completed", + "action_required", "withdrawn", "superseded", "incident_opened", @@ -58,6 +63,7 @@ "bun", "github", "homebrew", + "homebrew-core", "scoop", "winget", "chocolatey", @@ -67,5 +73,195 @@ "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 }, "payload": { "type": "object", "maxProperties": 30 }, "eventHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "train_started" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/legacyTrainStart" }, + { "$ref": "#/definitions/sourceBoundTrainStart" } + ] + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "rollback_started" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/knownGoodRollbackStart" }, + { "$ref": "#/definitions/legacyKnownGoodRollbackStart" }, + { "$ref": "#/definitions/firstReleaseWithdrawalStart" } + ] + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "rollback_repository_completed" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/knownGoodRepositoryEvidence" }, + { "$ref": "#/definitions/firstReleaseRepositoryEvidence" } + ] + } + } + } + } + ], + "definitions": { + "releaseChannels": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "npm", "pnpm", "yarn", "bun", "github", "homebrew", + "homebrew-core", "scoop", "winget", "chocolatey", "pypi" + ] + } + }, + "legacyTrainStart": { + "type": "object", + "additionalProperties": false, + "required": ["channels"], + "properties": { + "channels": { "$ref": "#/definitions/releaseChannels" } + } + }, + "sourceBoundTrainStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", "releasePr", "sourcePrHead", "sourcePrBase", "sourcePrTree", "channels" + ], + "properties": { + "profile": { "const": "full" }, + "releasePr": { "type": "integer", "minimum": 1 }, + "sourcePrHead": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sourcePrBase": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sourcePrTree": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "channels": { "$ref": "#/definitions/releaseChannels" } + } + }, + "knownGoodRollbackStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "mode": { "const": "known-good" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 } + } + }, + "legacyKnownGoodRollbackStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 } + } + }, + "firstReleaseWithdrawalStart": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "requiredNextVersion"], + "properties": { + "mode": { "const": "first-release-withdrawal" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } + } + }, + "knownGoodRepositoryEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mode", + "defectiveVersion", + "knownGoodVersion", + "forwardFix", + "catalog", + "completedAt" + ], + "properties": { + "schemaVersion": { "const": "guardscan.rollback-repository-evidence.v1" }, + "mode": { "const": "known-good" }, + "defectiveVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFix": { "type": "object" }, + "catalog": { "type": "object" }, + "completedAt": { "type": "string", "format": "date-time" } + } + }, + "firstReleaseRepositoryEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mode", + "defectiveVersion", + "requiredNextVersion", + "externalActionsPending", + "catalog", + "completedAt" + ], + "properties": { + "schemaVersion": { "const": "guardscan.rollback-repository-evidence.v1" }, + "mode": { "const": "first-release-withdrawal" }, + "defectiveVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "externalActionsPending": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["npm", "pypi", "homebrew-core", "winget", "chocolatey"] + } + }, + "catalog": { + "type": "object", + "additionalProperties": false, + "required": ["state", "branch", "pullRequest", "commit"], + "properties": { + "state": { "enum": ["already-absent", "removed"] }, + "branch": { "type": "string", "minLength": 1, "maxLength": 200 }, + "pullRequest": { "type": "integer", "minimum": 0 }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + } + }, + "completedAt": { "type": "string", "format": "date-time" } + } + } } } diff --git a/cli/schemas/guardscan.release-manifest.v1.schema.json b/cli/schemas/guardscan.release-manifest.v1.schema.json index 18aa722..0b880c4 100644 --- a/cli/schemas/guardscan.release-manifest.v1.schema.json +++ b/cli/schemas/guardscan.release-manifest.v1.schema.json @@ -101,6 +101,48 @@ "accurateTokenCounting": { "type": "boolean" } } }, + "optionalCapabilities": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "tokenCounting", "chartRendering"], + "properties": { + "schemaVersion": { "const": "guardscan.runtime-capabilities.v1" }, + "tokenCounting": { + "type": "object", + "additionalProperties": false, + "required": [ + "dependency", + "dependencyAvailable", + "mode", + "sampleTokenCount", + "safeFallbackObserved" + ], + "properties": { + "dependency": { "const": "tiktoken" }, + "dependencyAvailable": { "const": false }, + "mode": { "const": "estimated" }, + "sampleTokenCount": { "type": "integer", "minimum": 1 }, + "safeFallbackObserved": { "const": true } + } + }, + "chartRendering": { + "type": "object", + "additionalProperties": false, + "required": [ + "dependency", + "dependencyAvailable", + "mode", + "safeFallbackObserved" + ], + "properties": { + "dependency": { "const": "chartjs-node-canvas" }, + "dependencyAvailable": { "const": false }, + "mode": { "const": "unavailable" }, + "safeFallbackObserved": { "const": true } + } + } + } + }, "source": { "type": "object", "additionalProperties": false, @@ -204,6 +246,7 @@ "archiveFormat": {}, "entrypoint": {}, "url": {}, + "optionalCapabilities": {}, "signatures": {}, "sboms": {}, "archiveEntries": {}, @@ -215,6 +258,7 @@ "archiveFormat", "entrypoint", "url", + "optionalCapabilities", "signatures", "sboms", "archiveEntries", @@ -264,6 +308,7 @@ "sha256": { "$ref": "#/definitions/sha256" }, "source": { "$ref": "#/definitions/source" }, "capabilities": { "$ref": "#/definitions/capabilities" }, + "optionalCapabilities": { "$ref": "#/definitions/optionalCapabilities" }, "platform": { "$ref": "#/definitions/platform" }, "archiveFormat": { "enum": ["tar.gz", "zip"] }, "entrypoint": { diff --git a/cli/schemas/guardscan.release-state.v2.schema.json b/cli/schemas/guardscan.release-state.v2.schema.json index 358969f..13256ec 100644 --- a/cli/schemas/guardscan.release-state.v2.schema.json +++ b/cli/schemas/guardscan.release-state.v2.schema.json @@ -28,7 +28,20 @@ "channels": { "type": "object", "minProperties": 1, - "additionalProperties": { "$ref": "#/definitions/channel" } + "additionalProperties": false, + "properties": { + "npm": { "$ref": "#/definitions/primaryPublicationChannel" }, + "pnpm": { "$ref": "#/definitions/channel" }, + "yarn": { "$ref": "#/definitions/channel" }, + "bun": { "$ref": "#/definitions/channel" }, + "github": { "$ref": "#/definitions/primaryPublicationChannel" }, + "homebrew": { "$ref": "#/definitions/channel" }, + "homebrew-core": { "$ref": "#/definitions/channel" }, + "scoop": { "$ref": "#/definitions/channel" }, + "winget": { "$ref": "#/definitions/channel" }, + "chocolatey": { "$ref": "#/definitions/channel" }, + "pypi": { "$ref": "#/definitions/primaryPublicationChannel" } + } }, "canaries": { "type": "object", @@ -41,9 +54,35 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/incident" } }, - "promotion": { "type": "object" } + "actionRequired": { + "type": "array", + "items": { "$ref": "#/definitions/actionRequired" } + }, + "promotion": { "type": "object" }, + "recovery": { "$ref": "#/definitions/recovery" } }, "definitions": { + "primaryPublicationChannel": { + "allOf": [ + { "$ref": "#/definitions/channel" }, + { + "if": { + "type": "object", + "properties": { + "status": { "enum": ["published", "verified", "superseded"] } + }, + "required": ["status"] + }, + "then": { + "type": "object", + "properties": { + "publication": { "$ref": "#/definitions/publicationEvidence" } + }, + "required": ["publication"] + } + } + ] + }, "channel": { "type": "object", "additionalProperties": false, @@ -57,6 +96,9 @@ "accepted", "verified", "failed", + "rejected", + "corrected", + "resubmitted", "withdrawn", "superseded" ] @@ -69,9 +111,66 @@ "updatedAt": { "type": "string", "format": "date-time" }, "remoteIdentity": { "type": "string", "minLength": 1, "maxLength": 500 }, "remoteDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "catalog": { "$ref": "#/definitions/catalogEvidence" }, + "publication": { "$ref": "#/definitions/publicationEvidence" }, + "submission": { "type": "object" }, "error": { "type": "string", "minLength": 1, "maxLength": 2000 } } }, + "publicationEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "channel", + "version", + "tag", + "remoteIdentity", + "aggregateSha256", + "files" + ], + "properties": { + "schemaVersion": { "const": "guardscan.provider-publication.v1" }, + "channel": { "enum": ["github", "npm", "pypi"] }, + "version": { "type": "string", "minLength": 1, "maxLength": 100 }, + "tag": { "type": "string", "minLength": 2, "maxLength": 101 }, + "remoteIdentity": { "type": "string", "minLength": 1, "maxLength": 500 }, + "aggregateSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "files": { + "type": "object", + "minProperties": 1, + "maxProperties": 100, + "propertyNames": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+~-]{0,199}$" + }, + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + }, + "catalogEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "commit", + "pullRequest", + "lockDigest", + "manifestDigest", + "path", + "fileDigest" + ], + "properties": { + "repository": { "const": "ntanwir10/homebrew-tap" }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "pullRequest": { "type": "integer", "minimum": 1 }, + "lockDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "manifestDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "path": { + "enum": ["Formula/guardscan.rb", "bucket/guardscan.json"] + }, + "fileDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, "canary": { "type": "object", "additionalProperties": false, @@ -87,12 +186,72 @@ "additionalProperties": false, "required": ["kind", "status", "openedAt", "summary"], "properties": { - "kind": { "enum": ["integrity", "security", "availability"] }, + "kind": { "enum": ["integrity", "security", "availability", "recovery"] }, "status": { "enum": ["open", "resolved"] }, "openedAt": { "type": "string", "format": "date-time" }, "resolvedAt": { "type": "string", "format": "date-time" }, "summary": { "type": "string", "minLength": 1, "maxLength": 2000 } } + }, + "actionRequired": { + "type": "object", + "additionalProperties": false, + "required": ["channel", "action", "authority", "reason", "requestedAt"], + "properties": { + "channel": { "type": "string", "minLength": 1, "maxLength": 50 }, + "action": { "type": "string", "minLength": 1, "maxLength": 200 }, + "authority": { "type": "string", "minLength": 1, "maxLength": 100 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "requestedAt": { "type": "string", "format": "date-time" } + } + }, + "recovery": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "startedAt", + "mode", + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "status": { "enum": ["started", "repository-completed"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "repositoryCompletedAt": { "type": "string", "format": "date-time" }, + "mode": { "const": "known-good" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 }, + "evidence": { "type": "object" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "startedAt", "mode", "requiredNextVersion"], + "properties": { + "status": { "enum": ["started", "repository-completed", "provider-actions-pending"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "repositoryCompletedAt": { "type": "string", "format": "date-time" }, + "mode": { "const": "first-release-withdrawal" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "externalActionsPending": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["npm", "pypi", "homebrew-core", "winget", "chocolatey"] + } + }, + "evidence": { "type": "object" } + } + } + ] } } } diff --git a/cli/scripts/eslint-baseline.json b/cli/scripts/eslint-baseline.json index 1e0b862..5aab134 100644 --- a/cli/scripts/eslint-baseline.json +++ b/cli/scripts/eslint-baseline.json @@ -151,10 +151,6 @@ "errors": 11, "warnings": 0 }, - "src/core/cost-guard.ts": { - "errors": 1, - "warnings": 1 - }, "src/core/dependency-scanner.ts": { "errors": 6, "warnings": 0 diff --git a/cli/scripts/eslint-ratchet-lib.js b/cli/scripts/eslint-ratchet-lib.js new file mode 100644 index 0000000..600776c --- /dev/null +++ b/cli/scripts/eslint-ratchet-lib.js @@ -0,0 +1,30 @@ +'use strict'; + +function evaluateBaseline(current, baseline, fileExists) { + const regressions = []; + const improvements = []; + + for (const [file, counts] of Object.entries(current)) { + const allowed = baseline[file] || { errors: 0, warnings: 0 }; + if (counts.errors > allowed.errors || counts.warnings > allowed.warnings) { + regressions.push( + `${file}: ${counts.errors} errors/${counts.warnings} warnings ` + + `(baseline ${allowed.errors}/${allowed.warnings})` + ); + } else if (counts.errors < allowed.errors || counts.warnings < allowed.warnings) { + improvements.push(`${file}: ${counts.errors} errors/${counts.warnings} warnings`); + } + } + + for (const [file, allowed] of Object.entries(baseline)) { + if (!fileExists(file)) { + regressions.push(`${file}: baseline source file is missing; regenerate the reviewed baseline`); + } else if (!current[file] && (allowed.errors > 0 || allowed.warnings > 0)) { + improvements.push(`${file}: clean`); + } + } + + return {regressions, improvements}; +} + +module.exports = {evaluateBaseline}; diff --git a/cli/scripts/package-manager-smoke.js b/cli/scripts/package-manager-smoke.js index f481f9d..cb88683 100644 --- a/cli/scripts/package-manager-smoke.js +++ b/cli/scripts/package-manager-smoke.js @@ -20,7 +20,7 @@ function installArgs(manager, tarball) { if (manager === 'npm') return ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball]; if (manager === 'pnpm') return ['add', '--ignore-scripts', tarball]; if (manager === 'yarn') return ['add', tarball]; - if (manager === 'bun') return ['add', '--ignore-scripts', tarball]; + if (manager === 'bun') return ['add', '--ignore-scripts', '--yarn', tarball]; throw new Error(`Unsupported package manager: ${manager}`); } @@ -105,14 +105,12 @@ function main(argv = process.argv.slice(2)) { assert(help.stdout.includes('scan'), `${manager} CLI help is missing scan`); const output = path.join(project, 'scan.json'); - runCli(manager, [ - '--no-telemetry', 'scan', '--offline', '--no-cve', '--skip-tests', '--skip-ai', - '--format', 'json', '--output', output, - ], project, env); + runCli(manager, scanArgsFor(manager, output), project, env); const scan = JSON.parse(fs.readFileSync(output, 'utf8')); assert(scan.schemaVersion === 'guardscan.scan.v1', `${manager} emitted the wrong scan schema`); assert(scan.run?.executionMode === 'static-analysis', `${manager} did not preserve safe execution mode`); assert(scan.run?.offline === true, `${manager} did not preserve offline mode`); + assert(scan.run?.allowPartial === false, `${manager} weakened the partial-inventory policy`); const telemetry = runCli(manager, ['--no-telemetry', 'telemetry', 'status'], project, env); assert(telemetry.stdout.includes('Consent: disabled'), `${manager} did not preserve telemetry opt-out`); @@ -126,6 +124,13 @@ function runCli(manager, args, cwd, env) { return run(commandFor(manager), execArgs(manager, args), cwd, env); } +function scanArgsFor(_manager, output) { + return [ + '--no-telemetry', 'scan', '--offline', '--no-cve', '--skip-tests', '--skip-ai', + '--format', 'json', '--output', output, + ]; +} + function run(command, args, cwd, env) { const result = spawnSync(command, args, { cwd, @@ -156,4 +161,12 @@ if (require.main === module) { } } -module.exports = {commandFor, execArgs, installArgs, main, parseManager, parseTarball}; +module.exports = { + commandFor, + execArgs, + installArgs, + main, + parseManager, + parseTarball, + scanArgsFor, +}; diff --git a/cli/scripts/package-smoke.js b/cli/scripts/package-smoke.js index b3eb40e..f690088 100644 --- a/cli/scripts/package-smoke.js +++ b/cli/scripts/package-smoke.js @@ -7,6 +7,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const Ajv = require('ajv'); const addFormats = require('ajv-formats'); +const {assertCompiledRuntimeFilesClean} = require('./release/runtime-artifact-policy'); const packageRoot = path.resolve(__dirname, '..'); const expectedVersion = require(path.join(packageRoot, 'package.json')).version; @@ -27,8 +28,7 @@ try { name: 'guardscan-package-smoke', version: '1.0.0', private: true, })); fs.writeFileSync(path.join(project, 'index.js'), 'module.exports = () => 42;\n'); - run( - npmCommand(), + runNpm( [ 'install', '--global', @@ -47,6 +47,7 @@ try { ? path.join(globalPrefix, 'node_modules', 'guardscan') : path.join(globalPrefix, 'lib', 'node_modules', 'guardscan'); const files = new Set(listPackageFiles(installedPackage)); + assertCompiledRuntimeFilesClean(installedPackage, files, 'npm packed runtime'); for (const required of [ 'package.json', 'dist/index.js', @@ -77,6 +78,14 @@ try { ? path.join(globalPrefix, 'guardscan.cmd') : path.join(globalPrefix, 'bin', 'guardscan'); assert(fs.existsSync(cli), `global install did not create the GuardScan shim at ${cli}`); + const cliCommand = process.platform === 'win32' ? process.execPath : cli; + const cliPrefix = process.platform === 'win32' + ? [path.join(installedPackage, 'dist', 'index.js')] + : []; + if (process.platform === 'win32') { + const shim = fs.readFileSync(cli, 'utf8').replace(/\\/g, '/'); + assert(shim.includes('node_modules/guardscan/dist/index.js'), 'Windows shim has the wrong target'); + } const env = { ...npmEnv, GUARDSCAN_HOME: home, @@ -84,24 +93,24 @@ try { USERPROFILE: home, GUARDSCAN_NO_TELEMETRY: 'true', }; - const version = run(cli, ['--version'], project, env); + const version = run(cliCommand, [...cliPrefix, '--version'], project, env); const versionLines = version.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean); const reportedVersion = versionLines.at(-1); assert( reportedVersion === expectedVersion, `installed CLI reported ${JSON.stringify(reportedVersion)}; expected ${expectedVersion}` ); - const help = run(cli, ['--help'], project, env); + const help = run(cliCommand, [...cliPrefix, '--help'], project, env); for (const command of ['scan', 'security', 'vuln|cve', 'telemetry', 'cache']) { assert(help.stdout.includes(command), `installed CLI help is missing ${command}`); } - run(cli, ['--no-telemetry', 'init'], project, env); + run(cliCommand, [...cliPrefix, '--no-telemetry', 'init'], project, env); const config = parseJsonLikeYaml(path.join(home, '.guardscan', 'config.yml')); assert(config.telemetryEnabled === false, 'installed CLI did not default telemetry off'); assert(config.offlineMode === true, 'installed CLI did not default offline mode on'); const output = path.join(project, 'scan.json'); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', 'scan', '--offline', @@ -126,10 +135,10 @@ try { const spdxOutput = path.join(project, 'sbom-spdx.json'); const cycloneDxOutput = path.join(project, 'sbom-cyclonedx.json'); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', '--offline', 'sbom', '--format', 'spdx', '--output', spdxOutput, ], project, env); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', '--offline', 'sbom', '--format', 'cyclonedx', '--output', cycloneDxOutput, ], project, env); validateSboms( @@ -137,7 +146,12 @@ try { JSON.parse(fs.readFileSync(cycloneDxOutput, 'utf8')) ); - const telemetry = run(cli, ['--no-telemetry', 'telemetry', 'status'], project, env); + const telemetry = run( + cliCommand, + [...cliPrefix, '--no-telemetry', 'telemetry', 'status'], + project, + env + ); assert(telemetry.stdout.includes('Consent: disabled'), 'installed telemetry consent default is wrong'); assert(telemetry.stdout.includes('Pending events: 0'), 'installed telemetry outbox is not empty'); process.stdout.write(`Package smoke passed: ${path.basename(tarball)}\n`); @@ -145,8 +159,13 @@ try { fs.rmSync(tempRoot, { recursive: true, force: true }); } -function npmCommand() { - return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +function runNpm(args, cwd, env) { + const npmCli = process.env.npm_execpath; + if (npmCli && fs.existsSync(npmCli)) { + return run(process.execPath, [npmCli, ...args], cwd, env); + } + assert(process.platform !== 'win32', 'npm_execpath is required for shell-free Windows smoke tests'); + return run('npm', args, cwd, env); } function resolveTarball(argv, destination, env) { @@ -163,8 +182,7 @@ function resolveTarball(argv, destination, env) { assert(stat.isFile() && resolved.endsWith('.tgz'), `invalid package tarball: ${resolved}`); return resolved; } - const packed = run( - npmCommand(), + const packed = runNpm( ['pack', '--json', '--pack-destination', destination], packageRoot, env diff --git a/cli/scripts/release/candidate.js b/cli/scripts/release/candidate.js index 7bc1a76..653592c 100644 --- a/cli/scripts/release/candidate.js +++ b/cli/scripts/release/candidate.js @@ -22,7 +22,15 @@ function writeJson(file, document) { fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); } -function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead, timestamp) { +function createReleaseCandidate( + source, + candidateVersion, + sourcePr, + sourcePrHead, + sourcePrBase, + sourcePrTree, + timestamp +) { assertCandidateVersion(source.version, candidateVersion); if (!Number.isSafeInteger(Number(sourcePr)) || Number(sourcePr) < 1) { throw new Error('release candidate source PR is invalid'); @@ -30,6 +38,12 @@ function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead if (!/^[a-f0-9]{40}$/.test(sourcePrHead || '')) { throw new Error('release candidate source PR head is invalid'); } + if (!/^[a-f0-9]{40}$/.test(sourcePrBase || '')) { + throw new Error('release candidate source PR base is invalid'); + } + if (!/^[a-f0-9]{40}$/.test(sourcePrTree || '')) { + throw new Error('release candidate source PR tree is invalid'); + } const date = new Date(timestamp); if (!Number.isFinite(date.getTime()) || date.toISOString() !== timestamp) { throw new Error('release candidate timestamp must be canonical'); @@ -62,6 +76,8 @@ function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead candidateVersion, sourcePr: Number(sourcePr), sourcePrHead, + sourcePrBase, + sourcePrTree, createdAt: timestamp, }; writeJson(path.join(source.repositoryRoot, '.release-candidate.json'), metadata); diff --git a/cli/scripts/release/events.js b/cli/scripts/release/events.js index a3d4b4e..46772b3 100644 --- a/cli/scripts/release/events.js +++ b/cli/scripts/release/events.js @@ -4,9 +4,16 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const {CHANNELS, readBounded} = require('./lib'); +const {createPublicationEvidence} = require('./publication-evidence'); const EVENT_SCHEMA = 'guardscan.release-event.v1'; const MAX_EVENT_BYTES = 64 * 1024; +const CATALOG_IDENTITY_PATTERN = /^github:ntanwir10\/homebrew-tap@[a-f0-9]{40}#(?:Formula\/guardscan\.rb|bucket\/guardscan\.json)$/; +const PRIMARY_PUBLICATION_CHANNELS = new Set(['github', 'npm', 'pypi']); +const MODERATED_CHANNELS = new Set(['winget', 'chocolatey']); +const EXTERNAL_WITHDRAWAL_CHANNELS = new Set([ + 'npm', 'pypi', 'homebrew-core', 'winget', 'chocolatey', +]); const EVENT_TYPES = Object.freeze([ 'train_started', 'artifact_built', @@ -17,9 +24,14 @@ const EVENT_TYPES = Object.freeze([ 'channel_accepted', 'channel_verified', 'channel_failed', + 'channel_rejected', + 'channel_corrected', + 'channel_resubmitted', 'canary_recorded', 'promotion_decided', 'rollback_started', + 'rollback_repository_completed', + 'action_required', 'withdrawn', 'superseded', 'incident_opened', @@ -31,6 +43,9 @@ const CHANNEL_EVENT_STATUS = Object.freeze({ channel_accepted: 'accepted', channel_verified: 'verified', channel_failed: 'failed', + channel_rejected: 'rejected', + channel_corrected: 'corrected', + channel_resubmitted: 'resubmitted', withdrawn: 'withdrawn', superseded: 'superseded', }); @@ -73,6 +88,371 @@ function assertIdentity(document, expected, label) { } } +function validateCatalogEvidence(event) { + const evidence = event.payload?.catalog; + if (evidence === undefined) return; + if (event.type === 'rollback_repository_completed') return; + if (!['homebrew', 'scoop'].includes(event.channel)) { + throw new Error('catalog evidence is valid only for homebrew or scoop events'); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('release event catalog evidence must be an object'); + } + const keys = Object.keys(evidence).sort(); + const expectedKeys = [ + 'commit', + 'fileDigest', + 'lockDigest', + 'manifestDigest', + 'path', + 'pullRequest', + 'repository', + ].sort(); + if (keys.join('\n') !== expectedKeys.join('\n')) { + throw new Error('release event catalog evidence has unexpected or missing fields'); + } + if (evidence.repository !== 'ntanwir10/homebrew-tap') { + throw new Error('release event catalog repository is invalid'); + } + if (!/^[a-f0-9]{40}$/.test(evidence.commit || '')) { + throw new Error('release event catalog commit is invalid'); + } + if (!Number.isSafeInteger(evidence.pullRequest) || evidence.pullRequest < 1) { + throw new Error('release event catalog pull request is invalid'); + } + for (const field of ['lockDigest', 'manifestDigest', 'fileDigest']) { + if (!/^[a-f0-9]{64}$/.test(evidence[field] || '')) { + throw new Error(`release event catalog ${field} is invalid`); + } + } + const expectedPath = event.channel === 'homebrew' + ? 'Formula/guardscan.rb' + : 'bucket/guardscan.json'; + if (evidence.path !== expectedPath) { + throw new Error(`release event catalog path does not match ${event.channel}`); + } + if (!CATALOG_IDENTITY_PATTERN.test(event.payload.remoteIdentity || '') + || event.payload.remoteIdentity + !== `github:${evidence.repository}@${evidence.commit}#${evidence.path}`) { + throw new Error('release event catalog remote identity is invalid'); + } + if (event.payload.remoteDigest !== evidence.fileDigest) { + throw new Error('release event catalog remote digest does not match file evidence'); + } +} + +function validateActionRequired(event) { + if (event.type !== 'action_required') return; + if (!event.channel) throw new Error('action_required requires a release channel'); + const payload = event.payload; + const keys = Object.keys(payload).sort(); + const expectedKeys = ['action', 'authority', 'reason']; + if (keys.join('\n') !== expectedKeys.join('\n') + || expectedKeys.some(key => typeof payload[key] !== 'string' || payload[key].length < 1)) { + throw new Error('action_required payload must contain only action, authority, and reason'); + } + if (payload.action.length > 200 || payload.authority.length > 100 || payload.reason.length > 2000) { + throw new Error('action_required payload exceeds its bounded field length'); + } +} + +function validatePublicationEvidence(event) { + const evidence = event.payload?.publication; + const required = event.type === 'channel_published' + && PRIMARY_PUBLICATION_CHANNELS.has(event.channel); + if (evidence === undefined) { + if (required) { + throw new Error('primary channel publication requires provider-bound file evidence'); + } + return; + } + if (!required) { + throw new Error('provider publication evidence is valid only for primary published channels'); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('provider publication evidence must be an object'); + } + const normalized = createPublicationEvidence(evidence); + if (canonicalJson(normalized) !== canonicalJson(evidence)) { + throw new Error('provider publication evidence is not canonical'); + } + if (evidence.channel !== event.channel + || evidence.version !== event.version + || evidence.tag !== event.tag + || evidence.remoteIdentity !== event.payload.remoteIdentity + || evidence.aggregateSha256 !== event.payload.remoteDigest) { + throw new Error('provider publication evidence does not match its release event'); + } +} + +function validateTrainStarted(event) { + if (event.type !== 'train_started') return; + const channels = event.payload.channels; + const supported = new Set(CHANNELS.map(channel => channel.id)); + if (!Array.isArray(channels) || channels.length < 1 + || new Set(channels).size !== channels.length + || channels.some(channel => typeof channel !== 'string' || !supported.has(channel))) { + throw new Error('release train channels must be a non-empty unique supported channel list'); + } + const sourceFields = ['profile', 'releasePr', 'sourcePrHead', 'sourcePrBase', 'sourcePrTree']; + if (sourceFields.some(field => event.payload[field] !== undefined)) { + const keys = Object.keys(event.payload).sort(); + const expected = ['channels', ...sourceFields].sort(); + if (keys.join('\n') !== expected.join('\n') + || event.payload.profile !== 'full' + || !Number.isSafeInteger(event.payload.releasePr) + || event.payload.releasePr < 1 + || ['sourcePrHead', 'sourcePrBase', 'sourcePrTree'].some(field => ( + !/^[a-f0-9]{40}$/.test(event.payload[field] || '') + ))) { + throw new Error('release train source provenance is incomplete or invalid'); + } + } +} + +function validateModerationEvent(event) { + const moderationTypes = new Set([ + 'channel_submitted', 'channel_accepted', 'channel_rejected', + 'channel_corrected', 'channel_resubmitted', + ]); + if (['channel_rejected', 'channel_corrected', 'channel_resubmitted'].includes(event.type) + && !MODERATED_CHANNELS.has(event.channel)) { + throw new Error(`${event.type} is valid only for moderated release channels`); + } + if (!moderationTypes.has(event.type) || !MODERATED_CHANNELS.has(event.channel)) return; + const evidence = event.payload.submission; + const requiresReason = event.type === 'channel_rejected'; + const payloadKeys = Object.keys(event.payload).sort(); + const expectedPayloadKeys = [ + 'artifactIds', 'remoteDigest', 'remoteIdentity', 'submission', + ...(requiresReason ? ['reason'] : []), + ].sort(); + if (payloadKeys.join('\n') !== expectedPayloadKeys.join('\n') + || !Array.isArray(event.payload.artifactIds) + || event.payload.artifactIds.length < 1 + || new Set(event.payload.artifactIds).size !== event.payload.artifactIds.length + || event.payload.artifactIds.some(id => ( + typeof id !== 'string' || id.length < 1 || id.length > 200 + )) + || typeof event.payload.remoteIdentity !== 'string' + || event.payload.remoteIdentity.length < 1 + || !/^[a-f0-9]{64}$/.test(event.payload.remoteDigest || '') + || !evidence + || typeof evidence !== 'object' + || Array.isArray(evidence)) { + throw new Error(`${event.type} requires canonical moderated provider evidence`); + } + const expectedState = { + channel_submitted: new Set(['submitted', 'pending', 'pending-ledger']), + channel_accepted: new Set(['accepted', 'public-exact']), + channel_rejected: new Set(['rejected']), + channel_corrected: new Set(['corrected']), + channel_resubmitted: new Set(['resubmitted']), + }[event.type]; + const commonKeys = [ + 'channel', 'packageIdentity', 'provider', 'remoteDigest', 'remoteIdentity', + 'schemaVersion', 'state', 'tag', 'version', + ]; + const expectedEvidenceKeys = [ + ...commonKeys, + ...(event.channel === 'winget' ? ['files'] : ['packageFilename']), + ...(requiresReason ? ['reason'] : []), + ].sort(); + if (Object.keys(evidence).sort().join('\n') !== expectedEvidenceKeys.join('\n') + || evidence.schemaVersion !== 'guardscan.moderated-submission.v1' + || evidence.channel !== event.channel + || evidence.version !== event.version + || evidence.tag !== event.tag + || !expectedState.has(evidence.state) + || evidence.remoteIdentity !== event.payload.remoteIdentity + || evidence.remoteDigest !== event.payload.remoteDigest + || typeof evidence.packageIdentity !== 'string' + || evidence.packageIdentity.length < 1 + || !evidence.provider + || typeof evidence.provider !== 'object' + || Array.isArray(evidence.provider) + || typeof evidence.provider.pendingStateQuery !== 'string' + || evidence.provider.pendingStateQuery.length < 1 + || evidence.provider.pendingStateQuery.length > 1000 + || (requiresReason && ( + typeof evidence.reason !== 'string' + || evidence.reason.length < 1 + || evidence.reason.length > 2000 + || event.payload.reason !== evidence.reason + ))) { + throw new Error(`${event.type} moderated provider evidence is not release-bound`); + } + if (event.channel === 'winget') { + const filenames = [ + 'NaumanTanwir.GuardScan.installer.yaml', + 'NaumanTanwir.GuardScan.locale.en-US.yaml', + 'NaumanTanwir.GuardScan.yaml', + ]; + const providerKeys = [ + 'commit', 'path', 'pendingStateQuery', 'publicBytesVerified', + 'pullRequest', 'repository', + ].sort(); + const fileNames = Object.keys(evidence.files || {}).sort(); + const digestInput = fileNames.map(name => `${name}\0${evidence.files[name]}\n`).join(''); + const publicExact = evidence.state === 'public-exact'; + const expectedIdentity = publicExact + ? `github:microsoft/winget-pkgs@${evidence.provider.commit}#${evidence.provider.path}` + : `github:microsoft/winget-pkgs/pull/${evidence.provider.pullRequest}@${evidence.provider.commit}#${evidence.provider.path}`; + if (Object.keys(evidence.provider).sort().join('\n') !== providerKeys.join('\n') + || fileNames.join('\n') !== filenames.join('\n') + || fileNames.some(name => !/^[a-f0-9]{64}$/.test(evidence.files[name] || '')) + || sha256(digestInput) !== evidence.remoteDigest + || evidence.packageIdentity !== `NaumanTanwir.GuardScan@${event.version}` + || evidence.provider.repository !== 'microsoft/winget-pkgs' + || evidence.provider.path !== `manifests/n/NaumanTanwir/GuardScan/${event.version}` + || !/^[a-f0-9]{40}$/.test(evidence.provider.commit || '') + || evidence.provider.publicBytesVerified !== publicExact + || (publicExact + ? evidence.provider.pullRequest !== null + : (!Number.isSafeInteger(evidence.provider.pullRequest) + || evidence.provider.pullRequest < 1)) + || evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${event.type} WinGet evidence is not artifact-bound`); + } + return; + } + const providerKeys = ['pendingStateQuery', 'publicBytesVerified', 'url'].sort(); + const publicExact = evidence.state === 'public-exact'; + const expectedUrl = `https://community.chocolatey.org/api/v2/package/guardscan/${event.version}`; + const expectedIdentity = publicExact ? expectedUrl : `chocolatey:guardscan@${event.version}`; + if (Object.keys(evidence.provider).sort().join('\n') !== providerKeys.join('\n') + || evidence.packageIdentity !== `guardscan@${event.version}` + || evidence.packageFilename !== `guardscan.${event.version}.nupkg` + || evidence.provider.url !== expectedUrl + || evidence.provider.publicBytesVerified !== publicExact + || evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${event.type} Chocolatey evidence is not artifact-bound`); + } +} + +function validateIncidentEvent(event) { + if (event.type === 'incident_opened') { + const keys = Object.keys(event.payload).sort(); + const expected = ['incidentId', 'kind', 'summary']; + if (keys.join('\n') !== expected.join('\n') + || typeof event.payload.incidentId !== 'string' + || event.payload.incidentId.length < 1 + || event.payload.incidentId.length > 200 + || !['integrity', 'security', 'availability', 'recovery'].includes(event.payload.kind) + || typeof event.payload.summary !== 'string' + || event.payload.summary.length < 1 + || event.payload.summary.length > 2000) { + throw new Error('incident_opened payload is invalid'); + } + } + if (event.type === 'incident_resolved') { + const keys = Object.keys(event.payload); + if (keys.length !== 1 || keys[0] !== 'incidentId' + || typeof event.payload.incidentId !== 'string' + || event.payload.incidentId.length < 1 + || event.payload.incidentId.length > 200) { + throw new Error('incident_resolved payload is invalid'); + } + } +} + +function validateRollbackStarted(event) { + if (event.type !== 'rollback_started') return; + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + const keys = Object.keys(event.payload).sort(); + if (event.payload.mode === 'known-good' || event.payload.mode === undefined) { + const expected = [ + 'forwardFixBranch', + 'forwardFixVersion', + 'knownGoodCommit', + 'knownGoodVersion', + ...(event.payload.mode === undefined ? [] : ['mode']), + ].sort(); + if (keys.join('\n') !== expected.join('\n') + || !stable.test(event.payload.knownGoodVersion || '') + || !stable.test(event.payload.forwardFixVersion || '') + || !/^[a-f0-9]{40}$/.test(event.payload.knownGoodCommit || '') + || typeof event.payload.forwardFixBranch !== 'string' + || event.payload.forwardFixBranch.length > 200) { + throw new Error('known-good rollback_started payload is invalid'); + } + return; + } + if (event.payload.mode === 'first-release-withdrawal') { + const expected = ['mode', 'requiredNextVersion']; + if (keys.join('\n') !== expected.join('\n') + || !stable.test(event.payload.requiredNextVersion || '')) { + throw new Error('first-release rollback_started payload is invalid'); + } + return; + } + throw new Error('rollback_started requires an explicit supported recovery mode'); +} + +function validateRollbackCompletion(event) { + if (event.type !== 'rollback_repository_completed') return; + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + const evidence = event.payload; + if (evidence.schemaVersion !== 'guardscan.rollback-repository-evidence.v1' + || !stable.test(evidence.defectiveVersion || '') + || evidence.defectiveVersion !== event.version) { + throw new Error('rollback repository evidence has invalid release identity'); + } + assertCanonicalTimestamp(evidence.completedAt, 'rollback repository completion'); + if (evidence.mode === 'known-good') { + const keys = Object.keys(evidence).sort(); + const expected = [ + 'catalog', + 'completedAt', + 'defectiveVersion', + 'forwardFix', + 'knownGoodVersion', + 'mode', + 'schemaVersion', + ].sort(); + if (keys.join('\n') !== expected.join('\n') + || !stable.test(evidence.knownGoodVersion || '') + || !evidence.forwardFix + || !evidence.catalog) { + throw new Error('known-good rollback repository evidence is invalid'); + } + return; + } + if (evidence.mode === 'first-release-withdrawal') { + const keys = Object.keys(evidence).sort(); + const expected = [ + 'catalog', + 'completedAt', + 'defectiveVersion', + 'externalActionsPending', + 'mode', + 'requiredNextVersion', + 'schemaVersion', + ].sort(); + const catalog = evidence.catalog; + const catalogKeys = Object.keys(catalog || {}).sort(); + const pending = evidence.externalActionsPending; + if (keys.join('\n') !== expected.join('\n') + || !stable.test(evidence.requiredNextVersion || '') + || !Array.isArray(pending) + || new Set(pending).size !== pending.length + || pending.join('\n') !== [...pending].sort().join('\n') + || pending.some(channel => !EXTERNAL_WITHDRAWAL_CHANNELS.has(channel)) + || !catalog + || catalogKeys.join('\n') !== ['branch', 'commit', 'pullRequest', 'state'].sort().join('\n') + || !['already-absent', 'removed'].includes(catalog.state) + || typeof catalog.branch !== 'string' + || !Number.isSafeInteger(catalog.pullRequest) + || (catalog.state === 'already-absent' && catalog.pullRequest !== 0) + || (catalog.state === 'removed' && catalog.pullRequest < 1) + || !/^[a-f0-9]{40}$/.test(catalog.commit || '')) { + throw new Error('first-release rollback repository evidence is invalid'); + } + return; + } + throw new Error('rollback repository evidence has an unsupported mode'); +} + function validateEvent(event, previous) { if (!event || typeof event !== 'object' || Array.isArray(event)) { throw new Error('release event must be an object'); @@ -98,6 +478,14 @@ function validateEvent(event, previous) { if (event.payload === null || typeof event.payload !== 'object' || Array.isArray(event.payload)) { throw new Error('release event payload must be an object'); } + validateCatalogEvidence(event); + validateActionRequired(event); + validatePublicationEvidence(event); + validateModerationEvent(event); + validateIncidentEvent(event); + validateRollbackStarted(event); + validateRollbackCompletion(event); + validateTrainStarted(event); assertCanonicalTimestamp(event.timestamp, 'release event timestamp'); if (!/^[a-f0-9]{64}$/.test(event.eventHash || '') || event.eventHash !== eventDigest(event)) { @@ -251,7 +639,9 @@ function materializeReleaseState(events) { channels: initialChannels(first), canaries: {}, incidents: {}, + actionRequired: [], promotion: undefined, + recovery: undefined, }; for (const event of events) { state.updatedAt = event.timestamp; @@ -264,6 +654,27 @@ function materializeReleaseState(events) { throw new Error(`${event.type} requires a channel present in the release train`); } const previous = state.channels[event.channel]; + const allowedTransitions = { + // Publication can be reconciled after a provider has already advanced. + // Permit the first provider-bound observation, but never reopen terminal states. + planned: [ + 'published', 'submitted', 'accepted', 'verified', 'failed', + 'rejected', 'corrected', 'resubmitted', 'withdrawn', + ], + published: ['verified', 'failed', 'withdrawn', 'superseded'], + submitted: ['published', 'accepted', 'rejected', 'failed', 'withdrawn', 'superseded'], + accepted: ['verified', 'failed', 'withdrawn', 'superseded'], + verified: ['failed', 'withdrawn', 'superseded'], + failed: ['accepted', 'verified', 'withdrawn', 'superseded'], + rejected: ['corrected', 'withdrawn', 'superseded'], + corrected: ['resubmitted', 'withdrawn', 'superseded'], + resubmitted: ['accepted', 'rejected', 'failed', 'withdrawn', 'superseded'], + withdrawn: [], + superseded: [], + }; + if (!allowedTransitions[previous.status]?.includes(status)) { + throw new Error(`${event.type} cannot move ${event.channel} from ${previous.status} to ${status}`); + } state.channels[event.channel] = { status, artifactIds: Array.isArray(event.payload.artifactIds) @@ -277,6 +688,15 @@ function materializeReleaseState(events) { ? {remoteDigest: event.payload.remoteDigest || previous.remoteDigest} : {}), ...(event.payload.error ? {error: event.payload.error} : {}), + ...(event.payload.catalog || previous.catalog + ? {catalog: event.payload.catalog || previous.catalog} + : {}), + ...(event.payload.publication || previous.publication + ? {publication: event.payload.publication || previous.publication} + : {}), + ...(event.payload.submission || previous.submission + ? {submission: event.payload.submission || previous.submission} + : {}), }; } if (event.type === 'canary_recorded') { @@ -306,9 +726,48 @@ function materializeReleaseState(events) { }; } if (event.type === 'promotion_decided') state.promotion = event.payload; + if (event.type === 'rollback_started') { + if (state.recovery) throw new Error('release recovery cannot be started more than once'); + state.recovery = { + status: 'started', + startedAt: event.timestamp, + ...(event.payload.mode ? event.payload : {mode: 'known-good', ...event.payload}), + }; + } + if (event.type === 'rollback_repository_completed') { + if (!state.recovery) throw new Error('rollback repository completion has no started recovery'); + const providerActionsPending = event.payload.mode === 'first-release-withdrawal' + && event.payload.externalActionsPending.length > 0; + state.recovery = { + ...state.recovery, + status: providerActionsPending ? 'provider-actions-pending' : 'repository-completed', + repositoryCompletedAt: event.timestamp, + evidence: event.payload, + }; + } + if (event.type === 'action_required') { + state.actionRequired.push({ + channel: event.channel, + action: event.payload.action, + authority: event.payload.authority, + reason: event.payload.reason, + requestedAt: event.timestamp, + }); + } + } + if (state.recovery?.mode === 'first-release-withdrawal' + && Array.isArray(state.recovery.evidence?.externalActionsPending)) { + const externalActionsPending = state.recovery.evidence.externalActionsPending.filter(channel => ( + !['withdrawn', 'superseded'].includes(state.channels[channel]?.status) + )); + state.recovery.externalActionsPending = externalActionsPending; + state.recovery.status = externalActionsPending.length > 0 + ? 'provider-actions-pending' + : 'repository-completed'; } if (!state.manifestSha256) delete state.manifestSha256; if (!state.promotion) delete state.promotion; + if (!state.recovery) delete state.recovery; return state; } diff --git a/cli/scripts/release/first-release-withdrawal.js b/cli/scripts/release/first-release-withdrawal.js new file mode 100644 index 0000000..c14bf50 --- /dev/null +++ b/cli/scripts/release/first-release-withdrawal.js @@ -0,0 +1,190 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); +const {materializeReleaseState, readEvents} = require('./events'); +const {releaseTrainChannels} = require('./lib'); +const {reconcileRelease} = require('./reconcile'); + +const CATALOG_PATHS = Object.freeze([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', +]); +const MAX_CONTROL_FILE_BYTES = 1024 * 1024; + +function pathExists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +function readText(file, label) { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file`); + } + if (stat.size > MAX_CONTROL_FILE_BYTES) { + throw new Error(`${label} exceeds ${MAX_CONTROL_FILE_BYTES} bytes`); + } + return fs.readFileSync(file, 'utf8'); +} + +function readJson(file, label) { + let value; + try { + value = JSON.parse(readText(file, label)); + } catch (error) { + throw new Error(`${label} is invalid: ${error.message}`); + } + return value; +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function assertFirstReleaseWithdrawal(ledgerRoot, defectiveVersion, ledgerCommit) { + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('first-release withdrawal requires a stable defective version'); + } + if (!/^[a-f0-9]{40}$/.test(ledgerCommit || '')) { + throw new Error('first-release withdrawal requires the protected ledger commit'); + } + const root = path.resolve(ledgerRoot); + const eventsRoot = path.join(root, 'events'); + const defectiveLedger = path.join(eventsRoot, `v${defectiveVersion}.jsonl`); + if (!pathExists(defectiveLedger)) { + throw new Error('defective release has no protected ledger'); + } + const defectiveState = materializeReleaseState(readEvents(defectiveLedger)); + const alreadyCompleted = defectiveState.recovery?.mode === 'first-release-withdrawal' + && ['repository-completed', 'provider-actions-pending'].includes( + defectiveState.recovery?.status + ); + const active = readJson(path.join(root, 'active-versions.json'), 'active release trains'); + const activeTarget = active.trains?.some(train => ( + train?.version === defectiveVersion && train?.channel === 'stable' + )); + if (active.schemaVersion !== 'guardscan.active-trains.v1' + || !Array.isArray(active.trains) + || (!alreadyCompleted && !activeTarget) + || (alreadyCompleted && activeTarget)) { + throw new Error('defective stable release is not an active protected train'); + } + const selectedChannels = Object.keys(defectiveState.channels).sort(); + const stableChannels = releaseTrainChannels('stable').sort(); + const stableWithCore = releaseTrainChannels('stable', {homebrewCoreEnabled: true}).sort(); + if (![stableChannels, stableWithCore].some(expected => ( + expected.length === selectedChannels.length + && expected.every((channel, index) => channel === selectedChannels[index]) + ))) { + throw new Error('defective release ledger is not an exact stable release train'); + } + if (alreadyCompleted) { + const evidence = defectiveState.recovery.evidence; + const pending = evidence?.externalActionsPending; + if (evidence?.mode !== 'first-release-withdrawal' + || !evidence.catalog + || !Array.isArray(pending) + || new Set(pending).size !== pending.length + || pending.some(channel => typeof channel !== 'string') + || pending.join('\n') !== [...pending].sort().join('\n')) { + throw new Error('completed first-release withdrawal evidence is incomplete'); + } + const requested = new Set((defectiveState.actionRequired || []).map(item => item.channel)); + for (const [channel, channelState] of Object.entries(defectiveState.channels)) { + if (pending.includes(channel)) { + if (!requested.has(channel)) { + throw new Error(`completed withdrawal has no external action request for ${channel}`); + } + continue; + } + if (!['withdrawn', 'superseded'].includes(channelState.status)) { + throw new Error(`completed withdrawal left ${channel} non-terminal`); + } + } + } + const completed = []; + for (const name of fs.readdirSync(eventsRoot).sort()) { + const match = /^v(.+)\.jsonl$/.exec(name); + if (!match || match[1] === defectiveVersion) continue; + const version = match[1]; + if (!semver.valid(version) + || semver.prerelease(version) !== null + || !semver.lt(version, defectiveVersion)) continue; + const events = readEvents(path.join(eventsRoot, name)); + if (events.some((_, index) => ( + reconcileRelease(materializeReleaseState(events.slice(0, index + 1))).complete + ))) { + completed.push(version); + } + } + if (completed.length > 0) { + throw new Error( + `verified stable predecessors exist (${completed.join(', ')}); a known-good rollback is required` + ); + } + return { + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion, + defectiveTag: defectiveState.tag, + defectiveCommit: defectiveState.commit, + ledgerCommit, + priorCompleteStableVersions: [], + alreadyCompleted, + }; +} + +function prepareFirstReleaseCatalogWithdrawal(catalogRoot, defectiveVersion, defectiveCommit) { + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('catalog withdrawal requires a stable defective version'); + } + if (!/^[a-f0-9]{40}$/.test(defectiveCommit || '')) { + throw new Error('catalog withdrawal requires the defective source commit'); + } + const root = path.resolve(catalogRoot); + const present = CATALOG_PATHS.filter(relative => pathExists(path.join(root, relative))); + if (present.length === 0) { + return {changed: false, state: 'already-absent', removed: []}; + } + if (present.length !== CATALOG_PATHS.length) { + throw new Error(`catalog is partial and cannot be withdrawn safely: ${present.join(', ')}`); + } + const lock = readJson(path.join(root, 'channel-lock.json'), 'channel catalog lock'); + const formula = readText(path.join(root, 'Formula/guardscan.rb'), 'Homebrew formula'); + const scoopText = readText(path.join(root, 'bucket/guardscan.json'), 'Scoop manifest'); + const scoop = JSON.parse(scoopText); + const lockFiles = Object.keys(lock.files || {}).sort(); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1' + || lock.source?.repository !== 'ntanwir10/GuardScan' + || lock.source?.version !== defectiveVersion + || lock.source?.tag !== `v${defectiveVersion}` + || lock.source?.commit !== defectiveCommit + || !/^https:\/\//.test(lock.source?.manifestUrl || '') + || !/^[a-f0-9]{64}$/.test(lock.source?.manifestSha256 || '') + || lock.generator?.repository !== 'ntanwir10/GuardScan' + || lock.generator?.commit !== lock.source.commit + || lockFiles.join('\n') !== CATALOG_PATHS.slice(0, 2).sort().join('\n') + || lock.files['Formula/guardscan.rb']?.sha256 !== sha256(formula) + || lock.files['bucket/guardscan.json']?.sha256 !== sha256(scoopText) + || scoop.version !== defectiveVersion + || !formula.includes(` version "${defectiveVersion}"`)) { + throw new Error('catalog does not exactly identify the defective first release'); + } + for (const relative of CATALOG_PATHS) fs.unlinkSync(path.join(root, relative)); + return {changed: true, state: 'removed', removed: [...CATALOG_PATHS]}; +} + +module.exports = { + CATALOG_PATHS, + assertFirstReleaseWithdrawal, + prepareFirstReleaseCatalogWithdrawal, +}; diff --git a/cli/scripts/release/index.js b/cli/scripts/release/index.js index fb7d032..32e4d78 100644 --- a/cli/scripts/release/index.js +++ b/cli/scripts/release/index.js @@ -9,11 +9,18 @@ const { assertStateReferencesManifest, createPlan, prepareRelease, + readBounded, summarizeState, validateDocument, validateSource, } = require('./lib'); -const {renderAdapters, writeRenderedOutput} = require('./renderers'); +const { + classifyChannelCatalog, + renderAdapters, + renderChannelCatalog, + writeChannelCatalogOutput, + writeRenderedOutput, +} = require('./renderers'); const { buildNpmArtifact, queryNpmRemote, @@ -33,7 +40,7 @@ const { } = require('./events'); const {createPromotionDecision} = require('./promotion'); const {classifyRemoteArtifact} = require('./remote'); -const {planRollback, reconcileRelease} = require('./reconcile'); +const {planFirstReleaseWithdrawal, planRollback, reconcileRelease} = require('./reconcile'); const { verifyManifestFiles, writeReleaseManifest, @@ -42,8 +49,15 @@ const {buildWheel, finalizeWheelArtifact} = require('./python-wheel'); const {writeArtifactSboms} = require('./artifact-sbom'); const {buildStandaloneArtifact} = require('./standalone-artifact'); const {createReleaseCandidate} = require('./candidate'); - -const BOOLEAN_OPTIONS = new Set(['accepted', 'check', 'native', 'requireNative']); +const {createPublicationEvidence} = require('./publication-evidence'); + +const BOOLEAN_OPTIONS = new Set([ + 'accepted', + 'check', + 'firstReleaseWithdrawal', + 'native', + 'requireNative', +]); const COMMANDS = new Set([ 'validate', 'candidate', @@ -58,6 +72,8 @@ const COMMANDS = new Set([ 'prepare', 'dry-run', 'render', + 'catalog', + 'catalog-status', 'validate-adapters', 'standalone-prototype', 'package', @@ -98,8 +114,10 @@ function printHelp() { ' verify Verify local/remote identities and record acceptance or public verification', ' reconcile Materialize the ledger and plan only incomplete remote operations', ' promote Generate the machine 24-hour promotion decision', - ' rollback Append rollback_started and produce a forward-fix recovery plan', + ' rollback Append rollback_started and produce a verified recovery plan', ' status Materialize and summarize release state', + ' catalog Render or check the authoritative shared channel catalog', + ' catalog-status Classify shared catalog drift against the exact release source', '', 'Foundation and compatibility commands:', ' validate, plan, prepare, dry-run, render, validate-adapters', @@ -122,6 +140,15 @@ function printHelp() { ' --artifact-id ID Manifest artifact identity', ' --remote-identity ID Immutable public identity', ' --remote-digest SHA256 Observed public SHA-256', + ' --publication-evidence P Provider-bound file evidence for GitHub, npm, or PyPI', + ' --manifest-url URL Immutable GitHub release-manifest.json URL', + ' --manifest-sha256 SHA Exact release-manifest.json SHA-256', + ' --generator-repository R Repository containing the catalog renderer', + ' --generator-commit SHA Exact renderer source commit', + ' --known-good VERSION Verified stable rollback source version', + ' --known-good-commit SHA Exact verified rollback source commit', + ' --first-release-withdrawal Withdraw the first stable train without inventing a baseline', + ' --first-release-authority P Protected-ledger authority document for first withdrawal', '', ].join('\n')); } @@ -169,6 +196,36 @@ function platformFromOptions(options) { }; } +function catalogEvidence(options, manifestFile) { + if (!['homebrew', 'scoop'].includes(options.channel)) return undefined; + requireOptions('catalog publication', options, [ + 'catalogRepository', + 'catalogCommit', + 'catalogPullRequest', + 'catalogLockDigest', + 'catalogManifestDigest', + 'catalogPath', + 'catalogFileDigest', + ]); + const pullRequest = Number(options.catalogPullRequest); + if (!Number.isSafeInteger(pullRequest) || pullRequest < 1) { + throw new Error('catalog publication requires a positive --catalog-pull-request'); + } + const evidence = { + repository: options.catalogRepository, + commit: options.catalogCommit, + pullRequest, + lockDigest: options.catalogLockDigest, + manifestDigest: options.catalogManifestDigest, + path: options.catalogPath, + fileDigest: options.catalogFileDigest, + }; + if (evidence.manifestDigest !== manifestDigest(manifestFile)) { + throw new Error('catalog manifest digest does not match the exact release manifest'); + } + return evidence; +} + function hashExecutable(file) { const buffer = fs.readFileSync(path.resolve(file)); if (buffer.length <= 0) throw new Error('standalone executable is empty'); @@ -179,6 +236,27 @@ function hashExecutable(file) { }; } +function renderCatalogFromOptions(manifest, options) { + requireOptions('catalog', options, [ + 'manifest', + 'manifestUrl', + 'manifestSha256', + 'generatorRepository', + 'generatorCommit', + ]); + const manifestBytes = fs.readFileSync(path.resolve(options.manifest)); + const actualManifestSha256 = crypto.createHash('sha256').update(manifestBytes).digest('hex'); + if (actualManifestSha256 !== options.manifestSha256) { + throw new Error('catalog manifestSha256 does not match the exact manifest file'); + } + return renderChannelCatalog(manifest, { + manifestUrl: options.manifestUrl, + manifestSha256: options.manifestSha256, + generatorRepository: options.generatorRepository, + generatorCommit: options.generatorCommit, + }); +} + async function handleBuild(source, options) { requireOptions('build', options, ['kind']); if (options.kind !== 'python-wheel-artifact') requireOptions('build', options, ['outputDir']); @@ -257,8 +335,33 @@ function handlePublication(command, source, manifest, options) { ]); const artifact = manifestArtifact(manifest, options.artifactId); if (options.artifactRoot) verifyManifestFiles(manifest, options.artifactRoot); + const catalog = catalogEvidence(options, options.manifest); + const primaryPublication = command === 'publish' + && ['github', 'npm', 'pypi'].includes(options.channel); + let publication; + if (primaryPublication) { + if (!options.publicationEvidence) { + throw new Error(`${options.channel} publication requires --publication-evidence`); + } + const publicationFile = path.resolve(options.publicationEvidence); + let input; + try { + input = JSON.parse(readBounded(publicationFile, 'provider publication evidence')); + } catch (error) { + throw new Error(`provider publication evidence is invalid: ${error.message}`); + } + publication = createPublicationEvidence(input); + if (publication.channel !== options.channel + || publication.version !== source.version + || publication.tag !== source.tag + || publication.remoteIdentity !== options.remoteIdentity + || publication.aggregateSha256 !== options.remoteDigest + || publication.files[artifact.filename] !== artifact.sha256) { + throw new Error('provider publication evidence does not match the exact release artifact'); + } + } const classification = classifyRemoteArtifact( - {sha256: artifact.sha256}, + {sha256: publication?.aggregateSha256 || catalog?.fileDigest || artifact.sha256}, {identity: options.remoteIdentity, sha256: options.remoteDigest} ); if (classification.integrityIncident) { @@ -280,6 +383,8 @@ function handlePublication(command, source, manifest, options) { artifactIds: [artifact.id], remoteIdentity: options.remoteIdentity, remoteDigest: options.remoteDigest, + ...(catalog ? {catalog} : {}), + ...(publication ? {publication} : {}), }, options.channel)); return {changed: result.changed, classification, event: result.event}; } @@ -363,6 +468,8 @@ async function main(argv) { 'candidateVersion', 'sourcePr', 'sourcePrHead', + 'sourcePrBase', + 'sourcePrTree', 'timestamp', ]); process.stdout.write(`${JSON.stringify(createReleaseCandidate( @@ -370,6 +477,8 @@ async function main(argv) { options.candidateVersion, options.sourcePr, options.sourcePrHead, + options.sourcePrBase, + options.sourcePrTree, options.timestamp ), null, 2)}\n`); return; @@ -428,13 +537,95 @@ async function main(argv) { if (command === 'rollback') { requireOptions('rollback', options, ['ledger', 'timestamp', 'idempotencyKey']); + if (options.firstReleaseWithdrawal && (options.knownGood || options.knownGoodCommit)) { + throw new Error('first-release withdrawal cannot accept a known-good release'); + } + if (options.firstReleaseWithdrawal && !options.firstReleaseAuthority) { + throw new Error('first-release withdrawal requires --first-release-authority'); + } + if (!options.firstReleaseWithdrawal) { + requireOptions('rollback', options, ['knownGood', 'knownGoodCommit']); + } const materialized = materializeReleaseState(readEvents(options.ledger)); - const plan = planRollback(materialized, options.knownGood); - const result = appendEvent(options.ledger, eventIdentity(source, options, 'rollback_started', { - knownGoodVersion: options.knownGood || null, - forwardFixVersion: plan.forwardFixVersion, - })); - process.stdout.write(`${JSON.stringify({changed: result.changed, plan}, null, 2)}\n`); + let firstReleaseAuthority; + if (options.firstReleaseWithdrawal) { + try { + firstReleaseAuthority = JSON.parse(readBounded( + path.resolve(options.firstReleaseAuthority), + 'first-release withdrawal authority' + )); + } catch (error) { + throw new Error(`first-release withdrawal authority is invalid: ${error.message}`); + } + } + const plan = options.firstReleaseWithdrawal + ? planFirstReleaseWithdrawal(materialized, firstReleaseAuthority) + : planRollback(materialized, options.knownGood, options.knownGoodCommit); + const results = []; + results.push(appendEvent(options.ledger, eventIdentity( + source, + options, + 'rollback_started', + plan.mode === 'first-release-withdrawal' + ? { + mode: plan.mode, + requiredNextVersion: plan.requiredNextVersion, + } + : { + mode: plan.mode, + knownGoodVersion: options.knownGood, + knownGoodCommit: options.knownGoodCommit, + forwardFixVersion: plan.forwardFixVersion, + forwardFixBranch: plan.forwardFixBranch, + } + ))); + if (plan.mode === 'first-release-withdrawal') { + const incidentOptions = { + ...options, + idempotencyKey: `${options.idempotencyKey}:incident`, + }; + results.push(appendEvent(options.ledger, eventIdentity( + source, + incidentOptions, + 'incident_opened', + { + incidentId: `first-release-withdrawal-v${materialized.version}`, + kind: 'recovery', + summary: `v${materialized.version} requires withdrawal and a separately reviewed v${plan.requiredNextVersion}`, + } + ))); + } + const authorityReasons = { + npm: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + pypi: 'PyPI trusted publishing cannot yank an existing release', + 'homebrew-core': 'Homebrew Core changes require an upstream maintainer-reviewed pull request', + winget: 'WinGet correction requires upstream submission and moderation authority', + chocolatey: 'Chocolatey unlisting or superseding requires publisher moderation authority', + }; + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + const eventOptions = { + ...options, + idempotencyKey: `${options.idempotencyKey}:action-required:${action.channel}`, + }; + results.push(appendEvent(options.ledger, eventIdentity( + source, + eventOptions, + 'action_required', + { + action: action.action, + authority: action.authority, + reason: authorityReasons[action.channel], + }, + action.channel + ))); + } + process.stdout.write(`${JSON.stringify({ + ...plan, + ledger: { + changed: results.some(result => result.changed), + eventHashes: results.map(result => result.event.eventHash), + }, + }, null, 2)}\n`); return; } @@ -528,6 +719,36 @@ async function main(argv) { return; } + if (command === 'catalog') { + if (!manifest) throw new Error('catalog requires --manifest'); + requireOptions('catalog', options, ['outputDir']); + const rendered = renderCatalogFromOptions(manifest, options); + const result = writeChannelCatalogOutput( + rendered, + options.outputDir, + options.check === true + ); + validateDocument('catalog', path.join(result.outputDir, 'channel-lock.json'), packageRoot); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + if (command === 'catalog-status') { + if (!manifest) throw new Error('catalog-status requires --manifest'); + requireOptions('catalog-status', options, ['catalogRoot']); + const rendered = renderCatalogFromOptions(manifest, options); + const result = classifyChannelCatalog(rendered, options.catalogRoot); + if (result.classification === 'exact') { + validateDocument( + 'catalog', + path.join(path.resolve(options.catalogRoot), 'channel-lock.json'), + packageRoot + ); + } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (command === 'validate-adapters') { if (!manifest) throw new Error('validate-adapters requires --manifest'); requireOptions('validate-adapters', options, ['outputDir']); diff --git a/cli/scripts/release/lib.js b/cli/scripts/release/lib.js index 9d6be8f..5221197 100644 --- a/cli/scripts/release/lib.js +++ b/cli/scripts/release/lib.js @@ -18,12 +18,35 @@ const CHANNELS = Object.freeze([ {id: 'bun', phase: 'node', operation: 'verify', artifacts: ['npm-tarball']}, {id: 'github', phase: 'native', operation: 'publish', artifacts: ['standalone', 'checksum', 'sbom']}, {id: 'homebrew', phase: 'full', operation: 'update-adapter', artifacts: ['standalone']}, + { + id: 'homebrew-core', + phase: 'full', + operation: 'submit', + artifacts: ['npm-tarball'], + required: false, + }, {id: 'scoop', phase: 'full', operation: 'update-adapter', artifacts: ['standalone']}, {id: 'winget', phase: 'full', operation: 'submit', artifacts: ['standalone']}, {id: 'chocolatey', phase: 'full', operation: 'publish', artifacts: ['standalone']}, {id: 'pypi', phase: 'full', operation: 'publish', artifacts: ['python-wheel', 'standalone']}, ]); +const REQUIRED_RC_CHANNELS = Object.freeze([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', +]); + +function releaseTrainChannels(channel, options = {}) { + if (!['rc', 'stable'].includes(channel)) { + throw new Error(`unsupported release train channel: ${channel}`); + } + const channels = [...REQUIRED_RC_CHANNELS]; + if (channel === 'stable') { + if (options.homebrewCoreEnabled === true) channels.push('homebrew-core'); + channels.push('winget', 'chocolatey'); + } + return channels; +} + function readBounded(file, label = 'file') { const descriptor = fs.openSync(file, 'r'); try { @@ -106,8 +129,28 @@ function validateSource(options = {}) { if (semver.valid(packageJson.version)) { const changelog = readBounded(path.join(packageRoot, 'CHANGELOG.md'), 'CHANGELOG.md'); const escapedVersion = packageJson.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`, 'm').test(changelog)) { + const releaseHeadings = changelog.match( + new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`, 'gm') + ) || []; + if (releaseHeadings.length === 0) { errors.push(`CHANGELOG.md has no release section for ${packageJson.version}`); + } else if (releaseHeadings.length !== 1) { + errors.push(`CHANGELOG.md must contain exactly one release section for ${packageJson.version}`); + } + if (semver.prerelease(packageJson.version) === null) { + const unreleasedHeading = /^## \[Unreleased\][^\n]*$/m.exec(changelog); + const unreleasedBody = unreleasedHeading + ? changelog.slice( + unreleasedHeading.index + unreleasedHeading[0].length, + (() => { + const next = changelog.indexOf('\n## [', unreleasedHeading.index + unreleasedHeading[0].length); + return next === -1 ? changelog.length : next; + })() + ).trim() + : ''; + if (unreleasedBody.length > 0) { + errors.push(`CHANGELOG.md Unreleased section must be empty for stable release ${packageJson.version}`); + } } } @@ -137,6 +180,7 @@ function createPlan(source, profile = 'node') { phase: channel.phase, operation: channel.operation, artifacts: [...channel.artifacts], + required: channel.required !== false, status: PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] ? 'planned' : 'deferred', })); const gates = [ @@ -181,7 +225,9 @@ function createInitialState(source, profile, timestamp) { if (!(profile in PROFILE_ORDER)) throw new Error(`Unknown release profile: ${profile}`); const normalizedTimestamp = new Date(timestamp).toISOString(); const publicationChannels = CHANNELS.filter(channel => ( - channel.operation !== 'verify' && PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] + channel.operation !== 'verify' + && channel.required !== false + && PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] )); return { schemaVersion: 'guardscan.release-state.v1', @@ -241,6 +287,7 @@ function validateDocument(kind, file, packageRoot) { const document = readJson(path.resolve(file), kind); const schemaNames = { approval: 'guardscan.release-approval.v1.schema.json', + catalog: 'guardscan.channel-catalog.v1.schema.json', decision: 'guardscan.promotion-decision.v1.schema.json', event: 'guardscan.release-event.v1.schema.json', manifest: 'guardscan.release-manifest.v1.schema.json', @@ -260,7 +307,7 @@ function validateDocument(kind, file, packageRoot) { } function assertInternalDocumentIdentity(kind, document) { - if (kind === 'decision') return; + if (kind === 'decision' || kind === 'catalog') return; if (document.tag !== `v${document.version}`) { throw new Error(`${kind} tag does not match its version`); } @@ -340,6 +387,7 @@ module.exports = { prepareRelease, readBounded, readJson, + releaseTrainChannels, resolveGitCommit, resolveGitTimestamp, summarizeState, diff --git a/cli/scripts/release/promotion.js b/cli/scripts/release/promotion.js index 0cc8d12..909da13 100644 --- a/cli/scripts/release/promotion.js +++ b/cli/scripts/release/promotion.js @@ -59,6 +59,7 @@ function createPromotionDecision(input) { const reasons = []; if (evaluatedAt < eligibleAt) reasons.push('soak_window_incomplete'); if (input.rc.sourcePrHead !== input.currentSourcePrHead) reasons.push('source_pr_head_changed'); + if (input.rc.sourcePrBase !== input.currentSourcePrBase) reasons.push('source_pr_base_changed'); const activeIncidents = (input.incidents || []).filter(incident => incident.status === 'open'); if (activeIncidents.length > 0) reasons.push('active_release_incident'); @@ -92,12 +93,16 @@ function createPromotionDecision(input) { eligibleAt: eligibleAt.toISOString(), sourcePr: input.rc.sourcePr, sourcePrHead: input.rc.sourcePrHead, + sourcePrBase: input.rc.sourcePrBase, + sourcePrTree: input.rc.sourcePrTree, }, stable: { version, tag: `v${version}`, sourcePr: input.rc.sourcePr, sourcePrHead: input.currentSourcePrHead, + sourcePrBase: input.currentSourcePrBase, + sourcePrTree: input.rc.sourcePrTree, }, evaluatedAt: input.evaluatedAt, policy: { diff --git a/cli/scripts/release/publication-evidence.js b/cli/scripts/release/publication-evidence.js new file mode 100644 index 0000000..61f78b1 --- /dev/null +++ b/cli/scripts/release/publication-evidence.js @@ -0,0 +1,76 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SCHEMA = 'guardscan.provider-publication.v1'; +const CHANNELS = new Set(['github', 'npm', 'pypi']); +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/; + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function aggregateFiles(files) { + const names = Object.keys(files).sort(); + if (names.length < 1 || names.length > 100) { + throw new Error('provider publication evidence requires 1-100 files'); + } + for (const name of names) { + if (name.length > 200 || !FILENAME_PATTERN.test(name) || !SHA256_PATTERN.test(files[name] || '')) { + throw new Error(`provider publication file identity is invalid: ${name}`); + } + } + return names.length === 1 + ? files[names[0]] + : sha256(names.map(name => `${name}\0${files[name]}\n`).join('')); +} + +function createPublicationEvidence(input) { + if (!CHANNELS.has(input.channel)) throw new Error('provider publication channel is invalid'); + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$/.test(input.version || '')) { + throw new Error('provider publication version is invalid'); + } + if (input.tag !== `v${input.version}`) throw new Error('provider publication tag is invalid'); + if (typeof input.remoteIdentity !== 'string' + || input.remoteIdentity.length < 1 + || input.remoteIdentity.length > 500) { + throw new Error('provider publication remote identity is invalid'); + } + const files = Object.fromEntries(Object.entries(input.files || {}).sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + ))); + return { + schemaVersion: SCHEMA, + channel: input.channel, + version: input.version, + tag: input.tag, + remoteIdentity: input.remoteIdentity, + aggregateSha256: aggregateFiles(files), + files, + }; +} + +function writePublicationEvidence(file, input) { + const evidence = createPublicationEvidence(input); + const resolved = path.resolve(file); + fs.mkdirSync(path.dirname(resolved), {recursive: true, mode: 0o700}); + const text = `${JSON.stringify(evidence, null, 2)}\n`; + if (fs.existsSync(resolved)) { + if (fs.readFileSync(resolved, 'utf8') !== text) { + throw new Error(`provider publication evidence conflicts with existing file: ${resolved}`); + } + return {created: false, evidence}; + } + fs.writeFileSync(resolved, text, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); + return {created: true, evidence}; +} + +module.exports = { + SCHEMA, + aggregateFiles, + createPublicationEvidence, + writePublicationEvidence, +}; diff --git a/cli/scripts/release/reconcile.js b/cli/scripts/release/reconcile.js index 8cda44e..c9df653 100644 --- a/cli/scripts/release/reconcile.js +++ b/cli/scripts/release/reconcile.js @@ -12,19 +12,23 @@ function channelOperation(channel) { function reconcileRelease(state) { const actions = []; const blocking = []; + const optionalBlocking = []; const openIncidents = Object.entries(state.incidents || {}) .filter(([, incident]) => incident.status === 'open') .map(([incidentId]) => incidentId); if (openIncidents.length > 0) blocking.push(`open incidents: ${openIncidents.join(', ')}`); for (const [channel, channelState] of Object.entries(state.channels || {})) { + const definition = CHANNELS.find(candidate => candidate.id === channel); const operation = channelOperation(channel); + const required = definition.required !== false; if (['verified', 'withdrawn', 'superseded'].includes(channelState.status)) continue; if (channelState.status === 'failed') { - blocking.push(`${channel} failed`); + (required ? blocking : optionalBlocking).push(`${channel} failed`); continue; } const action = { channel, + required, currentStatus: channelState.status, action: 'verify', }; @@ -32,6 +36,12 @@ function reconcileRelease(state) { action.action = operation === 'verify' ? 'verify' : operation; } else if (channelState.status === 'submitted') { action.action = 'poll-acceptance'; + } else if (channelState.status === 'rejected') { + action.action = 'prepare-correction'; + } else if (channelState.status === 'corrected') { + action.action = 'resubmit-correction'; + } else if (channelState.status === 'resubmitted') { + action.action = 'poll-resubmission'; } else if (channelState.status === 'accepted') { action.action = 'verify-public-install'; } else if (channelState.status === 'published') { @@ -40,9 +50,10 @@ function reconcileRelease(state) { actions.push(action); } return { - complete: actions.length === 0 && blocking.length === 0, + complete: actions.every(action => action.required === false) && blocking.length === 0, blocked: blocking.length > 0, blocking, + optionalBlocking, actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), }; } @@ -53,10 +64,25 @@ function nextPatchVersion(version) { return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; } -function planRollback(state, knownGoodVersion) { - if (knownGoodVersion && !semver.valid(knownGoodVersion)) { +function planRollback(state, knownGoodVersion, knownGoodCommit) { + if (!knownGoodVersion) throw new Error('a verified known-good version is required'); + if (!semver.valid(knownGoodVersion) || semver.prerelease(knownGoodVersion) !== null) { throw new Error('known-good version is invalid'); } + if (!knownGoodCommit) throw new Error('known-good commit is required'); + if (!/^[a-f0-9]{40}$/.test(knownGoodCommit)) throw new Error('known-good commit is invalid'); + if (!semver.lt(knownGoodVersion, state.version)) { + throw new Error('known-good version must precede the defective release'); + } + const forwardFixVersion = nextPatchVersion(state.version); + const forwardFixBranch = `release/forward-fix-v${forwardFixVersion}-from-v${knownGoodVersion}`; + const externalAuthority = { + npm: 'npm-maintainer', + pypi: 'pypi-maintainer', + 'homebrew-core': 'homebrew-core-maintainer', + winget: 'winget-maintainer', + chocolatey: 'chocolatey-maintainer', + }; const actions = []; for (const [channel, channelState] of Object.entries(state.channels || {})) { if (!['published', 'submitted', 'accepted', 'verified'].includes(channelState.status)) continue; @@ -70,20 +96,119 @@ function planRollback(state, knownGoodVersion) { yarn: 'verify-npm-forward-fix', bun: 'verify-npm-forward-fix', pypi: 'yank-and-forward-fix', - homebrew: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', - scoop: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', + homebrew: 'redirect-to-known-good', + 'homebrew-core': 'submit-corrective-formula-or-revision', + scoop: 'redirect-to-known-good', winget: 'submit-corrective-manifest', chocolatey: 'unlist-or-supersede', }[channel], + automation: externalAuthority[channel] + ? 'external-action-required' + : 'repository-automated', + ...(externalAuthority[channel] ? {authority: externalAuthority[channel]} : {}), }; actions.push(action); } return { + schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'known-good', + version: state.version, + tag: state.tag, + commit: state.commit, + status: 'planned', + knownGood: { + version: knownGoodVersion, + tag: `v${knownGoodVersion}`, + commit: knownGoodCommit, + }, + knownGoodVersion, + knownGoodCommit, + forwardFixVersion, + forwardFixBranch, + repositoryActions: [ + {id: 'deactivate-train', action: 'remove-from-active-versions', status: 'planned'}, + {id: 'forward-fix-pr', action: 'open-or-update-pull-request', status: 'planned'}, + {id: 'shared-catalog-rollback', action: 'open-or-update-catalog-pull-request', status: 'planned'}, + ], + actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), + }; +} + +function planFirstReleaseWithdrawal(state, authority) { + if (!semver.valid(state.version) || semver.prerelease(state.version) !== null) { + throw new Error('first-release withdrawal requires a stable defective release'); + } + if (authority?.schemaVersion !== 'guardscan.first-release-withdrawal-authority.v1' + || authority.verified !== true + || authority.defectiveVersion !== state.version + || authority.defectiveTag !== state.tag + || authority.defectiveCommit !== state.commit + || !/^[a-f0-9]{40}$/.test(authority.ledgerCommit || '') + || !Array.isArray(authority.priorCompleteStableVersions) + || authority.priorCompleteStableVersions.length !== 0) { + throw new Error('first-release withdrawal requires exact protected-ledger authority'); + } + const externalAuthority = { + npm: 'npm-maintainer', + pypi: 'pypi-maintainer', + 'homebrew-core': 'homebrew-core-maintainer', + winget: 'winget-maintainer', + chocolatey: 'chocolatey-maintainer', + }; + const actionByChannel = { + github: 'retain-immutable-and-mark-superseded', + npm: 'deprecate-defective-release', + pnpm: 'mark-defective-npm-consumer-superseded', + yarn: 'mark-defective-npm-consumer-superseded', + bun: 'mark-defective-npm-consumer-superseded', + pypi: 'yank-defective-release', + homebrew: 'remove-first-release-listing', + 'homebrew-core': 'submit-removal-or-revision', + scoop: 'remove-first-release-listing', + winget: 'submit-removal-or-corrective-manifest', + chocolatey: 'unlist-defective-release', + }; + const actions = []; + for (const [channel, channelState] of Object.entries(state.channels || {})) { + if (channelState.status === 'planned') { + actions.push({ + channel, + currentStatus: channelState.status, + action: 'confirm-unpublished-and-cancel', + automation: 'repository-automated', + }); + continue; + } + if (['withdrawn', 'superseded'].includes(channelState.status)) continue; + actions.push({ + channel, + currentStatus: channelState.status, + action: actionByChannel[channel], + automation: externalAuthority[channel] + ? 'external-action-required' + : 'repository-automated', + ...(externalAuthority[channel] ? {authority: externalAuthority[channel]} : {}), + }); + } + return { + schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'first-release-withdrawal', version: state.version, - knownGoodVersion: knownGoodVersion || null, - forwardFixVersion: nextPatchVersion(state.version), + tag: state.tag, + commit: state.commit, + status: 'planned', + requiredNextVersion: nextPatchVersion(state.version), + authority: { + ledgerCommit: authority.ledgerCommit, + priorCompleteStableVersions: [], + }, + repositoryActions: [ + {id: 'open-recovery-incident', action: 'retain-open-incident', status: 'planned'}, + {id: 'shared-catalog-withdrawal', action: 'remove-first-release-listing', status: 'planned'}, + {id: 'deactivate-train', action: 'remove-from-active-versions', status: 'planned'}, + ], actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), }; } -module.exports = {planRollback, reconcileRelease}; +module.exports = {planFirstReleaseWithdrawal, planRollback, reconcileRelease}; diff --git a/cli/scripts/release/recovery-source.js b/cli/scripts/release/recovery-source.js new file mode 100644 index 0000000..c79746e --- /dev/null +++ b/cli/scripts/release/recovery-source.js @@ -0,0 +1,109 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +const FILES = Object.freeze([ + 'cli/CHANGELOG.md', + 'cli/package-lock.json', + 'cli/package.json', +]); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function readRegularFile(repositoryRoot, relative) { + const file = path.join(repositoryRoot, ...relative.split('/')); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`forward-fix source is not a regular file: ${relative}`); + } + if (stat.size > 10 * 1024 * 1024) { + throw new Error(`forward-fix source exceeds its size limit: ${relative}`); + } + return {file, contents: fs.readFileSync(file, 'utf8')}; +} + +function recoverySection(input) { + return [ + `## [${input.forwardFixVersion}]`, + '', + '### Fixed', + '', + `- Restore verified v${input.knownGoodVersion} source as a forward fix for defective v${input.defectiveVersion} without replacing immutable release artifacts.`, + ].join('\n'); +} + +function updateChangelog(contents, input) { + const section = recoverySection(input); + if (contents.includes(`${section}\n`) || contents.endsWith(section)) return contents; + const headingPattern = new RegExp(`^## \\[${input.forwardFixVersion.replace(/\./g, '\\.')}\\](?:\\s|$)`, 'm'); + if (headingPattern.test(contents)) { + throw new Error(`CHANGELOG already has conflicting notes for ${input.forwardFixVersion}`); + } + const unreleased = /^## \[Unreleased\][^\n]*$/m.exec(contents); + if (!unreleased) throw new Error('CHANGELOG has no Unreleased section'); + const headingEnd = unreleased.index + unreleased[0].length; + const nextHeading = contents.indexOf('\n## [', headingEnd); + const boundary = nextHeading === -1 ? contents.length : nextHeading; + if (contents.slice(headingEnd, boundary).trim().length > 0) { + throw new Error('known-good CHANGELOG Unreleased section must be empty'); + } + return `${contents.slice(0, headingEnd)}\n\n${section}${contents.slice(boundary)}`; +} + +function prepareForwardFixSource(repositoryRoot, input) { + const root = path.resolve(repositoryRoot); + if (path.dirname(root) === root) throw new Error('refusing to prepare a filesystem root'); + const {knownGoodVersion, defectiveVersion, forwardFixVersion} = input; + if (!semver.valid(knownGoodVersion) || semver.prerelease(knownGoodVersion) !== null) { + throw new Error('forward-fix known-good version is invalid'); + } + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('forward-fix defective version is invalid'); + } + if (forwardFixVersion !== semver.inc(defectiveVersion, 'patch')) { + throw new Error('forward-fix version must be the next patch after the defective release'); + } + const packageSource = readRegularFile(root, 'cli/package.json'); + const lockSource = readRegularFile(root, 'cli/package-lock.json'); + const changelogSource = readRegularFile(root, 'cli/CHANGELOG.md'); + const packageJson = JSON.parse(packageSource.contents); + const packageLock = JSON.parse(lockSource.contents); + for (const [label, version] of [ + ['package.json', packageJson.version], + ['package-lock.json', packageLock.version], + ['package-lock.json root package', packageLock.packages?.['']?.version], + ]) { + if (![knownGoodVersion, forwardFixVersion].includes(version)) { + throw new Error(`${label} does not match the known-good or forward-fix version`); + } + } + packageJson.version = forwardFixVersion; + packageLock.version = forwardFixVersion; + packageLock.packages[''].version = forwardFixVersion; + const outputs = { + 'cli/package.json': `${JSON.stringify(packageJson, null, 2)}\n`, + 'cli/package-lock.json': `${JSON.stringify(packageLock, null, 2)}\n`, + 'cli/CHANGELOG.md': updateChangelog(changelogSource.contents, input), + }; + let changed = false; + for (const relative of FILES) { + const target = path.join(root, ...relative.split('/')); + if (fs.readFileSync(target, 'utf8') === outputs[relative]) continue; + fs.writeFileSync(target, outputs[relative], {encoding: 'utf8', mode: 0o600}); + changed = true; + } + return { + schemaVersion: 'guardscan.forward-fix-source.v1', + changed, + version: forwardFixVersion, + files: [...FILES], + digests: Object.fromEntries(FILES.map(relative => [relative, sha256(outputs[relative])])), + }; +} + +module.exports = {prepareForwardFixSource}; diff --git a/cli/scripts/release/renderers.js b/cli/scripts/release/renderers.js index 9da4673..327f430 100644 --- a/cli/scripts/release/renderers.js +++ b/cli/scripts/release/renderers.js @@ -8,9 +8,24 @@ const {readJson} = require('./lib'); const RENDER_SCHEMA = 'guardscan.release-render.v1'; const MARKER_FILE = '.guardscan-release-render.json'; -const SUPPORTED_CHANNELS = Object.freeze(['homebrew', 'scoop', 'winget', 'chocolatey', 'pypi']); +const CATALOG_SCHEMA = 'guardscan.channel-catalog.v1'; +const CATALOG_FILES = Object.freeze([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', +]); +const SUPPORTED_CHANNELS = Object.freeze([ + 'homebrew', + 'homebrew-core', + 'scoop', + 'winget', + 'chocolatey', + 'pypi', +]); const DEFAULT_CHANNELS = Object.freeze(['homebrew', 'scoop', 'winget', 'chocolatey', 'pypi']); const MAX_RENDERED_FILES = 100; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const COMMIT_PATTERN = /^[a-f0-9]{40}$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; function parseChannels(value) { const channels = value @@ -71,6 +86,10 @@ function isSecureUrl(value) { } } +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + function selectStandalone(manifest, os, arch, libc) { const matches = manifest.artifacts.filter(artifact => ( artifact.kind === 'standalone' @@ -140,6 +159,54 @@ function renderHomebrew(manifest, artifacts) { ].join('\n'); } +function selectNpmTarball(manifest) { + const matches = manifest.artifacts.filter(artifact => artifact.kind === 'npm-tarball'); + if (matches.length !== 1) { + throw new Error('Homebrew Core rendering requires exactly one npm-tarball artifact'); + } + const artifact = matches[0]; + const expectedUrl = `https://registry.npmjs.org/guardscan/-/guardscan-${manifest.version}.tgz`; + if (artifact.url !== expectedUrl) { + throw new Error('Homebrew Core npm tarball URL is not the canonical versioned registry URL'); + } + if (!SHA256_PATTERN.test(artifact.sha256 || '')) { + throw new Error('Homebrew Core npm tarball has an invalid SHA-256'); + } + if (!artifact.integrity || artifact.provenance?.verified !== true + || !isSecureUrl(artifact.provenance.url)) { + throw new Error('Homebrew Core npm tarball lacks verified provenance'); + } + return artifact; +} + +function renderHomebrewCore(manifest) { + if (semver.prerelease(manifest.version)) { + throw new Error('Homebrew Core rendering is supported only for stable releases'); + } + const artifact = selectNpmTarball(manifest); + return [ + '# Generated by GuardScan release automation for submission to homebrew/core.', + 'class Guardscan < Formula', + ' desc "Privacy-first code review and security scanning CLI"', + ' homepage "https://guardscancli.com"', + ` url "${artifact.url}"`, + ` sha256 "${artifact.sha256}"`, + ' license "MIT"', + '', + ' depends_on "node"', + '', + ' def install', + ' system "npm", "install", *std_npm_args', + ' end', + '', + ' test do', + ' assert_match version.to_s, shell_output("#{bin}/guardscan --version")', + ' end', + 'end', + '', + ].join('\n'); +} + function renderScoop(manifest, artifact) { return `${JSON.stringify({ version: manifest.version, @@ -304,6 +371,9 @@ function renderAdapters(manifest, channelInput) { if (channels.includes('homebrew')) { files['homebrew/Formula/guardscan.rb'] = renderHomebrew(manifest, artifacts); } + if (channels.includes('homebrew-core')) { + files['homebrew-core/Formula/guardscan.rb'] = renderHomebrewCore(manifest); + } if (channels.includes('scoop')) { files['scoop/bucket/guardscan.json'] = renderScoop(manifest, artifacts.windowsX64); } @@ -313,6 +383,315 @@ function renderAdapters(manifest, channelInput) { return {channels, files}; } +function validateCatalogOptions(manifest, options = {}) { + const manifestUrl = String(options.manifestUrl || ''); + const manifestSha256 = String(options.manifestSha256 || ''); + const generatorRepository = String(options.generatorRepository || ''); + const generatorCommit = String(options.generatorCommit || '').toLowerCase(); + if (!isSecureUrl(manifestUrl)) throw new Error('catalog manifestUrl must be a secure HTTPS URL'); + const expectedManifestUrl = `https://github.com/ntanwir10/GuardScan/releases/download/${manifest.tag}/release-manifest.json`; + if (manifestUrl !== expectedManifestUrl) { + throw new Error('catalog manifestUrl is not the canonical immutable GuardScan release URL'); + } + if (!SHA256_PATTERN.test(manifestSha256)) { + throw new Error('catalog manifestSha256 must be a lowercase SHA-256'); + } + if (!REPOSITORY_PATTERN.test(generatorRepository) + || generatorRepository !== 'ntanwir10/GuardScan') { + throw new Error('catalog generator repository must be ntanwir10/GuardScan'); + } + if (!COMMIT_PATTERN.test(generatorCommit)) { + throw new Error('catalog generator commit must be a lowercase 40-character SHA'); + } + if (generatorCommit !== manifest.commit) { + throw new Error('catalog generator commit must match the exact release source commit'); + } + return {manifestUrl, manifestSha256, generatorRepository, generatorCommit}; +} + +function renderChannelCatalog(manifest, options = {}) { + const catalogOptions = validateCatalogOptions(manifest, options); + const artifacts = selectNativeArtifacts(manifest, ['homebrew', 'scoop']); + const files = { + 'Formula/guardscan.rb': renderHomebrew(manifest, artifacts), + 'bucket/guardscan.json': renderScoop(manifest, artifacts.windowsX64), + }; + const lock = { + schemaVersion: CATALOG_SCHEMA, + source: { + repository: 'ntanwir10/GuardScan', + version: manifest.version, + tag: manifest.tag, + commit: manifest.commit, + manifestUrl: catalogOptions.manifestUrl, + manifestSha256: catalogOptions.manifestSha256, + }, + generator: { + repository: catalogOptions.generatorRepository, + commit: catalogOptions.generatorCommit, + }, + files: Object.fromEntries(CATALOG_FILES.map(file => [ + file, + {sha256: sha256(files[file])}, + ])), + }; + return { + files: { + ...files, + 'channel-lock.json': `${JSON.stringify(lock, null, 2)}\n`, + }, + lock, + }; +} + +function assertSafeCatalogTarget(root, relative) { + let current = root; + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) { + throw new Error(`catalog output root must not be a symbolic link: ${root}`); + } + for (const segment of relative.split('/')) { + current = path.join(current, segment); + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) { + throw new Error(`catalog output contains a symbolic link: ${relative}`); + } + } +} + +function listManagedCatalogFiles(outputDir) { + const files = []; + function visit(directory, relative) { + if (!fs.existsSync(directory)) return; + const stat = fs.lstatSync(directory); + if (stat.isSymbolicLink()) { + throw new Error(`catalog managed path contains a symbolic link: ${relative}`); + } + if (!stat.isDirectory()) { + throw new Error(`catalog managed path is not a directory: ${relative}`); + } + for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { + const nextRelative = `${relative}/${entry.name}`; + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`catalog managed path contains a symbolic link: ${nextRelative}`); + } + if (entry.isDirectory()) visit(absolute, nextRelative); + else if (entry.isFile()) files.push(nextRelative); + else throw new Error(`catalog managed path contains a non-regular entry: ${nextRelative}`); + } + } + for (const directory of ['Formula', 'bucket']) { + visit(path.join(outputDir, directory), directory); + } + return files.sort(); +} + +function compareCatalogOutput(outputDir, rendered) { + const managedFiles = listManagedCatalogFiles(outputDir); + if (managedFiles.join('\n') !== [...CATALOG_FILES].sort().join('\n')) return false; + return Object.entries(rendered.files).every(([relative, contents]) => { + assertSafeCatalogTarget(outputDir, relative); + const target = path.join(outputDir, ...relative.split('/')); + return fs.existsSync(target) + && fs.statSync(target).isFile() + && fs.readFileSync(target, 'utf8') === contents; + }); +} + +function writeChannelCatalogOutput(rendered, outputDir, checkOnly = false) { + const resolved = path.resolve(outputDir); + if (path.dirname(resolved) === resolved) { + throw new Error('refusing to use a filesystem root as catalog output'); + } + const unexpectedManagedFiles = listManagedCatalogFiles(resolved) + .filter(file => !CATALOG_FILES.includes(file)); + if (unexpectedManagedFiles.length > 0) { + throw new Error( + `refusing to update catalog with unmanaged generated paths: ${unexpectedManagedFiles.join(', ')}` + ); + } + if (compareCatalogOutput(resolved, rendered)) { + return { + changed: false, + checked: checkOnly, + outputDir: resolved, + files: Object.keys(rendered.files).sort(), + lockSha256: sha256(rendered.files['channel-lock.json']), + }; + } + if (checkOnly) throw new Error(`channel catalog is missing, stale, or manually edited: ${resolved}`); + fs.mkdirSync(resolved, {recursive: true, mode: 0o700}); + const staged = []; + try { + for (const [relative, contents] of Object.entries(rendered.files)) { + assertSafeCatalogTarget(resolved, relative); + const target = path.join(resolved, ...relative.split('/')); + fs.mkdirSync(path.dirname(target), {recursive: true, mode: 0o700}); + const stage = `${target}.guardscan-${process.pid}-${crypto.randomUUID()}.tmp`; + fs.writeFileSync(stage, contents, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); + staged.push({stage, target}); + } + for (const entry of staged) fs.renameSync(entry.stage, entry.target); + } finally { + for (const entry of staged) fs.rmSync(entry.stage, {force: true}); + } + return { + changed: true, + checked: false, + outputDir: resolved, + files: Object.keys(rendered.files).sort(), + lockSha256: sha256(rendered.files['channel-lock.json']), + }; +} + +function readCatalogLock(catalogRoot) { + const resolved = path.resolve(catalogRoot); + const lockFile = path.join(resolved, 'channel-lock.json'); + if (!fs.existsSync(lockFile)) return undefined; + assertSafeCatalogTarget(resolved, 'channel-lock.json'); + const text = fs.readFileSync(lockFile, 'utf8'); + return {document: readJson(lockFile, 'channel catalog lock'), text}; +} + +function hasExactKeys(value, keys) { + return value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).sort().join('\n') === [...keys].sort().join('\n'); +} + +function isSelfConsistentCatalog(lock, catalogRoot) { + try { + if (!hasExactKeys(lock, ['schemaVersion', 'source', 'generator', 'files']) + || lock.schemaVersion !== CATALOG_SCHEMA + || !hasExactKeys(lock.source, [ + 'repository', + 'version', + 'tag', + 'commit', + 'manifestUrl', + 'manifestSha256', + ]) + || !hasExactKeys(lock.generator, ['repository', 'commit']) + || !hasExactKeys(lock.files, CATALOG_FILES)) { + return false; + } + if (lock.source.repository !== 'ntanwir10/GuardScan' + || lock.generator.repository !== 'ntanwir10/GuardScan' + || !COMMIT_PATTERN.test(lock.source.commit || '') + || lock.generator.commit !== lock.source.commit + || lock.source.tag !== `v${lock.source.version}` + || lock.source.manifestUrl + !== `https://github.com/ntanwir10/GuardScan/releases/download/${lock.source.tag}/release-manifest.json` + || !SHA256_PATTERN.test(lock.source.manifestSha256 || '')) { + return false; + } + const managedFiles = listManagedCatalogFiles(path.resolve(catalogRoot)); + if (managedFiles.join('\n') !== [...CATALOG_FILES].sort().join('\n')) return false; + for (const relative of CATALOG_FILES) { + if (!hasExactKeys(lock.files[relative], ['sha256']) + || !SHA256_PATTERN.test(lock.files[relative].sha256 || '')) { + return false; + } + assertSafeCatalogTarget(path.resolve(catalogRoot), relative); + const contents = fs.readFileSync(path.join(catalogRoot, ...relative.split('/'))); + if (sha256(contents) !== lock.files[relative].sha256) return false; + } + return true; + } catch { + return false; + } +} + +function classifyChannelCatalog(rendered, catalogRoot) { + const actual = readCatalogLock(catalogRoot); + const desiredLock = rendered.lock; + const desiredLockSha256 = sha256(rendered.files['channel-lock.json']); + if (!actual) { + return { + classification: 'missing', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + desiredLockSha256, + }; + } + const actualLock = actual.document; + const actualLockSha256 = sha256(actual.text); + if (!actualLock || typeof actualLock !== 'object' || Array.isArray(actualLock) + || actualLock.schemaVersion !== CATALOG_SCHEMA + || !actualLock.source || !actualLock.generator || !actualLock.files) { + return { + classification: 'invalid', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (!isSelfConsistentCatalog(actualLock, catalogRoot)) { + return { + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + const actualVersion = semver.valid(actualLock.source.version); + const desiredVersion = semver.valid(desiredLock.source.version); + if (!actualVersion || !desiredVersion) { + return { + classification: 'invalid', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (semver.gt(actualVersion, desiredVersion)) { + return { + classification: 'unexpected-newer', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (semver.lt(actualVersion, desiredVersion)) { + return { + classification: 'older', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + desiredLockSha256, + actualLockSha256, + }; + } + if (actualLock.source.tag !== desiredLock.source.tag + || actualLock.source.commit !== desiredLock.source.commit + || actualLock.source.manifestSha256 !== desiredLock.source.manifestSha256) { + return { + classification: 'release-identity-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (!compareCatalogOutput(path.resolve(catalogRoot), rendered)) { + return { + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + return { + classification: 'exact', + integrityIncident: false, + action: 'record-verified', + desiredLockSha256, + actualLockSha256, + }; +} + function expectedOutput(manifest, rendered) { const paths = Object.keys(rendered.files).sort(); const marker = { @@ -410,12 +789,17 @@ function writeRenderedOutput(manifest, rendered, outputDir, checkOnly = false) { } module.exports = { + CATALOG_FILES, + CATALOG_SCHEMA, DEFAULT_CHANNELS, MARKER_FILE, RENDER_SCHEMA, SUPPORTED_CHANNELS, + classifyChannelCatalog, parseChannels, renderAdapters, + renderChannelCatalog, toPep440, + writeChannelCatalogOutput, writeRenderedOutput, }; diff --git a/cli/scripts/release/runtime-artifact-policy.js b/cli/scripts/release/runtime-artifact-policy.js new file mode 100644 index 0000000..87ae790 --- /dev/null +++ b/cli/scripts/release/runtime-artifact-policy.js @@ -0,0 +1,51 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FORBIDDEN_RUNTIME_LITERALS = Object.freeze([ + 'api.guardscancli.com', + 'GUARDSCAN_API_URL', + 'DEFAULT_API_BASE_URL', +]); + +function findForbiddenRuntimeLiterals(content) { + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); + return FORBIDDEN_RUNTIME_LITERALS.filter(literal => bytes.includes(Buffer.from(literal, 'utf8'))); +} + +function assertRuntimeArtifactClean(content, label) { + const matches = findForbiddenRuntimeLiterals(content); + if (matches.length > 0) { + throw new Error(`${label} contains forbidden retired runtime literals: ${matches.join(', ')}`); + } +} + +function assertCompiledRuntimeFilesClean(root, files, label = 'compiled runtime') { + const resolvedRoot = path.resolve(root); + const runtimeFiles = [...files] + .map(file => String(file).replace(/\\/g, '/')) + .filter(file => file.startsWith('dist/') && /\.(?:c|m)?js$/i.test(file)) + .sort(); + if (runtimeFiles.length === 0) { + throw new Error(`${label} contains no compiled JavaScript under dist/`); + } + for (const file of runtimeFiles) { + const absolute = path.resolve(resolvedRoot, file); + const relative = path.relative(resolvedRoot, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`${label} contains an unsafe runtime path: ${file}`); + } + const stat = fs.lstatSync(absolute); + if (!stat.isFile()) throw new Error(`${label} is not a regular file: ${file}`); + assertRuntimeArtifactClean(fs.readFileSync(absolute), `${label} ${file}`); + } + return runtimeFiles; +} + +module.exports = { + FORBIDDEN_RUNTIME_LITERALS, + assertCompiledRuntimeFilesClean, + assertRuntimeArtifactClean, + findForbiddenRuntimeLiterals, +}; diff --git a/cli/scripts/release/standalone-artifact.js b/cli/scripts/release/standalone-artifact.js index 37be04f..7ed50af 100644 --- a/cli/scripts/release/standalone-artifact.js +++ b/cli/scripts/release/standalone-artifact.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const {inspectArchive, writeArchive} = require('./archive'); const {readJson} = require('./lib'); +const {assertReducedCapabilityEvidence} = require('./standalone'); const ARTIFACT_METADATA_SCHEMA = 'guardscan.artifact-metadata.v1'; const EVIDENCE_SCHEMA = 'guardscan.standalone-evidence.v1'; @@ -26,6 +27,7 @@ function assertEvidence(source, platform, evidence) { || !evidence.provenance) { throw new Error('standalone evidence is incomplete'); } + assertReducedCapabilityEvidence(evidence.optionalCapabilities); return evidence; } @@ -73,9 +75,10 @@ function buildStandaloneArtifact(source, executableFile, platform, outputDir, ti capabilities: { coreScan: true, sbom: true, - chartRendering: false, - accurateTokenCounting: false, + chartRendering: evidence.optionalCapabilities.chartRendering.dependencyAvailable, + accurateTokenCounting: evidence.optionalCapabilities.tokenCounting.mode === 'accurate', }, + optionalCapabilities: evidence.optionalCapabilities, platform, archiveFormat: format, entrypoint: executableName, diff --git a/cli/scripts/release/standalone.js b/cli/scripts/release/standalone.js index 34751d6..de25b70 100644 --- a/cli/scripts/release/standalone.js +++ b/cli/scripts/release/standalone.js @@ -8,13 +8,17 @@ const {spawnSync} = require('child_process'); const {builtinModules} = require('module'); const esbuild = require('esbuild'); const {inject} = require('postject'); +const {assertRuntimeArtifactClean} = require('./runtime-artifact-policy'); const PROTOTYPE_SCHEMA = 'guardscan.standalone-prototype.v1'; +const RUNTIME_CAPABILITY_SCHEMA = 'guardscan.runtime-capabilities.v1'; const SEA_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; const OPTIONAL_EXTERNALS = Object.freeze(['chartjs-node-canvas', 'tiktoken']); const MAX_BUNDLE_BYTES = 256 * 1024 * 1024; const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024; const BUILTIN_MODULES = new Set(builtinModules.flatMap(name => [name, name.replace(/^node:/, '')])); +const TRANSIENT_RENAME_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); +const RENAME_RETRY_DELAYS_MS = Object.freeze([50, 100, 200, 400, 800, 1000, 1500, 2000]); function hostPlatform() { const osName = {darwin: 'darwin', linux: 'linux', win32: 'windows'}[process.platform]; @@ -103,6 +107,21 @@ function hashRegularFile(file, maxBytes, label) { } } +async function renameWithTransientRetry(source, destination, options = {}) { + const rename = options.rename || fs.promises.rename; + const wait = options.wait || (delay => new Promise(resolve => setTimeout(resolve, delay))); + const delays = options.delays || RENAME_RETRY_DELAYS_MS; + for (let attempt = 0; ; attempt += 1) { + try { + await rename(source, destination); + return; + } catch (error) { + if (!TRANSIENT_RENAME_CODES.has(error?.code) || attempt >= delays.length) throw error; + await wait(delays[attempt]); + } + } +} + function run(command, args, cwd, env) { const result = spawnSync(command, args, { cwd, @@ -146,6 +165,26 @@ function assertSuccessfulOutput(result, expected, label) { } } +function assertReducedCapabilityEvidence(evidence) { + const tokenCounting = evidence?.tokenCounting; + const chartRendering = evidence?.chartRendering; + const valid = evidence?.schemaVersion === RUNTIME_CAPABILITY_SCHEMA + && tokenCounting?.dependency === 'tiktoken' + && tokenCounting.dependencyAvailable === false + && tokenCounting.mode === 'estimated' + && Number.isInteger(tokenCounting.sampleTokenCount) + && tokenCounting.sampleTokenCount > 0 + && tokenCounting.safeFallbackObserved === true + && chartRendering?.dependency === 'chartjs-node-canvas' + && chartRendering.dependencyAvailable === false + && chartRendering.mode === 'unavailable' + && chartRendering.safeFallbackObserved === true; + if (!valid) { + throw new Error('standalone reduced-capability contract failed'); + } + return evidence; +} + function smokeStandalone(executable, version) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-standalone-smoke-')); const project = path.join(root, 'project'); @@ -198,11 +237,31 @@ function smokeStandalone(executable, version) { if (!telemetry.stdout.includes('Consent: disabled')) { throw new Error('standalone telemetry opt-out contract failed'); } + const capabilityResult = run( + executable, + ['--no-telemetry', 'capabilities', '--json'], + project, + env + ); + let optionalCapabilities; + try { + optionalCapabilities = assertReducedCapabilityEvidence( + JSON.parse(capabilityResult.stdout.trim()) + ); + } catch (error) { + throw new Error( + `standalone capability evidence was invalid: ${error instanceof Error ? error.message : error}` + ); + } + const optionalCapabilitiesUnavailableSafely = + optionalCapabilities.tokenCounting.safeFallbackObserved === true + && optionalCapabilities.chartRendering.safeFallbackObserved === true; return { valid: true, nodeAbsentFromPath: true, packageManagersAbsentFromPath: true, - optionalCapabilitiesUnavailableSafely: true, + optionalCapabilitiesUnavailableSafely, + optionalCapabilities, spdx: true, cyclonedx: true, }; @@ -221,6 +280,7 @@ async function prepareExecutable(bundleFile, blobFile, executable) { useCodeCache: false, }, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); run(process.execPath, ['--experimental-sea-config', seaConfig], path.dirname(bundleFile), process.env); + assertRuntimeArtifactClean(fs.readFileSync(blobFile), 'standalone SEA payload'); fs.copyFileSync(process.execPath, executable); fs.chmodSync(executable, 0o755); if (process.platform === 'darwin') { @@ -256,6 +316,7 @@ async function buildHostPrototype(source, outputDir) { const executable = path.join(stage, executableName); const build = await esbuild.build(bundleOptions(entryPoint, bundleFile)); const externals = assertExternalAllowlist(build.metafile); + assertRuntimeArtifactClean(fs.readFileSync(bundleFile), 'standalone bundle'); const bundle = hashRegularFile(bundleFile, MAX_BUNDLE_BYTES, 'standalone bundle'); await prepareExecutable(bundleFile, blobFile, executable); const smoke = smokeStandalone(executable, source.version); @@ -276,8 +337,8 @@ async function buildHostPrototype(source, outputDir) { capabilities: { coreScan: true, sbom: true, - chartRendering: false, - accurateTokenCounting: false, + chartRendering: smoke.optionalCapabilities.chartRendering.dependencyAvailable, + accurateTokenCounting: smoke.optionalCapabilities.tokenCounting.mode === 'accurate', }, optionalExternalPackages: externals, bundle, @@ -297,7 +358,7 @@ async function buildHostPrototype(source, outputDir) { }); fs.rmSync(bundleFile, {force: true}); fs.rmSync(blobFile, {force: true}); - fs.renameSync(stage, resolved); + await renameWithTransientRetry(stage, resolved); return {outputDir: resolved, metadata}; } finally { fs.rmSync(stage, {recursive: true, force: true}); @@ -309,9 +370,11 @@ module.exports = { PROTOTYPE_SCHEMA, assertEmbeddableNodeRuntime, assertExternalAllowlist, + assertReducedCapabilityEvidence, buildHostPrototype, bundleOptions, externalPackages, hostPlatform, + renameWithTransientRetry, smokeStandalone, }; diff --git a/cli/scripts/release/validators.js b/cli/scripts/release/validators.js index 2d21113..12b456c 100644 --- a/cli/scripts/release/validators.js +++ b/cli/scripts/release/validators.js @@ -25,6 +25,20 @@ function validateStructuredOutput(manifest, outputDir, channels) { } results.homebrew = {valid: true, files: 1}; } + if (channels.includes('homebrew-core')) { + const formula = readOutput( + outputDir, + 'homebrew-core/Formula/guardscan.rb', + 'Homebrew Core formula' + ); + const npmUrl = `https://registry.npmjs.org/guardscan/-/guardscan-${manifest.version}.tgz`; + if (!formula.includes(`url "${npmUrl}"`) + || !formula.includes('depends_on "node"') + || !formula.includes('system "npm", "install", *std_npm_args')) { + throw new Error('Homebrew Core formula does not preserve the source-build contract'); + } + results['homebrew-core'] = {valid: true, files: 1}; + } if (channels.includes('scoop')) { const scoop = JSON.parse(readOutput(outputDir, 'scoop/bucket/guardscan.json', 'Scoop manifest')); if (scoop.version !== manifest.version || scoop.bin !== 'guardscan.exe' @@ -95,6 +109,22 @@ function nativeValidationPlan(outputDir, channels, platform = process.platform, skip('homebrew', 'Homebrew validation requires macOS or Linux'); } } + if (channels.includes('homebrew-core')) { + if (platform === 'darwin' || platform === 'linux') { + plan.push({ + channel: 'homebrew-core', + command: 'brew', + args: [ + 'audit', + '--strict', + '--new-formula', + path.join(outputDir, 'homebrew-core', 'Formula', 'guardscan.rb'), + ], + }); + } else { + skip('homebrew-core', 'Homebrew Core validation requires macOS or Linux'); + } + } if (channels.includes('scoop')) { if (platform === 'win32') { plan.push({ diff --git a/cli/src/commands/capabilities.ts b/cli/src/commands/capabilities.ts new file mode 100644 index 0000000..c74db82 --- /dev/null +++ b/cli/src/commands/capabilities.ts @@ -0,0 +1,12 @@ +import { Command } from 'commander'; +import { collectRuntimeCapabilities } from '../utils/runtime-capabilities'; + +export function createCapabilitiesCommand(): Command { + return new Command('capabilities') + .description('Inspect optional runtime capabilities and safe fallback modes') + .option('--json', 'Emit compact machine-readable JSON') + .action(async (options: {json?: boolean}) => { + const evidence = await collectRuntimeCapabilities(); + process.stdout.write(`${JSON.stringify(evidence, null, options.json ? undefined : 2)}\n`); + }); +} diff --git a/cli/src/constants/api-constants.ts b/cli/src/constants/api-constants.ts index 37bb5ab..5810c04 100644 --- a/cli/src/constants/api-constants.ts +++ b/cli/src/constants/api-constants.ts @@ -4,8 +4,5 @@ export const API_CONSTANTS = { // Use npm registry instead of GitHub releases for version checking // This ensures we check against what's actually published and available to users VERSION_CHECK_URL: "https://registry.npmjs.org/guardscan/latest", - // Legacy API default used by explicit telemetry and health clients. - // Override with GUARDSCAN_TELEMETRY_URL for a self-hosted collector. - DEFAULT_API_BASE_URL: "https://api.guardscancli.com", VERSION_CACHE_HOURS: 24, } as const; diff --git a/cli/src/core/codebase-indexer.ts b/cli/src/core/codebase-indexer.ts index 8529838..5dd1977 100644 --- a/cli/src/core/codebase-indexer.ts +++ b/cli/src/core/codebase-indexer.ts @@ -8,6 +8,7 @@ import { ParsedClass, } from "./ast-parser"; import { configManager } from "./config"; +import { repositoryRelativePath } from "../utils/path-helper"; /** * Symbol information in the codebase @@ -360,7 +361,7 @@ export class CodebaseIndexer { const index = await this.loadIndex(); if (!index) {return null;} - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); return index.files.get(relativePath) || null; } @@ -390,7 +391,7 @@ export class CodebaseIndexer { filePath: string, index: CodebaseIndex ): Promise { - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); // Parse file const parsed = await this.getParsedFile(filePath); @@ -488,7 +489,7 @@ export class CodebaseIndexer { * Remove file from index */ private removeFileFromIndex(filePath: string, index: CodebaseIndex): void { - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); const fileIndex = index.files.get(relativePath); if (!fileIndex) {return;} diff --git a/cli/src/core/cost-guard.ts b/cli/src/core/cost-guard.ts index e023ab6..8031965 100644 --- a/cli/src/core/cost-guard.ts +++ b/cli/src/core/cost-guard.ts @@ -238,15 +238,6 @@ export class CostGuard { this.usage.exportToCSV(outputPath, days); } - /** - * Reset daily budget (for testing/admin) - */ - async resetDailyBudget(): Promise { - // This would require filtering records to keep only old ones - // For now, this is a placeholder - console.log('⚠️ Manual budget reset not yet implemented'); - } - /** * Clear all usage records */ diff --git a/cli/src/core/embedding-chunker.ts b/cli/src/core/embedding-chunker.ts index ea60d1b..98297c8 100644 --- a/cli/src/core/embedding-chunker.ts +++ b/cli/src/core/embedding-chunker.ts @@ -11,6 +11,7 @@ import fastGlob from 'fast-glob'; import { CodebaseIndexer, CodebaseIndex } from './codebase-indexer'; import { ParsedFunction, ParsedClass } from './ast-parser'; import { CodeChunk, EmbeddingMetadata, hashContent } from './embeddings'; +import { repositoryRelativePath } from '../utils/path-helper'; export interface ChunkingOptions { maxFunctionSize?: number; // Max chars for function chunks (default: 2000) @@ -110,7 +111,8 @@ export class EmbeddingChunker { continue; } - const content = this.formatFunctionForEmbedding(func); + const source = repositoryRelativePath(this.repoRoot, func.file); + const content = this.formatFunctionForEmbedding(func, source); // Skip if too large if (content.length > options.maxFunctionSize!) { @@ -131,7 +133,7 @@ export class EmbeddingChunker { tags: this.generateTags(func), lastModified: await this.getFileModificationTime(func.file), }, - source: func.file, + source, startLine: func.line, endLine: func.endLine, }); @@ -150,7 +152,8 @@ export class EmbeddingChunker { const chunks: CodeChunk[] = []; for (const [classId, cls] of index.classes) { - const content = this.formatClassForEmbedding(cls); + const source = repositoryRelativePath(this.repoRoot, cls.file); + const content = this.formatClassForEmbedding(cls, source); // Skip if too large if (content.length > options.maxClassSize!) { @@ -171,7 +174,7 @@ export class EmbeddingChunker { tags: this.generateClassTags(cls), lastModified: await this.getFileModificationTime(cls.file), }, - source: cls.file, + source, startLine: cls.line, endLine: cls.endLine, }); @@ -302,11 +305,11 @@ export class EmbeddingChunker { /** * Format function with context for better embeddings */ - private formatFunctionForEmbedding(func: ParsedFunction): string { + private formatFunctionForEmbedding(func: ParsedFunction, source: string): string { const parts: string[] = []; // File context - parts.push(`// File: ${func.file}`); + parts.push(`// File: ${source}`); parts.push(`// Function: ${func.name}`); // Documentation @@ -339,11 +342,11 @@ export class EmbeddingChunker { /** * Format class with context */ - private formatClassForEmbedding(cls: ParsedClass): string { + private formatClassForEmbedding(cls: ParsedClass, source: string): string { const parts: string[] = []; // File context - parts.push(`// File: ${cls.file}`); + parts.push(`// File: ${source}`); parts.push(`// Class: ${cls.name}`); // Documentation diff --git a/cli/src/core/loc-counter.ts b/cli/src/core/loc-counter.ts index b1c23b2..d1801f9 100644 --- a/cli/src/core/loc-counter.ts +++ b/cli/src/core/loc-counter.ts @@ -105,7 +105,9 @@ export class LOCCounter { '**/*.{js,jsx,ts,tsx,py,java,go,rs,c,cpp,h,hpp,cs,rb,php,swift,kt,scala,sh,bash}', ]; - const globPatterns = patterns || defaultPatterns; + const globPatterns = (patterns || defaultPatterns).map(pattern => + path.sep === '\\' ? pattern.replace(/\\/g, '/') : pattern + ); const files = await fastGlob(globPatterns, { cwd: process.cwd(), absolute: true, // Get absolute paths first @@ -115,7 +117,7 @@ export class LOCCounter { // Convert to relative paths and filter using ignore patterns const cwd = process.cwd(); return files - .map(file => path.relative(cwd, file)) + .map(file => path.relative(cwd, file).split(path.sep).join('/')) .filter(file => !this.ignoreMatcher.ignores(file)); } diff --git a/cli/src/core/telemetry.ts b/cli/src/core/telemetry.ts index bb76de4..a78ded3 100644 --- a/cli/src/core/telemetry.ts +++ b/cli/src/core/telemetry.ts @@ -3,11 +3,11 @@ import * as path from "path"; import * as crypto from "crypto"; import { v4 as uuidv4 } from "uuid"; import { - APIClient, + TelemetryClient, TelemetryAction, TelemetryEvent, TelemetryExecutionMode, -} from "../utils/api-client"; +} from "../utils/telemetry-client"; import { Config, configManager } from "./config"; import { TELEMETRY_CONSTANTS } from "../constants/telemetry-constants"; import { createDebugLogger } from "../utils/debug-logger"; @@ -147,11 +147,11 @@ export class TelemetryManager { async sync(): Promise<{ sent: number; remaining: number }> { this.assertSyncAllowed(); - const endpoint = - process.env.GUARDSCAN_TELEMETRY_URL || process.env.GUARDSCAN_API_URL; + const endpoint = configuredTelemetryEndpoint(); if (!endpoint) { throw new Error( - "Telemetry endpoint is not configured. Set GUARDSCAN_TELEMETRY_URL." + "Telemetry endpoint is not configured. " + + "Set GUARDSCAN_TELEMETRY_URL to your self-hosted collector URL before syncing." ); } @@ -169,7 +169,7 @@ export class TelemetryManager { if (events.length === 0) {return { sent: 0, remaining: 0 };} const batchId = uuidv4(); - const response = await new APIClient(endpoint).sendTelemetry({ + const response = await new TelemetryClient(endpoint).sendTelemetry({ schemaVersion: "guardscan.telemetry.v1", batchId, sentAt: Date.now(), @@ -225,9 +225,7 @@ export class TelemetryManager { return { enabled: this.config.telemetryEnabled, suppressed: isTelemetrySuppressed(this.config), - endpointConfigured: Boolean( - process.env.GUARDSCAN_TELEMETRY_URL || process.env.GUARDSCAN_API_URL - ), + endpointConfigured: configuredTelemetryEndpoint() !== undefined, pending, oldestEventAt: oldestEventAt === undefined ? undefined @@ -520,6 +518,11 @@ export class TelemetryManager { } +function configuredTelemetryEndpoint(): string | undefined { + const endpoint = process.env.GUARDSCAN_TELEMETRY_URL?.trim(); + return endpoint || undefined; +} + export function createTelemetryManager( config: Config, stateDir = configManager.getConfigDir(), diff --git a/cli/src/index.ts b/cli/src/index.ts index 311cbd5..4dc8999 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -51,6 +51,7 @@ async function main(): Promise { { createMetricsCommand }, { createCacheCommand }, { createTelemetryCommand }, + { createCapabilitiesCommand }, { createVulnerabilityCommand }, { checkForUpdates }, { displayLogo }, @@ -82,6 +83,7 @@ async function main(): Promise { import("./commands/metrics"), import("./commands/cache"), import("./commands/telemetry"), + import("./commands/capabilities"), import("./commands/vuln"), import("./utils/version"), import("./utils/ascii-art"), @@ -415,6 +417,7 @@ async function main(): Promise { program.addCommand(createMetricsCommand()); program.addCommand(createCacheCommand()); program.addCommand(createTelemetryCommand()); + program.addCommand(createCapabilitiesCommand()); program.addCommand(createVulnerabilityCommand()); if ( diff --git a/cli/src/utils/path-helper.ts b/cli/src/utils/path-helper.ts index 9d1f60d..e47dd08 100644 --- a/cli/src/utils/path-helper.ts +++ b/cli/src/utils/path-helper.ts @@ -2,6 +2,16 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; +/** + * Return a stable repository-relative path for persisted data and user output. + */ +export function repositoryRelativePath(repoRoot: string, filePath: string): string { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(repoRoot, filePath); + return path.relative(repoRoot, absolutePath).replace(/\\/g, '/'); +} + /** * Get home directory with fallbacks for containerized environments * Handles Alpine Docker and other edge cases where os.homedir() may fail diff --git a/cli/src/utils/runtime-capabilities.ts b/cli/src/utils/runtime-capabilities.ts new file mode 100644 index 0000000..16f8433 --- /dev/null +++ b/cli/src/utils/runtime-capabilities.ts @@ -0,0 +1,95 @@ +import { AccurateTokenCounter, TokenCountResult } from '../providers/token-counter'; + +export const RUNTIME_CAPABILITY_SCHEMA = 'guardscan.runtime-capabilities.v1'; +const TOKEN_PROBE_TEXT = 'GuardScan standalone optional capability probe'; +const TOKEN_PROBE_MODEL = 'gpt-4o'; + +interface TokenCounterProbe { + countTokens(text: string, model: string): TokenCountResult; + getStatus(): {tiktokenAvailable: boolean}; + cleanup(): void; +} + +export interface RuntimeCapabilityProbeOptions { + createTokenCounter?: () => TokenCounterProbe; + loadChartRenderer?: () => Promise; +} + +export interface RuntimeCapabilityEvidence { + schemaVersion: typeof RUNTIME_CAPABILITY_SCHEMA; + tokenCounting: { + dependency: 'tiktoken'; + dependencyAvailable: boolean; + mode: TokenCountResult['method']; + sampleTokenCount: number; + safeFallbackObserved: boolean; + }; + chartRendering: { + dependency: 'chartjs-node-canvas'; + dependencyAvailable: boolean; + mode: 'native' | 'unavailable'; + safeFallbackObserved: boolean; + }; +} + +function isMissingChartDependency(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') + && error.message.includes('chartjs-node-canvas'); +} + +/** + * Exercise optional runtime boundaries and report what this exact executable can do. + * Unexpected initialization failures are not relabeled as a supported reduced mode. + */ +export async function collectRuntimeCapabilities( + options: RuntimeCapabilityProbeOptions = {} +): Promise { + const counter = options.createTokenCounter?.() ?? new AccurateTokenCounter(); + let tokenResult: TokenCountResult; + let tiktokenAvailable: boolean; + try { + tokenResult = counter.countTokens(TOKEN_PROBE_TEXT, TOKEN_PROBE_MODEL); + tiktokenAvailable = counter.getStatus().tiktokenAvailable; + } finally { + counter.cleanup(); + } + + let chartRendering: RuntimeCapabilityEvidence['chartRendering']; + try { + const loadChartRenderer = options.loadChartRenderer + ?? (() => import('./chart-generator')); + await loadChartRenderer(); + chartRendering = { + dependency: 'chartjs-node-canvas', + dependencyAvailable: true, + mode: 'native', + safeFallbackObserved: false, + }; + } catch (error) { + if (!isMissingChartDependency(error)) { + throw error; + } + chartRendering = { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }; + } + + return { + schemaVersion: RUNTIME_CAPABILITY_SCHEMA, + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: tiktokenAvailable, + mode: tokenResult.method, + sampleTokenCount: tokenResult.count, + safeFallbackObserved: tokenResult.method === 'estimated', + }, + chartRendering, + }; +} diff --git a/cli/src/utils/api-client.ts b/cli/src/utils/telemetry-client.ts similarity index 91% rename from cli/src/utils/api-client.ts rename to cli/src/utils/telemetry-client.ts index d086dbe..a7c84d5 100644 --- a/cli/src/utils/api-client.ts +++ b/cli/src/utils/telemetry-client.ts @@ -43,17 +43,12 @@ export class TelemetryDeliveryError extends Error { } } -export class APIClient { +export class TelemetryClient { private client?: AxiosInstance; private baseUrl: string; - constructor(baseUrl?: string) { - this.baseUrl = ( - baseUrl || - process.env.GUARDSCAN_TELEMETRY_URL || - process.env.GUARDSCAN_API_URL || - "" - ).replace(/\/+$/, ""); + constructor(baseUrl: string) { + this.baseUrl = baseUrl.trim().replace(/\/+$/, ""); if (this.baseUrl) { this.validateBaseUrl(this.baseUrl); @@ -178,18 +173,6 @@ export class APIClient { } } - async ping(): Promise { - if (!this.client) { - return false; - } - try { - await this.client.get("/health", { timeout: 3000 }); - return true; - } catch { - return false; - } - } - getBaseUrl(): string { return this.baseUrl; } @@ -226,6 +209,3 @@ export class APIClient { } } } - -/** @deprecated Construct APIClient with the explicit telemetry URL instead. */ -export const apiClient = new APIClient(); diff --git a/docs/AI_QUICK_REFERENCE.md b/docs/AI_QUICK_REFERENCE.md index 681e08c..d280f6d 100644 --- a/docs/AI_QUICK_REFERENCE.md +++ b/docs/AI_QUICK_REFERENCE.md @@ -1,192 +1,76 @@ # AI Quick Reference -## Essential Commands +GuardScan is BYOK: configure a cloud provider API key or a local provider, then run AI-assisted commands. Advanced nested settings are edited directly in `~/.guardscan/config.yml`. -### Setup +## Setup ```bash -# View available models -guardscan models list - -# Get model info -guardscan models info gpt-4o - -# Set your model -guardscan config set model gpt-4o +guardscan config +guardscan config --provider openai --key "$OPENAI_API_KEY" +guardscan config --provider ollama +guardscan config --telemetry=false +guardscan config --offline=true ``` -### Model Routing +## Core AI Workflows ```bash -# List current routing -guardscan routing list - -# Set task-specific model -guardscan routing set code-review --model gpt-4o -guardscan routing set chat --priority speed - -# Test routing -guardscan routing test code-review +guardscan run --with-ai +guardscan review --base main +guardscan chat +guardscan explain src/index.ts --type file +guardscan test-gen --file src/index.ts +guardscan docs --type architecture ``` -### Budget Management +## Model And Budget Tools ```bash -# Check budget status +guardscan models list +guardscan models info gpt-4o +guardscan routing list +guardscan routing set code-review --model gpt-4o guardscan budget status - -# Set budget limits guardscan budget set --daily 10 --monthly 100 - -# View usage report -guardscan budget report --days 30 -``` - -### Monitoring - -```bash -# View metrics guardscan metrics show --days 7 - -# Check cache performance guardscan cache stats - -# Export metrics -guardscan metrics export --output metrics.json -``` - -## Configuration Cheatsheet - -### Enable All Features (Recommended) - -```bash -guardscan config set retry.enabled true -guardscan config set cache.enabled true -guardscan config set circuitBreaker.enabled true -guardscan config set observability.enabled true -guardscan config set modelRouting.enabled true -guardscan budget set --daily 10 --monthly 100 +guardscan cache clear --repo --force ``` -### Cost-Optimized - -```bash -guardscan config set cache.enabled true -guardscan config set cache.semanticThreshold 0.9 -guardscan config set modelRouting.enabled true -guardscan config set modelRouting.strategy cost -guardscan routing set chat --model gpt-4.1-mini +## Advanced Settings + +Edit `~/.guardscan/config.yml` for nested settings: + +```yaml +cache: + enabled: true + semanticThreshold: 0.95 + maxSizeMB: 100 + ttlSeconds: 3600 +modelRouting: + enabled: true + strategy: balanced +observability: + enabled: true ``` -### Quality-First +## CI Mode ```bash -guardscan config set modelRouting.strategy quality -guardscan routing set code-review --model gpt-4o -guardscan routing set explanation --model claude-sonnet-4.5 +guardscan --no-telemetry scan --ci --offline --format json --output guardscan-results.json --fail-on critical +guardscan --no-telemetry security --ci --format sarif --output guardscan.sarif --max-findings 50 ``` -### Development (Free) - -```bash -guardscan config set provider ollama -guardscan config set model codellama -# All requests free, local, private -``` - -## Feature Quick Reference - -| Feature | Enable With | Check With | -| --------------- | -------------------------------------------------- | ------------------------- | -| Retry | `guardscan config set retry.enabled true` | `guardscan metrics show` | -| Caching | `guardscan config set cache.enabled true` | `guardscan cache stats` | -| Circuit Breaker | `guardscan config set circuitBreaker.enabled true` | `guardscan metrics show` | -| Rate Limiting | `guardscan config set rateLimit.enabled true` | Check wait times | -| Observability | `guardscan config set observability.enabled true` | `guardscan metrics show` | -| Model Routing | `guardscan config set modelRouting.enabled true` | `guardscan routing list` | -| Budgets | `guardscan budget set --daily ` | `guardscan budget status` | - -## Troubleshooting - -| Problem | Solution | -| -------------------- | ------------------------------------------------------------------- | -| Budget exceeded | `guardscan budget set --daily ` | -| Circuit breaker open | Wait 60s or check provider status | -| Low cache hit rate | Lower threshold: `guardscan config set cache.semanticThreshold 0.9` | -| High costs | Enable caching + routing with cost strategy | -| Slow performance | Use faster models: `guardscan routing set chat --priority speed` | -| Rate limit errors | Enable rate limiting: `guardscan config set rateLimit.enabled true` | - -## Cost Optimization Quick Wins - -1. **Enable caching** (30-50% savings): - ```bash - guardscan config set cache.enabled true - ``` - -2. **Use cheap models for simple tasks**: - ```bash - guardscan routing set chat --model gpt-4.1-mini - ``` - -3. **Enable smart routing**: - ```bash - guardscan config set modelRouting.enabled true - guardscan config set modelRouting.strategy cost - ``` - -4. **Monitor and optimize**: - ```bash - guardscan budget report --days 7 - guardscan cache stats - ``` - -## Model Comparison - -### By Cost (Per 1M tokens) - -| Model | Input | Output | Best For | -| --------------------- | ------ | ------- | -------------------------- | -| gpt-4.1-mini | $0.15 | $0.60 | Chat, simple tasks | -| gemini-2.5-flash-lite | $37.50 | $150 | Fast, balanced tasks | -| gemini-2.5-flash | $75 | $300 | Code generation | -| gpt-4o | $2,500 | $10,000 | Code review, quality tasks | -| claude-sonnet-4.5 | $3,000 | $15,000 | Reasoning, explanation | - -### By Context Window - -| Model | Context | Best For | -| -------------------- | ----------- | ------------------ | -| gemini-3-pro | 2M tokens | Large codebases | -| gemini-2.5-pro/flash | 1M tokens | Large files | -| gpt-4o | 128k tokens | Most use cases | -| claude models | 200k tokens | Long conversations | - -## Default Routing - -| Task | Priority | Typical Selection | -| --------------- | -------- | ------------------------------- | -| code-review | quality | gpt-4o, claude-sonnet-4.5 | -| code-generation | balanced | gemini-2.5-flash, gpt-4o | -| chat | speed | gpt-4.1-mini, gemini-flash-lite | -| explanation | quality | claude models, gpt-4o | -| refactoring | balanced | gpt-4o, gemini-2.5-flash | -| test-generation | balanced | gemini-2.5-flash, gpt-4o | - -## Performance Targets - -| Metric | Target | Command | -| -------------- | ------------- | ------------------------- | -| Success Rate | >99.5% | `guardscan metrics show` | -| Cache Hit Rate | >40% | `guardscan cache stats` | -| P95 Latency | <2000ms | `guardscan metrics show` | -| Daily Budget | Within limits | `guardscan budget status` | +## Privacy Controls -## Support +- `--no-telemetry` disables analytics for one command. +- `--no-cache` disables AI response caching for one command. +- `guardscan config --telemetry=false` persists telemetry opt-out and deletes queued events. +- `guardscan config --offline=true` blocks cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. +- `guardscan telemetry status` shows consent and local queue state; `guardscan telemetry sync` is the only delivery action and requires a user-operated collector selected with `GUARDSCAN_TELEMETRY_URL`. +- `guardscan cache clear --repo --force` clears the current repository, while `--all` clears every repository cache. -- **Full Documentation**: See `docs/` folder -- **Architecture**: [AI_ARCHITECTURE.md](./AI_ARCHITECTURE.md) -- **Reliability**: [AI_RELIABILITY.md](./AI_RELIABILITY.md) -- **Cost**: [COST_OPTIMIZATION.md](./COST_OPTIMIZATION.md) -- **Models**: [MODEL_MANAGEMENT.md](./MODEL_MANAGEMENT.md) -- **Migration**: [MIGRATION_ENHANCED_AI.md](./MIGRATION_ENHANCED_AI.md) +Telemetry is disabled by default. If enabled, it queues an aggregate allowlisted +event locally while online; it never includes source, paths, prompts, responses, +findings, or errors. GuardScan operates no hosted telemetry collector or default +endpoint. diff --git a/docs/API.md b/docs/API.md index c12cc4a..3bfb9c9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,280 +1,78 @@ -# API Documentation +# Self-Hosted Telemetry Collector Protocol and CLI Output -Backend API documentation for GuardScan. +GuardScan is local-first and BYOK. Built-in scanners do not send source code to +GuardScan services. Cloud AI requests go directly to the provider selected by +the user. -## Base URL +## Optional self-hosted telemetry -``` -Production: https://api.guardscancli.com -Development: http://localhost:8787 -``` - -## Authentication - -Currently, authentication is done via `client_id` passed in request bodies. No API keys required for basic usage. - -## Endpoints - -### Health Check +GuardScan operates no hosted telemetry collector and provides no default +endpoint. The former service at `api.guardscancli.com` and the legacy +`GUARDSCAN_API_URL` setting are retired. Operators may implement this protocol +at a user-controlled HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL`. -Check API status. +Telemetry is anonymous, opt-in, and delivered only by an explicit +`guardscan telemetry sync`. Recording and delivery are suppressed when +telemetry consent is disabled, persistent offline mode is enabled, +`GUARDSCAN_OFFLINE=true`, `GUARDSCAN_NO_TELEMETRY=true`, `--offline`, or +`--no-telemetry` applies. Events otherwise remain in the local queue. -**Endpoint:** `GET /health` +### Collector endpoint -**Response:** - -```json -{ - "status": "ok", - "timestamp": "2024-01-15T10:30:00Z" -} +```text +POST /api/telemetry ``` ---- - -### Validate Credits - -Validate if client has sufficient credits for a review. +This is a relative self-hosted protocol path, not a GuardScan-operated URL. -**Endpoint:** `POST /api/validate` - -**Request Body:** - -```json -{ - "clientId": "uuid-here", - "repoId": "hashed-repo-id", - "locCount": 1250 -} -``` - -**Response:** +### Request: `guardscan.telemetry.v1` ```json { - "allowed": true, - "remainingLoc": 8750 -} -``` - -**Status Codes:** -- `200`: Success -- `400`: Invalid request -- `500`: Server error - ---- - -### Submit Telemetry - -Submit anonymized telemetry data. - -**Endpoint:** `POST /api/telemetry` - -**Request Body:** - -```json -{ - "clientId": "uuid-here", - "repoId": "hashed-repo-id", + "schemaVersion": "guardscan.telemetry.v1", + "batchId": "00000000-0000-4000-8000-000000000001", + "sentAt": 1705320001000, + "cliVersion": "1.1.0", "events": [ { + "eventId": "00000000-0000-4000-8000-000000000002", "action": "review", "loc": 350, "durationMs": 2100, - "model": "gpt-4", - "timestamp": 1705320000000 + "executionMode": "cloud-ai", + "occurredAt": 1705320000000 } ] } ``` -**Response:** - -```json -{ - "status": "ok" -} -``` - -**Status Codes:** -- `200`: Success -- `400`: Invalid request -- `500`: Server error - ---- - -### Get Credits - -Get remaining credit balance for a client. - -**Endpoint:** `GET /api/credits/:clientId` - -**Parameters:** -- `clientId` (path): Client UUID - -**Response:** - -```json -{ - "clientId": "uuid-here", - "remainingLoc": 5000, - "plan": "tier_2" -} -``` - -**Status Codes:** -- `200`: Success -- `404`: Client not found -- `500`: Server error - ---- - -### Stripe Webhook - -Handle Stripe payment webhooks. - -**Endpoint:** `POST /api/stripe-webhook` - -**Headers:** -- `stripe-signature`: Webhook signature for verification - -**Events Handled:** -- `checkout.session.completed`: Credit purchase completed -- `invoice.payment_failed`: Payment failed - -**Response:** - -```json -{ - "received": true -} -``` - -**Status Codes:** -- `200`: Success -- `400`: Invalid signature -- `500`: Server error - ---- - -## Rate Limiting - -Currently no rate limiting is implemented. Consider adding rate limiting in production: - -- 100 requests per minute per client_id -- 1000 requests per hour per IP +The payload never includes installation or repository identifiers, source, paths, prompts, responses, findings, model names, errors, dependency names, or arbitrary metadata. -## Error Responses - -All errors return JSON in this format: +### Response ```json { - "error": "Error message here" + "status": "accepted", + "batchId": "00000000-0000-4000-8000-000000000001", + "accepted": 1, + "acceptedEventIds": ["00000000-0000-4000-8000-000000000002"] } ``` -## CORS - -CORS is enabled for all origins (`*`). Restrict in production if needed. - -## Webhook Security +`status` is `accepted` or `duplicate`. `eventId` is the canonical idempotency +key across batches. A partial `accepted` acknowledgement must list exactly the +accepted requested IDs. A `duplicate` acknowledgement is valid only when it +covers the complete batch; when IDs are included they must equal the complete +requested set. Inconsistent, unknown, failed, or unacknowledged events stay +local. -### Stripe Webhooks +## Local CLI output -Stripe webhooks are verified using the signature from the `stripe-signature` header. Always verify signatures before processing events. - -Example verification: - -```typescript -const signature = request.headers.get('stripe-signature'); -const event = stripe.webhooks.constructEvent( - body, - signature, - webhookSecret -); -``` - -## Data Privacy - -- No source code is transmitted or stored -- Repository IDs are cryptographically hashed -- Client IDs are UUIDs with no PII -- Telemetry is anonymized and aggregated - -## Client Libraries - -### JavaScript/TypeScript - -```typescript -import { APIClient } from 'guardscan'; - -const client = new APIClient('https://api.guardscancli.com'); - -// Validate credits -const validation = await client.validate({ - clientId: 'uuid', - repoId: 'hash', - locCount: 1000, -}); - -// Get credits -const credits = await client.getCredits('uuid'); - -// Send telemetry -await client.sendTelemetry({ - clientId: 'uuid', - repoId: 'hash', - events: [...], -}); -``` - -### cURL Examples - -**Validate Credits:** +Use CI mode for machine-readable scan output: ```bash -curl -X POST https://api.guardscancli.com/api/validate \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "uuid", - "repoId": "hash", - "locCount": 1000 - }' +guardscan --no-telemetry scan --ci --offline --format json --output guardscan-results.json +guardscan --no-telemetry security --ci --format sarif --output guardscan.sarif ``` -**Get Credits:** - -```bash -curl https://api.guardscancli.com/api/credits/uuid -``` - -**Submit Telemetry:** - -```bash -curl -X POST https://api.guardscancli.com/api/telemetry \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "uuid", - "repoId": "hash", - "events": [ - { - "action": "review", - "loc": 350, - "durationMs": 2100, - "model": "gpt-4", - "timestamp": 1705320000000 - } - ] - }' -``` - -## Status Monitoring - -Monitor API status at: https://status.guardscancli.com (if implemented) - -## Support - -For API issues or questions: -- GitHub Issues: https://github.com/ntanwir10/GuardScan/issues -- Email: api-support@guardscancli.com +The JSON report schema is `guardscan.scan.v1`. SARIF output uses SARIF 2.1.0. diff --git a/docs/DOCKER_GUIDE.md b/docs/DOCKER_GUIDE.md index d92b139..129aca2 100644 --- a/docs/DOCKER_GUIDE.md +++ b/docs/DOCKER_GUIDE.md @@ -49,7 +49,7 @@ This comprehensive guide covers running GuardScan CLI in Docker across all major ### General Requirements - Docker Engine 20.10+ or Docker Desktop 4.0+ -- Node.js 18+ (in container) +- Node.js 22+ (in container) - 2GB+ RAM available for Docker - Internet connection (for npm install and AI features) @@ -916,19 +916,26 @@ guardscan init # Will show detailed logging docker run -e GUARDSCAN_DEBUG=true node:lts-alpine guardscan init ``` -#### `GUARDSCAN_API_URL` +#### `GUARDSCAN_TELEMETRY_URL` -Override the default backend API URL (for self-hosting or testing). +Select a user-operated telemetry collector. GuardScan has no hosted or default +collector, and telemetry is sent only by an explicit `guardscan telemetry sync`. ```bash -export GUARDSCAN_API_URL=https://custom-api.example.com +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com ``` +`GUARDSCAN_API_URL` and `api.guardscancli.com` are retired. + #### `GUARDSCAN_NO_TELEMETRY` -Disable telemetry for the current command execution (set via `--no-telemetry` flag). +Disable telemetry for the current command execution with either the environment +variable or the `--no-telemetry` flag. -**Note:** This is handled via the `--no-telemetry` CLI flag, not an environment variable. +```bash +export GUARDSCAN_NO_TELEMETRY=true +guardscan security +``` ### OS-Specific Environment Variables @@ -1237,7 +1244,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '22' - name: Install GuardScan run: npm install -g guardscan @@ -1266,7 +1273,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '22' - name: Install GuardScan run: npm install -g guardscan @@ -1741,9 +1748,14 @@ apk add --no-cache \ ```bash mkdir -p /tmp/guardscan - chmod 755 /tmp/guardscan + chmod 700 /tmp/guardscan ``` + `GUARDSCAN_HOME` must be an absolute directory private to the container + user. Do not share the same writable state directory between different + users or containers. Mount a user-owned volume when state must survive the + container lifecycle. + ### General Troubleshooting #### Container Exits Immediately @@ -2183,7 +2195,7 @@ spec: - [Main README](../README.md) - [Getting Started Guide](GETTING_STARTED.md) -- [Alpine Linux Quick Reference](../DOCKER_ALPINE_GUIDE.md) +- [Alpine Linux Quick Reference](./DOCKER_ALPINE_GUIDE.md) - [Issue #25 - Alpine Linux Fix](https://github.com/ntanwir10/GuardScan/issues/25) - [Docker Documentation](https://docs.docker.com/) - [Docker Compose Documentation](https://docs.docker.com/compose/) diff --git a/docs/FUNCTIONAL_ACCEPTANCE.md b/docs/FUNCTIONAL_ACCEPTANCE.md new file mode 100644 index 0000000..7d0a1b1 --- /dev/null +++ b/docs/FUNCTIONAL_ACCEPTANCE.md @@ -0,0 +1,119 @@ +# GuardScan functional acceptance + +This document is the launch evidence map for GuardScan `1.1.0`. It separates +implemented behavior from behavior proven in the exact artifact that users will +install. A command appearing in help, compiling successfully, or passing a mock +does not by itself make that command production-verified. + +The release ledger is authoritative for a version. Evidence is valid only when +it names the source commit, tag, artifact digest, platform, runtime, test, and +time. Every current-source change invalidates older exact-artifact evidence until +the required jobs rerun against the new commit. + +The current launch posture is **closed**: `RELEASE_AUTOMATION_ENABLED` remains +`false` until onboarding is complete. No `1.1.0-rc.1` soak or `1.1.0` public +verification is implied by this matrix. + +## Evidence classes + +Each command has one primary acceptance class. The class describes the strongest +required proof shape, while the evidence and gate columns state what is and is +not proved today. + +- **Offline exact-artifact proven**: a test invokes the packed npm tarball or SEA + executable in an isolated home with network/telemetry disabled. For SEA, Node + and package managers are absent from `PATH`. +- **Component/integration proven**: real GuardScan code is exercised from source + or compiled output, but the behavior is not yet invoked through every public + distribution artifact. +- **External-tool dependent**: GuardScan orchestration and safety boundaries can + be tested, but the result also depends on a project-owned tool such as a test + runner, linter, Lighthouse, k6, or mutation framework. +- **Mocked BYOK boundary**: prompt construction, routing, redaction, parsing, and + failure behavior are tested with deterministic fake providers. This does not + prove real credentials, quotas, endpoint compatibility, or model behavior. +- **Live provider rehearsal**: bounded, account-scoped evidence from a real + provider is required. Secrets and response content must not enter artifacts, + logs, manifests, telemetry, or the release ledger. + +## Command acceptance matrix + +| Command | Primary evidence class | Current strongest repository evidence | Install/runtime variants in scope | Current launch gate | +| --- | --- | --- | --- | --- | +| `guardscan init` | Offline exact-artifact proven | Packed npm smoke invokes `init` and verifies provider `none`, offline mode, telemetry disabled, and no client ID; compiled command and config-lifecycle tests cover updates. | npm global on Node 22/24; npm consumers inherit the same tarball. | Rerun the exact candidate tarball on Linux, macOS, and Windows and record its digest-bound result. | +| `guardscan run` | Component/integration proven | Command tests execute the static path, the opt-in AI path, exclusion handling, reports, and safe failure boundaries. | Node package and embedded-runtime channels; AI behavior varies by configured provider. | Add packed/SEA command invocation; separately complete live BYOK and local-provider rehearsals for `--with-ai`. | +| `guardscan scan` | Offline exact-artifact proven | Packed npm, npm/pnpm/Yarn/Bun consumer smoke, compiled end-to-end tests, and SEA smoke exercise offline static scanning and validate `guardscan.scan.v1`. | npm global; pnpm, Yarn Classic/Modern, Bun; all five SEA/wheel targets. | Produce current-tag artifacts, rerun every native/public canary, and bind reports to the release manifest. | +| `guardscan security` | Component/integration proven | Compiled end-to-end tests cover JSON, SARIF, finding-policy exits, partial inventory, and static-safe execution; Docker fixtures exercise installed source builds. | Node package and embedded-runtime channels. | Invoke the exact candidate tarball and each native artifact; retain malicious-input and policy evidence. | +| `guardscan test` | External-tool dependent | Test-runner and process-policy tests cover framework detection, argv-safe execution, timeouts, and failure reporting. | Requires compatible project test/lint commands in the host environment; standalone does not bundle project tools. | Rehearse supported project matrices on each OS and prove missing/hostile tools fail safely. | +| `guardscan sbom` | Offline exact-artifact proven | Packed npm and SEA smoke generate and parse SPDX 2.3 and CycloneDX 1.7; command tests cover deterministic local inventory. | npm/package-manager consumers and all five SEA/wheel targets. | Rerun exact candidate artifacts on all hosts and verify output schemas and manifest-bound artifact SBOMs. | +| `guardscan perf` | External-tool dependent | Load-test components exercise collection and reporting, but host tools and target services remain outside GuardScan's artifact. | Node or embedded runtime plus project-selected performance tools. | Add native command rehearsals with pinned k6/Lighthouse fixtures and explicit unavailable-tool behavior. | +| `guardscan mutation` | External-tool dependent | Mutation components test framework selection, argv-safe process execution, failure handling, and injection resistance. | Node or embedded runtime plus a supported mutation framework. | Run deterministic Stryker, mutmut, PIT, and custom-command fixtures where supported; document unsupported platform results. | +| `guardscan rules` | Component/integration proven | The compiled command/help contract exists; rule evaluation is exercised indirectly by scan-engine tests. Direct command execution is not yet an acceptance proof. | All runtime channels use the same compiled rule implementation. | Add direct list/enable/disable/custom-rule lifecycle tests, then invoke the exact npm and native artifacts. | +| `guardscan config` | Component/integration proven | Direct command and isolated config-lifecycle tests cover show/update, privacy defaults, provider changes, and secret-safe output. | All channels; state is isolated under `GUARDSCAN_HOME`. | Add exact-artifact lifecycle coverage on POSIX and Windows, including corrupt/unreadable configuration. | +| `guardscan status` | Component/integration proven | Compiled command surface and configuration/provider components are covered; direct installed-command state reporting is not yet proved. | All channels, with Node/SEA capability differences. | Add deterministic configured/unconfigured/degraded fixtures and exact npm/SEA invocation. | +| `guardscan reset` | Component/integration proven | Configuration reset behavior is covered through lifecycle components; installed CLI reset and cancellation are not yet separately proved. | All channels; mutates only the selected GuardScan home. | Add isolated confirmation/force tests on POSIX and Windows and prove unrelated files are retained. | +| `guardscan commit` | Mocked BYOK boundary | Provider factories, routing, cost guards, retries, and output parsers have deterministic tests; the command has no live-provider release evidence. | Node and embedded runtime with a user-selected BYOK or local provider. | Add direct command fixtures, then run bounded live rehearsals without committing or logging generated content. | +| `guardscan explain` | Mocked BYOK boundary | Code-explainer tests exercise prompt construction, provider output, file/range handling, and failure behavior with fakes. | Node and embedded runtime with BYOK/local provider access. | Invoke packaged artifacts and complete one bounded rehearsal per supported provider family. | +| `guardscan test-gen` | Mocked BYOK boundary | Compiled help and shared provider boundaries are covered; generated-test correctness is not currently command-level acceptance evidence. | Node and embedded runtime with BYOK/local provider access. | Add deterministic file/language/output fixtures, sandbox generated tests, and complete bounded provider rehearsals. | +| `guardscan docs` | Mocked BYOK boundary | Compiled help and shared provider boundaries are covered; direct documentation-generation behavior lacks a dedicated acceptance fixture. | Node and embedded runtime with BYOK/local provider access. | Add deterministic type/output/path tests, exact-artifact invocation, and bounded provider rehearsals. | +| `guardscan chat` | Live provider rehearsal | Deterministic RAG integration uses fake embeddings/provider responses and proves local retrieval boundaries; it does not prove a real provider session. | Node and embedded runtime; cloud BYOK and local Ollama/LM Studio are separate variants. | Rehearse one bounded conversation per supported provider family, including offline/local operation and signal-safe cancellation. | +| `guardscan refactor` | Mocked BYOK boundary | Refactoring feature tests cover parsing, suggestions, file selection, and malformed provider output using fakes. | Node and embedded runtime with BYOK/local provider access. | Invoke exact artifacts, prove no unapproved file writes, and complete bounded provider rehearsals. | +| `guardscan threat-model` | Mocked BYOK boundary | Compiled help and shared provider/security components are covered; no dedicated command acceptance fixture proves the final threat model. | Node and embedded runtime with BYOK/local provider access. | Add deterministic input/output/redaction fixtures and bounded provider rehearsals. | +| `guardscan migrate` | Mocked BYOK boundary | Compiled help and shared provider/process boundaries are covered; migration correctness is not directly proved. | Node and embedded runtime with BYOK/local provider access and project tooling. | Add dry-run, diff, rollback, and unsupported-language fixtures before any live-provider rehearsal. | +| `guardscan review` | Mocked BYOK boundary | Compiled option syntax and shared scan/provider components are tested; direct review-output acceptance remains incomplete. | Node and embedded runtime with BYOK/local provider access. | Add deterministic file/diff/report fixtures, exact-artifact invocation, and bounded provider rehearsals. | +| `guardscan models` | Component/integration proven | Model-registry, provider-factory, validation, redirect, and token-counter tests cover known model metadata without requiring a live completion. | All runtime channels; availability still depends on the configured provider. | Add exact-artifact list/validation tests and a non-secret live availability probe for each enabled provider family. | +| `guardscan routing` | Component/integration proven | Model-router and decorator tests cover deterministic selection, fallbacks, rate limits, retries, circuits, and observability. | Node and embedded runtime with one or more configured providers. | Add direct command fixtures and live failover rehearsal without exposing prompts or credentials. | +| `guardscan budget` | Component/integration proven | Direct command and cost-guard tests cover limits, usage display, reset, and provider/model accounting. | All channels; local state is scoped to the selected GuardScan home. | Add exact npm/SEA lifecycle tests, clock-boundary coverage, and concurrent-write evidence. | +| `guardscan metrics` | Component/integration proven | Metrics-collector tests cover collection and serialization; the installed command output is not yet an exact-artifact proof. | All channels; local-only unless a user explicitly exports data. | Add exact-artifact status/export fixtures and prove sensitive source/prompt content is excluded. | +| `guardscan cache` | Component/integration proven | Direct cache tests and compiled end-to-end execution cover clear/select/all behavior and prove the telemetry spool is retained. | All channels; paths differ on POSIX and Windows. | Invoke the exact candidate npm/SEA artifacts on all hosts and exercise corrupt, locked, and concurrent entries. | +| `guardscan telemetry` | Offline exact-artifact proven | Packed npm, package-manager consumer, compiled end-to-end, and SEA smoke prove disabled consent/status; unit and contract tests cover queue/delivery boundaries. | All channels; optional synchronization targets the configured service only after consent. | Reprove disabled status for exact artifacts and separately rehearse opt-in HTTPS delivery to a user-authorized endpoint. | +| `guardscan capabilities` | Offline exact-artifact proven | Unit/compiled JSON contracts and the SEA exact-executable smoke contract verify the explicit reduced profile. The current-source five-host SEA run is still required. | Node package reports full/available runtime features; SEA and wheel report `coreScan`/`sbom` true and chart/token extras false. | Pass the current SHA on all five SEA hosts and verify wheels forward byte-identical output. | +| `guardscan vuln` | Component/integration proven | Direct command, OSV client, dependency inventory, CISA KEV, and compiled end-to-end tests cover exact versions, offline snapshot behavior, fail-closed exit 2, and aliases `cve`/`audit`. | All channels; online OSV and offline signed-snapshot modes are distinct. | Invoke exact artifacts; verify signed snapshot refresh/expiry and run a bounded live OSV canary with recorded response metadata only. | + +## Major workflow acceptance + +| Workflow | Required evidence | Current repository evidence | Launch gate | +| --- | --- | --- | --- | +| Privacy-first initialization | Exact npm artifact on each supported Node/OS pair | Package smoke asserts provider `none`, offline mode, telemetry disabled, and no generated client ID. | Candidate tarball must pass the three-OS, Node 22/24 matrix. | +| Offline scan and policy reporting | Exact npm consumers and all five SEA targets | Package-manager, compiled CLI, and standalone smoke contracts validate static execution, schema, and policy. | Current-tag signed artifacts and public-install canaries must pass. | +| Project-code execution | Component plus hostile-tool integration | Process runner, execution policy, test runner, mutation, and injection tests use argv arrays and constrained policies. | Native fixtures must prove opt-in execution, timeouts, cancellation, and safe failure. | +| Vulnerability intelligence | Component, signed offline snapshot, and bounded live OSV | Inventory/OSV/KEV tests and offline fail-closed CLI behavior exist. | Publish/verify the snapshot chain and run a public OSV canary without persisting dependency names beyond evidence policy. | +| AI-assisted commands | Mocked boundary plus bounded live BYOK/local-provider rehearsal | Factories, routers, decorators, cost controls, explanation/refactor, and RAG tests are deterministic. | OpenAI, Anthropic, Gemini, OpenRouter, Ollama, and LM Studio must each be explicitly supported, skipped with reason, or removed from the release claim. | +| Telemetry | Exact disabled path plus opt-in delivery boundary | Exact artifacts prove disabled status; queue, redaction, endpoint, and collector contracts are tested. | Run an authorized live opt-in delivery rehearsal and prove opt-out leaves no queued/sent event. | +| npm ecosystem | Exact tested tarball, then public-registry install | Three-OS Node 22/24 npm smoke and Linux npm/pnpm/Yarn/Bun consumer contracts are encoded in CI. | Publish RC with OIDC/provenance, redownload it, compare digest, and run public `next` canaries for 24 hours. | +| Native GitHub release | Signed/notarized exact executable and immutable redownload | Five-host unsigned SEA build/smoke, deterministic archive, manifest, checksum, SBOM, signature, and attestation contracts exist. | Provider signing must succeed, every draft asset must redownload identically, and the release must become immutable. | +| PyPI/pip/pipx | Wheel containing the exact signed SEA executable | Wheel construction, platform tags, launcher integrity, and release-manifest binding have contract tests. | TestPyPI then PyPI OIDC publication and pip/pipx install/invoke/uninstall must pass on all supported hosts. | +| Shared Homebrew/Scoop catalog | Generated projection bound to the immutable release manifest | Empty bootstrap and atomic formula/manifest/lock generation and validation are implemented. | The first stable catalog PR must pass native install/upgrade/invoke/uninstall and GuardScan must verify the merged lock callback or reconciliation result. | +| WinGet | Generated portable manifest plus upstream state | Rendering, validation commands, and append-only submitted/accepted/verified states are implemented. | Local manifest install must pass, then upstream PR acceptance and public-catalog install must be observed. | +| Chocolatey | Deterministic `.nupkg` plus moderated public state | Renderer, local validation command, and append-only moderation states are implemented. | Local-feed lifecycle must pass, then validation, verification, VirusTotal, moderation, and public install must complete. | +| RC soak and promotion | Public canaries, unchanged PR head, no incident, 24-hour decision | Promotion policy and reconciliation contracts are tested. | At least 24 hourly green samples per required channel and a complete 24-hour wall-clock window are mandatory. | +| Rollback/forward fix | Append-only recovery evidence | Known-good restoration and first-release withdrawal model supersession, exact catalog correction/removal, provider-owned yanking/deprecation, and recovery incidents without overwriting immutable artifacts. | Rehearse both a partial-publication failure and the no-predecessor first-release path before stable; require a separately reviewed patch when no verified baseline exists. | +| Homebrew Core | Separately authorized submission and public-Core canary | A renderer/validator exists, but Core is not selected by the current release train. | A reviewed enablement change, Core submission/acceptance, and `brew install guardscan` public canary are required. It never blocks `1.1.0`. | + +## Install and runtime variants + +| Variant | Runtime contract | Current acceptance target | Current launch gate | +| --- | --- | --- | --- | +| Source checkout / `npm link` | Developer-only Node runtime; not a public artifact | Unit, integration, compiled CLI, lint, typecheck, and release contracts | Cannot substitute for packed/public artifact evidence. | +| npm global | Exact tarball; Node 22 or newer | Linux, macOS, Windows on Node `22.23.1` and `24.18.0` | OIDC/provenance publish, digest redownload, and public RC/stable lifecycle. | +| pnpm global/dlx | Same npm tarball and Node requirement | Pinned pnpm `10.34.0` package-consumer smoke | Public registry global/dlx install, invoke, and uninstall. | +| Yarn Classic global / Yarn Modern dlx | Same npm tarball and Node requirement | Yarn `1.22.22` and `4.9.2` package-consumer smoke | Public registry lifecycle for both generations. | +| Bun global/bunx | Same npm tarball; GuardScan still requires Node 22 | Bun `1.3.14` package-consumer smoke | Public registry global/bunx lifecycle with Node present. | +| SEA archives | Embedded pinned Node runtime | Linux glibc x64/arm64, macOS x64/arm64, Windows x64 | Current-SHA signing/notarization, attestation, immutable release, and public redownload. | +| PyPI wheels | Exact signed SEA plus standard-library launcher | `manylinux_2_28_x86_64`, `manylinux_2_28_aarch64`, `macosx_11_0_x86_64`, `macosx_11_0_arm64`, `win_amd64` | TestPyPI/PyPI OIDC and pip/pipx native lifecycles. | +| First-party Homebrew tap | Signed SEA selected by host CPU/OS | Four macOS/Linux targets from the shared catalog | Stable catalog merge and public tap lifecycle. | +| Scoop, WinGet, Chocolatey | Signed Windows x64 SEA | One manifest/package per catalog, all bound to the same release digest | Public catalog acceptance and clean Windows lifecycle. | +| Homebrew Core | Source build with Homebrew `node` dependency | Not selected | Separate reviewed enablement and public-Core verification. | + +## Release decision rule + +A command is launch-accepted only when its required evidence is present for the +exact selected version and every install/runtime variant that claims it. A +command-specific partial result does not block unrelated development, but it +does block any public claim that the command is verified on that variant. + +Stable release completion additionally requires every selected distribution +channel to materialize as `verified` in the append-only ledger. Moderated +channels may remain `submitted`, but the release is then still incomplete. +Homebrew Core is not selected and therefore is neither a current install promise +nor a `1.1.0` completion gate. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 1c4feab..7373465 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -12,7 +12,7 @@ GuardScan is a privacy-first CLI tool that uses AI to automatically review your ## Key Features -- **Privacy-First**: Never uploads source code, only anonymized metadata +- **Privacy-First**: Built-in scanners stay local; cloud AI receives context only when you configure and invoke it - **Multi-Provider**: Supports OpenAI, Claude, Gemini, Ollama, and more - **Offline-Capable**: Works without internet using local AI models - **Universal**: Works with any git-based repository @@ -45,7 +45,8 @@ cd your-project guardscan init ``` -This generates a unique `client_id` stored locally in `~/.guardscan/config.yml`. +This creates local configuration under `~/.guardscan/config.yml`. +New non-interactive configurations default to offline mode with telemetry disabled. ### 2. Configure AI Provider @@ -69,9 +70,8 @@ guardscan run This will: 1. Count lines of code -2. Validate credits (if online) -3. Analyze your codebase with AI -4. Generate a detailed report +2. Analyze your codebase with the configured AI provider +3. Generate a detailed report ### 4. Check Your Status @@ -79,7 +79,7 @@ This will: guardscan status ``` -View your configuration, repository info, and remaining credits. +View your configuration, repository info, and local status. ## Using Local AI (Offline) @@ -103,7 +103,7 @@ guardscan config 4. Run offline: ```bash -guardscan run --no-cloud +guardscan run ``` ### With LM Studio @@ -115,7 +115,7 @@ guardscan run --no-cloud ```bash guardscan config # Select "lmstudio" as provider -# Default endpoint: http://localhost:1234 +# Endpoint may be entered as http://localhost:1234; GuardScan normalizes the OpenAI-compatible /v1 base ``` ## Security Scanning @@ -126,6 +126,17 @@ Run a free security scan: guardscan security ``` +For a guaranteed local-only run, disable CVE lookup or prepare an offline snapshot first: + +```bash +guardscan security --offline --no-cve + +# Prepare exact-version CVE coverage, then reuse it offline +guardscan config --offline=false +guardscan vuln db update . +guardscan vuln . --offline +``` + For verbose debug output, use the `--debug` flag: ```bash @@ -141,6 +152,8 @@ This performs SAST-like scanning for: - Code injection risks - And more... +See [Dependency Vulnerability Scanning](./VULNERABILITY_SCANNING.md) for supported ecosystems, snapshot freshness, OSV/CVSS/CISA limitations, and CI thresholds. + ## Review Specific Files Target specific files or patterns: @@ -202,11 +215,12 @@ guardscan run > review.md ```bash # Add to .git/hooks/pre-commit #!/bin/bash -guardscan security --no-cloud -if [ $? -ne 0 ]; then - echo "Security issues found! Review before committing." - exit 1 -fi +guardscan security --offline --no-cve --ci --format json --output guardscan.json +case $? in + 0) exit 0 ;; + 1) echo "GuardScan policy failed; review guardscan.json." ; exit 1 ;; + 2) echo "GuardScan could not complete required coverage." ; exit 2 ;; +esac ``` ### CI/CD Integration @@ -216,9 +230,9 @@ fi - name: Run GuardScan run: | npm install -g guardscan - guardscan init - guardscan config --provider openai --key ${{ secrets.OPENAI_API_KEY }} - guardscan run --no-cloud + guardscan --no-telemetry init + guardscan security --offline --no-cve --ci \ + --format sarif --output guardscan.sarif --fail-on high ``` ## Command Flags and Options @@ -230,7 +244,10 @@ GuardScan commands support various flags to customize behavior. Flags use kebab- - **File Selection**: `-f, --files ` - Specify files or patterns to analyze - **Debug Mode**: `--debug` - Enable verbose debug logging (available for `security` command) - **Output**: `-o, --output ` - Specify output file path -- **Negated Flags**: Flags like `--no-body` or `--no-cloud` disable features +- **Offline boundary**: `--offline` - Block GuardScan cloud/advisory/telemetry clients for the invocation +- **Cache boundary**: `--no-cache` - Disable exact, semantic, and advisory cache reads and writes +- **Scanner completeness**: `--allow-partial` - Explicitly accept incomplete scanner coverage +- **Negated Flags**: Flags like `--no-body` disable features ### Examples @@ -248,6 +265,17 @@ guardscan commit --no-body guardscan run --no-with-ai ``` +### CI results + +`scan` and `security` support versioned JSON and SARIF output: + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan scan --ci --format sarif --output guardscan.sarif --max-findings 25 +``` + +Exit code `0` means execution and policy passed, `1` means findings violated policy, and `2` means a required scanner or coverage step failed. JSON includes the same policy exit code and each scanner's succeeded, failed, or skipped state. + ### Flag Naming Convention - CLI flags use **kebab-case**: `--with-ai`, `--test-command`, `--embedding-provider` @@ -293,34 +321,34 @@ guardscan security --debug Edit `~/.guardscan/config.yml`: ```yaml -clientId: your-uuid provider: openai apiKey: sk-... -telemetryEnabled: true -offlineMode: false +telemetryEnabled: false +offlineMode: true createdAt: '2024-01-15T10:00:00Z' lastUsed: '2024-01-15T15:30:00Z' ``` ## Privacy & Telemetry -### What is Collected? +### What is queued after opt-in? -Only anonymized metadata: +Only a strict aggregate allowlist: -- Hashed repository ID -- Lines of code count -- AI provider used +- Random event ID +- Action category +- Aggregate lines of code - Processing duration -- Action type (review/security) +- Coarse execution mode +- Timestamp ### What is NOT Collected? - Source code -- File names -- Variable names -- Comments -- Any PII +- File names or paths +- Prompts or AI responses +- Findings, dependency names, or errors +- API keys ### Disabling Telemetry @@ -335,6 +363,21 @@ Or edit config: telemetryEnabled: false ``` +Telemetry is disabled by default and never uploads automatically. GuardScan +operates no hosted collector or default endpoint. After explicit consent, +events are queued only while online and remain local until you configure a +user-operated HTTPS collector and sync explicitly: + +```bash +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com +guardscan telemetry sync +``` + +Failed delivery leaves the batch queued. `guardscan config --telemetry=false` +deletes the queue. Use `guardscan telemetry status` and +`guardscan telemetry clear --force` to inspect or delete it explicitly. +`GUARDSCAN_API_URL` and the former `api.guardscancli.com` service are retired. + ## Troubleshooting ### "Configuration not found" @@ -345,13 +388,9 @@ Run `guardscan init` first. Run `guardscan config` and set up your provider. -### "Insufficient credits" - -Either: +### "AI provider not configured" -- Purchase more credits online -- Use `--no-cloud` flag -- Switch to local AI provider (Ollama) +Either run `guardscan config` to set up a BYOK provider or switch to a local AI provider such as Ollama or LM Studio. ### "Could not connect to provider" @@ -399,4 +438,4 @@ done ## Next Steps - Read the [API Documentation](./API.md) -- Check [Contributing Guide](./CONTRIBUTING.md) +- Open a focused pull request with tests and a clear rationale diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index 8d13ef9..ea78c01 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -1,286 +1,36 @@ -# Rate Limiting Documentation +# Telemetry Collector Rate Limiting -## Overview +GuardScan does not ship, configure, or operate a hosted collector. +`guardscan telemetry sync` sends one bounded batch to the user-operated HTTPS +endpoint selected with `GUARDSCAN_TELEMETRY_URL`. The former +`api.guardscancli.com` endpoint and `GUARDSCAN_API_URL` setting are retired and +must not be used as collector configuration. -The GuardScan backend implements rate limiting to prevent abuse and ensure fair resource allocation across all users. Rate limiting is applied to all telemetry and monitoring endpoints. +The CLI sends no client or repository identifier. A collector that applies rate limits must therefore use transport-level information such as source IP, an operator-provided authentication mechanism, or aggregate endpoint limits. Collector operators must document their limits, retention, deletion, access-control, and jurisdiction policies independently of GuardScan. -## Implementation +## CLI behavior -### Algorithm +- At most `TELEMETRY_CONSTANTS.BATCH_SIZE` oldest events are sent per explicit sync. +- HTTP 408, 429, and 5xx responses are treated as retryable delivery failures. +- Failed and unacknowledged events remain in the local spool. +- GuardScan does not automatically retry or transmit in the background. +- `--offline` and `--no-telemetry` block both recording and delivery. +- `guardscan config --telemetry=false` deletes queued events. -- **Sliding Window**: Tracks requests within a time window -- **Per-Client Tracking**: Each client has independent rate limits -- **In-Memory Storage**: Lightweight, fast, edge-compatible -- **Automatic Cleanup**: Expired records are cleaned up every 5 minutes - -### Rate Limits - -| Endpoint | Limit | Window | Notes | -| ----------------------- | ------------ | -------- | ----------------------- | -| `/api/telemetry` | 100 requests | 1 minute | Per client ID | -| `/api/monitoring` | 50 requests | 1 minute | Per client ID or IP | -| `/api/monitoring/stats` | 30 requests | 1 minute | Per IP (admin endpoint) | - -## Response Headers - -All responses include rate limit information in headers: - -``` -X-RateLimit-Limit: 100 # Maximum requests allowed -X-RateLimit-Remaining: 95 # Requests remaining in window -X-RateLimit-Reset: 2024-11-17... # When the window resets (ISO 8601) -``` - -## Rate Limit Exceeded Response - -When rate limit is exceeded, the API returns a `429 Too Many Requests` response: - -```json -{ - "error": "Rate limit exceeded", - "message": "Too many requests. Please try again later.", - "retryAfter": 42, - "limit": 100 -} -``` - -**Response Headers:** - -``` -Status: 429 Too Many Requests -Retry-After: 42 # Seconds until rate limit resets -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 0 -X-RateLimit-Reset: 2024-11-17... -``` - -## Client Identification - -### Telemetry Endpoint - -- Uses `clientId` from request body -- Each CLI instance has a unique client ID (UUID) -- Stored in `~/.guardscan/config.json` - -### Monitoring Endpoint - -- Prioritizes `clientId` from usage events -- Falls back to Cloudflare's `CF-Connecting-IP` header -- Last resort: "unknown" (shared rate limit) - -### Stats Endpoint - -- Uses `CF-Connecting-IP` header (Cloudflare's edge IP detection) -- Falls back to `X-Forwarded-For` header -- Stricter limit (admin/analytics endpoint) - -## Configuration - -Rate limits can be adjusted in the monitoring repo at -[`worker/src/utils/rate-limiter.ts`](https://github.com/ntanwir10/GuardScan-Monitoring/blob/main/worker/src/utils/rate-limiter.ts): - -```typescript -export const rateLimiters = { - telemetry: new RateLimiter({ - windowMs: 60 * 1000, // 1 minute - maxRequests: 100, // Adjust this value - }), - monitoring: new RateLimiter({ - windowMs: 60 * 1000, - maxRequests: 50, // Adjust this value - }), - monitoringStats: new RateLimiter({ - windowMs: 60 * 1000, - maxRequests: 30, // Adjust this value - }), -}; -``` - -## Monitoring - -Rate limit warnings are logged: - -```typescript -console.warn(`Rate limit exceeded for client: ${clientId}`); -``` - -Check Cloudflare Workers logs for rate limiting activity: +Inspect or clear local state through the CLI rather than editing spool files: ```bash -wrangler tail +guardscan telemetry status +guardscan telemetry clear --force ``` -## Best Practices - -### For CLI Users - -1. **Batching**: CLI automatically batches telemetry (50 events per sync) -2. **Offline Mode**: Use `--offline` flag to disable telemetry completely -3. **Respect Limits**: Normal usage stays well within limits - -### For Backend Operators - -1. **Monitor Logs**: Watch for repeated rate limit violations -2. **Adjust Limits**: Tune based on actual usage patterns -3. **Consider Cloudflare**: For production, consider Cloudflare's Rate Limiting product -4. **Add Metrics**: Track rate limit hits in monitoring system - -## Advanced: Cloudflare Rate Limiting - -For production deployments, consider using [Cloudflare's Rate Limiting](https://developers.cloudflare.com/waf/rate-limiting-rules/): - -### Benefits - -- Distributed rate limiting across edge network -- More sophisticated rules (IP, headers, etc.) -- DDoS protection -- Persistent storage - -### Example Rule - -``` -(http.request.uri.path eq "/api/telemetry") and -(rate(1m) > 100) -``` - -**Action**: Block or Challenge - -### Migration Path - -1. Deploy with in-memory rate limiting (current) -2. Monitor usage patterns for 1-2 weeks -3. Configure Cloudflare rules based on data -4. Gradually shift to Cloudflare Rate Limiting -5. Keep in-memory as fallback - -## Testing - -### Manual Testing - -```bash -# Test telemetry endpoint -for i in {1..105}; do - curl -X POST https://guardscan-backend.workers.dev/api/telemetry \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "test-client-123", - "repoId": "test-repo", - "events": [{"action": "scan", "loc": 100, "durationMs": 1000, "model": "gpt-4", "timestamp": 1234567890, "metadata": {}}] - }' - echo "Request $i" -done - -# Should see 429 after 100 requests -``` - -### Load Testing - -```bash -# Install hey (HTTP load generator) -go install github.com/rakyll/hey@latest - -# Test with concurrent requests -hey -n 1000 -c 10 -m POST \ - -H "Content-Type: application/json" \ - -d '{"clientId": "load-test", "repoId": "test", "events": [...]}' \ - https://guardscan-backend.workers.dev/api/telemetry -``` - -### Unit Tests - -```typescript -import { RateLimiter } from './rate-limiter'; - -describe('RateLimiter', () => { - it('should allow requests within limit', () => { - const limiter = new RateLimiter({ windowMs: 1000, maxRequests: 5 }); - - for (let i = 0; i < 5; i++) { - const result = limiter.check('test-client'); - expect(result.allowed).toBe(true); - } - }); - - it('should block requests exceeding limit', () => { - const limiter = new RateLimiter({ windowMs: 1000, maxRequests: 5 }); - - for (let i = 0; i < 5; i++) { - limiter.check('test-client'); - } - - const result = limiter.check('test-client'); - expect(result.allowed).toBe(false); - }); - - it('should reset after window expires', async () => { - const limiter = new RateLimiter({ windowMs: 100, maxRequests: 1 }); - - limiter.check('test-client'); - const blocked = limiter.check('test-client'); - expect(blocked.allowed).toBe(false); - - await new Promise(resolve => setTimeout(resolve, 150)); - - const allowed = limiter.check('test-client'); - expect(allowed.allowed).toBe(true); - }); -}); -``` - -## Troubleshooting - -### CLI Getting Rate Limited - -**Symptoms**: `429 Too Many Requests` errors in CLI - -**Solutions**: - -1. Check if batching is working: `cat ~/.guardscan/cache/telemetry.json` -2. Enable offline mode temporarily: `guardscan scan --offline` -3. Clear local batch: `rm ~/.guardscan/cache/telemetry.json` -4. Contact support if legitimate usage is blocked - -### High Rate Limit Hits - -**Symptoms**: Many rate limit warnings in logs - -**Investigation**: - -1. Check client IDs hitting limits -2. Look for patterns (same IP, time of day) -3. Verify batch sizes aren't too large -4. Check for CLI bugs causing excessive requests - -**Actions**: - -1. Increase limits if legitimate usage -2. Block malicious IPs at Cloudflare level -3. Add authentication for verified users - -### Memory Issues - -**Symptoms**: Worker exceeding memory limits - -**Solutions**: - -1. Reduce cleanup interval (more frequent) -2. Add max entries limit -3. Implement LRU cache eviction -4. Consider external storage (KV, Durable Objects) - -## Future Enhancements - -1. **Persistent Storage**: Use Cloudflare Durable Objects for distributed rate limiting -2. **Token Bucket**: More flexible rate limiting algorithm -3. **Burst Allowance**: Allow short bursts above limit -4. **Per-User Tiers**: Different limits for different user types -5. **Analytics Dashboard**: Visualize rate limit metrics -6. **Auto-Scaling**: Increase limits based on load -7. **Exemptions**: Whitelist trusted clients - -## References +## Collector guidance -- [Cloudflare Rate Limiting](https://developers.cloudflare.com/waf/rate-limiting-rules/) -- [RFC 6585 - HTTP Status Code 429](https://tools.ietf.org/html/rfc6585) -- [Rate Limiting Headers](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers) +- Return `429 Too Many Requests` and a standard `Retry-After` header when limiting requests. +- Validate the `guardscan.telemetry.v1` schema and cap request size. +- Deduplicate canonically by `eventId` across batches; `batchId` is request correlation only. +- Return accepted event IDs for partial acknowledgements, and return `duplicate` only for a complete duplicate batch. +- Do not infer that an anonymous event identifies a stable installation or repository. +See [Self-Hosted Telemetry Collector Protocol](./API.md) for the exact request +and acknowledgement contract. diff --git a/docs/RELEASE_AUTOMATION.md b/docs/RELEASE_AUTOMATION.md new file mode 100644 index 0000000..85a2a91 --- /dev/null +++ b/docs/RELEASE_AUTOMATION.md @@ -0,0 +1,353 @@ +# GuardScan release automation + +GuardScan uses one RC-first, append-only release train for npm, standalone GitHub assets, Homebrew, Scoop, WinGet, Chocolatey, and PyPI. pnpm, both Yarn generations, and Bun consume the npm package and are verified as separate install channels. + +The automation is fail-closed. A tag is an identity created by the release train, never publication authority. General CI has no tag trigger, publication permission, registry command, or GitHub release job. + +[`FUNCTIONAL_ACCEPTANCE.md`](./FUNCTIONAL_ACCEPTANCE.md) maps every public +command and major workflow to its strongest evidence class, install/runtime +variants, and remaining launch gate. [`RELEASE_ONBOARDING.md`](./RELEASE_ONBOARDING.md) +defines the one-time default-branch bootstrap and provider activation order. +Neither an implemented workflow nor a green component test is public-release +evidence until it is bound to the selected commit and exact artifact. + +## Release invariants + +- `cli/package.json`, `cli/package-lock.json`, `cli/CHANGELOG.md`, the tag, and the exact commit agree. +- The stable Release Please PR remains at `1.1.0`. A bot-owned candidate commit derives `1.1.0-rc.1` from that exact PR head. +- Release builds use Node `22.23.1`, esbuild `0.28.1`, and postject `1.0.0-alpha.6`. +- Every public artifact is immutable and digest-bound to its source commit. +- Missing remote versions are published. Identical remote digests are accepted as retries. Different remote digests open an integrity incident and stop the train. +- Stable promotion is a machine decision after a full 24-hour window. It requires an unchanged release-PR head, fresh green canaries for every RC channel, and no open release incident. +- WinGet and Chocolatey remain `submitted` until their public catalogs accept them and a clean public installation passes. +- Recovery never mutates history or overwrites a release. Normal rollback restores a verified known-good source through a forward-fix patch; the first stable train instead fails closed into withdrawal when no verified baseline exists. + +## Selected channels + +The current RC train selects npm, its pnpm/Yarn/Bun consumer canaries, GitHub +native assets, Homebrew tap preview, Scoop preview, and PyPI. The stable train +selects those channels plus WinGet and Chocolatey. Every selected channel must +reach `verified` before the stable release is complete. + +Homebrew Core is not selected. The renderer and validator are dormant building +blocks only: the orchestrator calls `releaseTrainChannels` without the explicit +`homebrewCoreEnabled` option, so it cannot submit Core in the current train. +Core requires a separate reviewed enablement and remains nonblocking. + +## Workflow ownership + +| Workflow | Authority | +| --- | --- | +| `.github/workflows/ci.yml` | Required source, test, coverage, package, package-manager, audit, and five-host SEA gates. It cannot publish. | +| `.github/workflows/release-please.yml` | Maintains the stable release PR only, using a short-lived GitHub App token. | +| `.github/workflows/release-train.yml` | Derives RC commits, creates protected tags, dispatches builds/publication, reconciles every 30 minutes, promotes, rolls back, and persists release events. | +| `.github/workflows/release-first-withdrawal.yml` | Withdraws the first stable train only when the protected ledger proves no verified predecessor exists; it never invents forward-fix source. | +| `.github/workflows/release-build.yml` | Builds the exact npm tarball and five SEA targets, signs, notarizes, generates SPDX/CycloneDX, creates wheels, attests, archives deterministically, and aggregates the manifest/checksums. | +| `.github/workflows/release-publish.yml` | Publishes tested registry handoffs through OIDC and opens the generated shared-catalog update PR. | +| `.github/workflows/release-canary.yml` | Runs hourly public install/invoke/uninstall canaries and polls moderated registries. | + +Release workflows deliberately do not use dependency caches. Release-critical actions are pinned to immutable commits. + +## Shared package-manager catalog + +GuardScan is the sole release authority. The public +`ntanwir10/homebrew-tap` repository is a generated, cryptographically bound +projection of one GuardScan release manifest; it is not a second source of +product or release state. The shared catalog contains both first-party +package-manager adapters: + +```text +Formula/guardscan.rb +bucket/guardscan.json +channel-lock.json +.github/workflows/verify.yml +``` + +A stable release renders the formula, Scoop manifest, and lock together and +opens one catalog pull request. RCs use the temporary branch +`channel-preview/vVERSION`; stable users read catalog `main`. Catalog CI +fetches the immutable GuardScan manifest, verifies its SHA-256 and tagged +source commit, reruns the renderer from that exact source commit, checks every +asset URL and digest, and runs the native lifecycle tests before merge. +Hand-written catalog changes fail unless they are byte-identical to renderer +output. + +`channel-lock.json` uses schema `guardscan.channel-catalog.v1` and binds the +projection without trying to include the catalog commit in its own contents: + +```json +{ + "schemaVersion": "guardscan.channel-catalog.v1", + "source": { + "repository": "ntanwir10/GuardScan", + "version": "1.1.0", + "tag": "v1.1.0", + "commit": "GUARDSCAN_SOURCE_COMMIT", + "manifestUrl": "IMMUTABLE_RELEASE_MANIFEST_URL", + "manifestSha256": "RELEASE_MANIFEST_SHA256" + }, + "generator": { + "repository": "ntanwir10/GuardScan", + "commit": "GENERATOR_COMMIT" + }, + "files": { + "Formula/guardscan.rb": { + "sha256": "FORMULA_SHA256" + }, + "bucket/guardscan.json": { + "sha256": "SCOOP_MANIFEST_SHA256" + } + } +} +``` + +After merge, the catalog sends a `catalog_updated` repository dispatch +containing the merged commit and lock digest. The dispatch is only a latency +hint: GuardScan refetches the catalog at that commit and validates the lock, +manifest, and generated file digests before appending evidence to the release +ledger. Scheduled reconciliation repeats this check every 30 minutes, so a +missed dispatch cannot create permanent drift. + +Reconciliation is idempotent and fail-closed: + +- missing or older catalog state opens or reuses the deterministic update PR; +- the exact expected lock and file digests materialize as `verified`; +- different digests for the same release open a release-integrity incident; +- an unexpected newer catalog version stops automated mutation for review. + +The ledger records the catalog repository, merged commit, pull-request number, +lock digest, manifest digest, and channel-specific path/digest. Channel remote +identities are: + +```text +github:ntanwir10/homebrew-tap@COMMIT#Formula/guardscan.rb +github:ntanwir10/homebrew-tap@COMMIT#bucket/guardscan.json +``` + +This is strong convergence, not a cross-repository atomic transaction: the +release remains incomplete while the catalog is behind. A rollback is another +generated catalog PR pointing to a verified known-good release plus append-only +ledger events. The repositories are intentionally not connected with +submodules, subtrees, mirroring, or bidirectional synchronization. + +## Maintainer interface + +Run from `cli/`, or use `npm run release -- `: + +```bash +npm run release -- build +npm run release -- manifest +npm run release -- publish --channel npm +npm run release -- verify --channel npm +npm run release -- reconcile +npm run release -- promote +npm run release -- rollback +npm run release -- status +``` + +The low-level commands require explicit source, manifest, ledger, timestamp, artifact, and remote-identity arguments. `npm run release -- --help` is the canonical option reference. + +Important supporting commands: + +```bash +npm run release:validate +npm run release:plan -- --profile full +npm run release:prepare -- \ + --profile full \ + --output-dir ../release-evidence/v1.1.0-rc.1 \ + --ledger ../release-evidence/v1.1.0-rc.1/events.jsonl \ + --timestamp 2026-07-25T18:00:00.000Z \ + --idempotency-key train:v1.1.0-rc.1 +npm run release:render -- \ + --manifest ../release-evidence/v1.1.0-rc.1/release-manifest.json \ + --output-dir ../release-evidence/v1.1.0-rc.1/adapters +``` + +`advance` and the v1 mutable state schema remain temporarily available for compatibility with earlier local evidence. New automation uses only the hash-chained event ledger and materialized v2 state. + +## Artifact contracts + +Five host-native artifacts are required: + +- `linux-x64-glibc` +- `linux-arm64-glibc` +- `darwin-x64` +- `darwin-arm64` +- `windows-x64` + +The builder bundles one CommonJS program, allows only `tiktoken` and `chartjs-node-canvas` as optional externals, injects a Node SEA blob, and runs with Node and package managers absent from `PATH`. Required smoke covers: + +- exact version and help; +- offline static scanning; +- SPDX 2.3 and CycloneDX 1.7; +- telemetry-disabled status; +- safe reduced capability behavior, proven by invoking + `guardscan capabilities --json` from the exact standalone executable. + +The standalone profile reports: + +```json +{ + "coreScan": true, + "sbom": true, + "chartRendering": false, + "accurateTokenCounting": false +} +``` + +Archives have normalized entry names, ordering, modes, timestamps, ownership, and compression. Inspection rejects traversal, absolute paths, links, duplicate/case-colliding entries, unexpected files, truncation, trailing data, invalid checksums, excessive entry counts, and excessive expanded size. + +Each PyPI wheel contains the exact signed executable already represented by its standalone archive. Its standard-library-only launcher verifies the embedded executable digest, forwards arguments and exit status, and uses `exec` for POSIX signal behavior. Wheel tags are derived from the actual platform: + +| Target | Wheel platform tag | +| --- | --- | +| Linux x64 glibc | `manylinux_2_28_x86_64` | +| Linux arm64 glibc | `manylinux_2_28_aarch64` | +| macOS x64 | `macosx_11_0_x86_64` | +| macOS arm64 | `macosx_11_0_arm64` | +| Windows x64 | `win_amd64` | + +## Append-only state + +Events live on the protected `release-ledger` branch under `events/vVERSION.jsonl`. Each event contains a sequence, previous-event hash, idempotency key, source identity, timestamp, payload, and its own digest. + +Materialized channel states include: + +```text +planned -> published +planned -> submitted -> accepted -> verified +any active state -> failed +published/verified -> withdrawn or superseded +``` + +Rollback is represented by `rollback_started`, `withdrawn`, and `superseded` +events; no backward state mutation is needed. Integrity, security, +availability, and recovery incidents use `incident_opened` and +`incident_resolved`. An `action_required` event records a provider-owned task; +it does not claim that a package has been withdrawn. + +The repository also contains: + +- `guardscan.release-event.v1` +- `guardscan.release-state.v2` +- `guardscan.promotion-decision.v1` +- strengthened `guardscan.release-manifest.v1` + +## RC and promotion + +Before the first candidate, merge the inert automation bootstrap to `main` +while `RELEASE_AUTOMATION_ENABLED=false`, complete every onboarding check, and +verify that the release PR head and full gate are unchanged. Refetch the +protected `release-ledger` branch and fail if its tree contains anything other +than the empty `active-versions.json` seed; this is the explicit no-migration +boundary for the first source-bound event schema. Then set the variable to +`true` and start the candidate from the default-branch workflow: + +```bash +gh workflow run release-train.yml \ + -f action=candidate \ + -f version=1.1.0-rc.1 \ + -f release_pr=RELEASE_PR_NUMBER +``` + +The train: + +1. resolves the exact stable PR head; +2. creates a candidate commit containing only RC identity changes; +3. creates `v1.1.0-rc.1` with the release GitHub App; +4. builds, signs, attests, and verifies every artifact; +5. publishes npm under `next`, GitHub as a prerelease, TestPyPI then PyPI, and the shared catalog preview branch `channel-preview/v1.1.0-rc.1`; +6. renders and validates WinGet/Chocolatey without publishing an RC; +7. records `publishedAt` and hourly canary evidence; +8. reconciles every 30 minutes. + +Promotion produces `promotion-decision.json`. A permitted decision requires at least 24 green samples per required channel, a complete 24-hour wall-clock window, a fresh last sample, the same source PR head, and no open incident. The release App then auto-merges that unchanged PR, tags its exact merge commit, rebuilds stable artifacts, and publishes `latest`. + +## Public installation contracts + +These commands become user-facing only when the ledger shows `verified` from the public production source: + +```bash +npm install -g guardscan +pnpm add -g guardscan +pnpm dlx guardscan +yarn global add guardscan +yarn dlx guardscan +bun add -g guardscan +bunx guardscan +brew install ntanwir10/tap/guardscan +scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap +scoop install guardscan +winget install --exact --id NaumanTanwir.GuardScan +choco install guardscan +pip install guardscan-cli +pipx install guardscan-cli +``` + +The npm package requires Node 22 or newer even when invoked by Bun. The standalone and wheel channels include the runtime. + +The one-part command `brew install guardscan` is **not selected or advertised by +the current train**. It becomes available only after a separately reviewed +change enables the optional `homebrew-core` channel, a source-building formula +is submitted and accepted, and a clean public-Core canary reaches `verified`. +Until then, `brew install ntanwir10/tap/guardscan` is the only supported Homebrew +contract. + +If enabled later, `homebrew-core` uses the normal append-only +`submitted -> accepted -> verified` states. Submission is not acceptance, +acceptance is not public verification, and Core never blocks first-party release +completion. + +## Recovery + +Before stable promotion, any failed build, signature, digest, canary, vulnerability, or source-head check stops the train. The correction is a new `rc.N`. + +After stable publication: + +- immutable GitHub assets are retained and marked superseded; +- Homebrew/Scoop move together through a generated shared-catalog PR to a known-good native release, or remove the new listing; +- PyPI is yanked where authorized; +- npm is deprecated and moved forward through a patch; +- Chocolatey is unlisted/superseded; +- WinGet receives a corrective manifest; +- a higher patch version is prepared from selected known-good source. + +The first ledger-backed stable release is the only exception to the +known-good requirement. Dispatching rollback without `known_good` invokes the +separate first-release withdrawal workflow. It is authorized only when the +protected ledger proves that the defective version is the active first stable +train and that no earlier stable ledger ever reached completion. The workflow: + +- retains immutable GitHub assets and marks them superseded; +- removes only an exact matching `Formula/guardscan.rb`, + `bucket/guardscan.json`, and `channel-lock.json`, or records that the catalog + was already in the valid empty bootstrap state; it also closes the exact + still-open publication PR so that a delayed merge cannot restore the listing; +- appends provider-owned npm, PyPI, WinGet, Chocolatey, and optional Core + actions without claiming they have completed; +- records `provider-actions-pending` while any of those external actions remain, + keeps a recovery incident open, deactivates the train only after repository + evidence is persisted, and requires a separately reviewed next patch; +- makes retries prove the active train is absent, repository-owned channels are + terminal, the catalog is still empty, and no publication PR remains open; +- never creates a forward-fix branch from the defective source. + +Once any earlier stable release has reached completion, omitting `known_good` +fails closed and the normal verified-baseline rollback is mandatory. + +```bash +# First ledger-backed stable release only; protected-ledger proof is mandatory. +gh workflow run release-train.yml \ + -f action=rollback \ + -f version=1.1.0 + +# Every later stable release requires an exact verified predecessor. +gh workflow run release-train.yml \ + -f action=rollback \ + -f version=DEFECTIVE_VERSION \ + -f known_good=VERIFIED_PREDECESSOR +``` + +A release is complete only when every selected blocking channel materializes +as `verified`. Optional Homebrew Core submission/acceptance is tracked +separately and never blocks the release train. diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md new file mode 100644 index 0000000..6c691c3 --- /dev/null +++ b/docs/RELEASE_ONBOARDING.md @@ -0,0 +1,281 @@ +# One-time release provider onboarding + +The repository contains the zero-touch release implementation. The following provider-owned identity and account steps must be completed once before `1.1.0-rc.1`. Automation must not fabricate or bypass them. + +The release remains closed until the evidence in +[`FUNCTIONAL_ACCEPTANCE.md`](./FUNCTIONAL_ACCEPTANCE.md) is satisfied for the +candidate commit and artifacts. + +## Activation order while automation is off + +Use this order for the first train: + +1. Reauthenticate the maintainer and set the GuardScan repository variable + `RELEASE_AUTOMATION_ENABLED=false` **before** merging release automation to + the default branch. +2. Land a reviewed bootstrap PR on `main` containing the inert release + workflows, schemas, renderer, and ledger seed. It must not change a public + version, create a tag, or publish an artifact. +3. Confirm GitHub lists the workflows from `main`, CI passes, Release Please is + skipped, scheduled train jobs are skipped, and publication jobs reject the + disabled state. A manual canary validation may run read-only checks, but it + cannot authorize publication or a ledger transition. +4. Complete the GitHub App, branch/tag protections, environments, catalog, OIDC + publishers, signing identities, moderated-registry accounts, and monitoring + below. Keep the automation variable false throughout. +5. Recheck that the protected `release/1.1.0` PR head is unchanged and that its + complete release gate passes. The bootstrap merge to `main` is not release + approval. +6. Set `RELEASE_AUTOMATION_ENABLED=true` only when every checklist item and + provider rehearsal is complete, then dispatch exactly the candidate command + in `RELEASE_AUTOMATION.md`. + +GitHub only accepts manual and scheduled workflow execution from workflow files +present on the default branch. Keeping the implementation solely on the release +PR would therefore leave the orchestrator unavailable; the inert bootstrap is a +required first-release exception. + +## GitHub + +- Reauthenticate `gh` as `ntanwir10` and verify the active host/account before + any repository mutation. +- Create or verify `ntanwir10/homebrew-tap` as the public shared Homebrew/Scoop + catalog. +- Create `guardscan-release-bot` as a GitHub App owned by `ntanwir10`. +- Grant the App Actions, Contents, Pull requests, Issues, and Workflows + **write** permission plus Metadata **read** permission. +- Install the App only on GuardScan and `ntanwir10/homebrew-tap`; do not grant + organization-wide or all-repository access. +- Store `RELEASE_APP_ID` as a repository variable and + `RELEASE_APP_PRIVATE_KEY` as a secret in each installed repository. The key is + exchanged only for short-lived installation tokens; there is no PAT fallback. +- Seed the orphan `release-ledger` branch from + `.github/release-ledger/active-versions.json`, then protect the branch and + require the App identity for writes. Do not copy application source onto the + ledger branch. Before merging the bootstrap, refetch this branch and require + its complete tree to contain only `active-versions.json` with an empty + `trains` array. The first source-bound and moderated event contracts have no + migration path from experimental pre-release ledger records; any unexpected + `events/` path is a launch blocker, not data to coerce. +- Protect `v*` tags so only the release App can create them. +- Enable immutable releases for GuardScan. +- Enable squash merge and auto-merge, and require the full `Release gate` + status on the stable release PR. +- Keep repository variable `RELEASE_AUTOMATION_ENABLED=false` until every + onboarding rehearsal below passes. Scheduled reconciliation and automatic + canaries remain dormant while it is false. + +Create environments without manual reviewers: + +- `release-rc` +- `release-stable` +- `npm-publish` +- `pypi` +- `apple-notarization` +- `windows-signing` +- `winget` +- `chocolatey` + +Allow protected branch `main` in these environments because the authorized +`workflow_dispatch` caller runs from `main`; add protected candidate/stable tags +where an environment needs them. Fork pull requests must not receive +environment secrets or OIDC tokens. + +The reusable build and publish workflows run in the security context of their +caller. In npm and PyPI provider forms, enter the workflow **filename** +`release-train.yml` (not a path), which identifies the caller; do not register +the reusable `release-publish.yml`. PyPI does not accept a reusable workflow as +the trusted-publisher workflow. See the current +[npm trusted-publisher fields](https://docs.npmjs.com/trusted-publishers/) and +[PyPI reusable-workflow limitation](https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github). + +## Shared Homebrew and Scoop catalog + +Initialize `ntanwir10/homebrew-tap` with: + +```text +README.md +.github/workflows/verify.yml +``` + +Before the first verified stable native release, all of +`Formula/guardscan.rb`, `bucket/guardscan.json`, and `channel-lock.json` must +remain absent. The verification workflow accepts only that exact unpublished +state; it rejects partial catalog metadata. The first stable catalog PR creates +all three generated files atomically. After publication, the repository layout +contains all five files. + +Protect catalog `main`; require pull requests and the catalog verification +check. Stable metadata is merged only to `main`. RC metadata lives on temporary +`channel-preview/vVERSION` branches. Install the release App on this repository +so it can open and update one generated PR containing both package-manager +files and their cryptographic lock. Add the same `RELEASE_APP_ID` variable and +`RELEASE_APP_PRIVATE_KEY` secret to the catalog so its post-merge workflow can +mint a short-lived cross-repository dispatch token; do not store a personal +access token for this notification. + +Configure the catalog's post-merge workflow to send `catalog_updated` to +GuardScan with the merged commit and lock digest. This notification does not +authorize a ledger transition: GuardScan must refetch that exact commit and +validate `channel-lock.json`, the release-manifest digest, and both generated +file digests. The 30-minute GuardScan reconciliation schedule is the recovery +path for missed dispatches and drift. + +Do not add a Git submodule, subtree, repository mirror, or reverse update from +the catalog into GuardScan. GuardScan is authoritative; the catalog is a +generated projection. + +## npm + +- Configure trusted publishing for package `guardscan`. +- Bind it exactly to GitHub user `ntanwir10`, repository `GuardScan`, workflow + filename `release-train.yml`, environment `npm-publish`, and allowed action + `npm publish`. Do not allow staged publication unless the release train is + separately changed to use it. +- Confirm the release job installs and verifies exactly npm `11.5.2` before the + trusted-publishing rehearsal; the npm bundled with Node 22 is not the release + identity. +- Confirm the publisher receives an OIDC identity token only in the + `npm-publish` job and publishes the previously tested tarball with + `--provenance`. The job must redownload registry metadata and reject a digest + conflict before recording success. +- Do not retain an npm token fallback after OIDC succeeds. + +## TestPyPI and PyPI + +- Reserve `guardscan-cli`. +- Configure pending trusted publishers for both TestPyPI and PyPI. +- Bind them exactly to GitHub owner `ntanwir10`, repository `GuardScan`, + workflow filename `release-train.yml`, and environment `pypi`. +- Confirm both publishers accept the PEP 440 identity `1.1.0rc1` derived from + tag `v1.1.0-rc.1`. TestPyPI must converge to all five tested wheels and pass + pip/pipx native lifecycles before the production PyPI job can run. +- Do not configure passwords or API-token fallbacks in the `pypi` environment. + +## Apple + +Enroll the publisher and provision: + +- `APPLE_CERTIFICATE_P12` +- `APPLE_CERTIFICATE_PASSWORD` +- `APPLE_TEAM_ID` +- `APPLE_NOTARY_KEY_ID` +- `APPLE_NOTARY_ISSUER_ID` +- `APPLE_NOTARY_PRIVATE_KEY` + +Store them only in `apple-notarization`. Renewals and Apple identity revalidation remain external authority boundaries. + +Import the certificate into an ephemeral keychain during the macOS job. Verify +the Developer ID Application subject includes `APPLE_TEAM_ID`; submit with +`notarytool`, inspect the accepted log, staple, and require both `codesign` and +`spctl` verification before archiving. Delete the keychain and temporary key +material at job cleanup. + +## Azure Artifact Signing + +Create a Public Trust signing account/profile and GitHub OIDC federation. +Configure these GitHub environment **variables** (not secrets) in +`windows-signing`: + +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_CLIENT_ID` +- `AZURE_SIGNING_ACCOUNT` +- `AZURE_SIGNING_PROFILE` +- `AZURE_SIGNING_ENDPOINT` + +Grant only the Artifact Signing Certificate Profile Signer role needed by the federated identity. + +Set the GitHub OIDC federated credential subject exactly to +`repo:ntanwir10/GuardScan:environment:windows-signing`. The environment's +protected-branch policy and the repository's protected workflows constrain the +caller separately. The job must verify Authenticode status and timestamp before +the executable is archived. Do not add an Azure client secret fallback. + +## WinGet and Chocolatey + +- Accept the Microsoft CLA for the submitting identity. +- Store a narrowly scoped, expiry-bounded `WINGET_GITHUB_TOKEN` in `winget`. + It may submit the generated manifests to `microsoft/winget-pkgs`; it must not + have GuardScan administration or release authority. +- Create/validate the Chocolatey publisher account. +- Store `CHOCO_API_KEY` in `chocolatey`. + +Rehearse WinGet local-manifest install before submission and Chocolatey +install/upgrade/invoke/uninstall from a local feed before `choco push`. A +successful submission is not deployment. + +WinGet review and Chocolatey validation, verification, VirusTotal, and moderation are external states. The ledger keeps them `submitted` until public installation passes. + +## Optional Homebrew Core + +Homebrew Core is **not selected** for the current release train. Do not submit a +Core formula and do not advertise `brew install guardscan` during `1.1.0`. +Users install from the first-party tap until a separate reviewed enablement is +merged and verified. + +Enabling Core later requires all of the following: + +1. a reviewed change that explicitly adds `homebrew-core` to the selected + stable channels and ledger policy; +2. a source-building formula that meets current Homebrew Core policy and passes + local style, audit, build, test, and uninstall checks; +3. submission to Homebrew Core, external acceptance, and a clean public install + canary for the one-part command; and +4. documentation changes only after the public canary reports `verified`. + +Core remains nonblocking for first-party release completion even after an +optional submission is tracked. + +## First `1.1.0` bootstrap exception + +The first stable train does not ask Release Please to regenerate `1.1.0`. +The protected `release/1.1.0` pull request is the bootstrap release source +because the previous `main` baseline and the new Release Please seed disagree +about whether `1.1.0` is already prepared. Derive `1.1.0-rc.1` from that exact +PR head, require the normal release gates, and merge/tag it through the release +train. After `v1.1.0` is verified, align the Release Please manifest to `1.1.0` +so subsequent stable release PRs follow the normal automated path. + +Because v1.0.5 predates the protected release ledger, it is not a valid +automated rollback target for this train. Before enabling automation, rehearse +the first-release withdrawal contract: an empty `known_good` must be accepted +only when the protected ledger proves there is no completed predecessor; it +must retain immutable assets, remove or verify the exact empty shared catalog, +open a recovery incident, record provider-owned actions, and avoid generating +a forward-fix branch. A separately reviewed v1.1.1 is required after such a +withdrawal. Later releases must supply a verified ledger-backed `known_good`. +Repository withdrawal is not provider withdrawal: the protected state remains +`provider-actions-pending`, with the recovery incident open, until the recorded +npm, PyPI, WinGet, Chocolatey, or optional Core authorities complete their +actions. Retrying the workflow re-verifies the empty catalog and closes the +exact stale catalog publication PR; it does not turn pending provider work into +success. + +## Expiry monitoring + +Credential health is a launch gate, not an informal maintainer reminder. +Configure alerts to a monitored release-owner destination and record only +status/expiry metadata, never secret values. + +| Identity/binding | Automated signal | Alert/rehearsal policy | +| --- | --- | --- | +| GitHub App | Installation exists on exactly both repositories; required permissions remain; token mint succeeds | Check daily and before every candidate. Alert immediately on installation/permission drift and at the configured private-key age limit. | +| npm trusted publisher | Repository, caller workflow, and `npm-publish` environment match; no token fallback exists | Check before every candidate and monthly. A mismatch keeps automation off. | +| TestPyPI/PyPI trusted publishers | Project, repository, caller workflow, and `pypi` environment match | Check both services before every candidate and monthly; rehearse TestPyPI before production. | +| Apple certificate/notary identity | Certificate subject/team, expiry, and notary authentication are valid | Check daily; alert at 60, 30, 14, and 7 days. Renewal/identity revalidation requires the publisher. | +| Azure federation/signing profile | Federated subject, signer role, account/profile state, and timestamp service are healthy | Check weekly and before every candidate with a non-secret signing preflight. Alert immediately on role or federation drift. | +| WinGet submitter | Token expiry/scope and Microsoft CLA state are valid | Check weekly; alert at 30, 14, and 7 days. CLA or account challenges remain external authority boundaries. | +| Chocolatey publisher | Account/package ownership and API-key authentication remain valid | Check weekly with a non-publishing endpoint; alert at 30, 14, and 7 days when expiry metadata is available. | +| Shared catalog connection | App installation, required check, dispatch permission, and scheduled reconciliation are healthy | Check daily. A missed dispatch is recovered by reconciliation; an invalid lock/digest is an integrity incident. | + +If the provider does not expose expiry metadata, use a bounded authentication +preflight and record `unknown` rather than inventing a date. Monitoring is not +complete until at least one alert path has been tested. + +After every onboarding item, functional acceptance prerequisite, and monitoring +rehearsal passes, enable the variable and immediately dispatch the first +candidate. No later release requires a human promotion click. Only +provider-mandated identity, MFA, legal, certificate-renewal, account +verification, or moderator requests remain human boundaries; automation must +report those states rather than claiming completion. diff --git a/docs/VULNERABILITY_SCANNING.md b/docs/VULNERABILITY_SCANNING.md new file mode 100644 index 0000000..e90afc5 --- /dev/null +++ b/docs/VULNERABILITY_SCANNING.md @@ -0,0 +1,120 @@ +# Dependency Vulnerability Scanning + +GuardScan 1.1 scans exact dependency versions against the OSV API. Use the dedicated command for a dependency-only audit, or include the same scanner in `security` and `scan`. + +## Quick start + +```bash +# Online lookup using the exact local inventory +guardscan config --offline=false +guardscan vuln . + +# Equivalent command aliases +guardscan cve . +guardscan audit . + +# Machine-readable CI report +guardscan vuln . --ci --format json --output vulnerabilities.json +``` + +`security` and `scan` include dependency vulnerabilities by default. Disable the scanner explicitly when a workflow only needs local source checks: + +```bash +guardscan security --no-cve +guardscan scan --no-cve +``` + +Use `--cve` to state the default explicitly. Use `--concurrency <1-16>` to bound scanner or advisory detail work. + +## Inventory and supported ecosystems + +GuardScan reads local manifests and lockfiles recursively, excluding generated and vendor directories. It currently recognizes: + +| Ecosystem | Inputs | OSV ecosystem | +| --- | --- | --- | +| JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, exact versions in `package.json` | npm | +| Python | pinned `requirements.txt` | PyPI | +| Go | `go.mod` | Go | +| Rust | `Cargo.lock` | crates.io | +| Ruby | `Gemfile.lock` | RubyGems | +| Java | exact dependencies in `pom.xml` | Maven | + +Only exact versions can be queried correctly. Unresolved ranges, malformed manifests, and unsupported lockfile records are coverage errors rather than evidence that a dependency is safe. `--scope runtime` excludes known development dependencies; `--scope all` is the default. + +The same local inventory powers offline SBOM generation. `guardscan sbom` therefore continues to list known exact dependencies without network access. `--format spdx` emits SPDX 2.3 and `--format cyclonedx` emits CycloneDX 1.7; both are validated against their official JSON schemas in tests and package smoke. License values may remain `NOASSERTION` or absent when neither a lockfile nor installed package contains license metadata; offline mode skips only network enrichment. + +## Online and offline coverage + +An online scan batches package coordinates through OSV and fetches advisory details. A complete successful response is stored atomically as a repository-specific snapshot unless `--no-cache` is set. + +```bash +# Create or replace coverage for the current exact inventory +guardscan config --offline=false +guardscan vuln db update . + +# Inspect freshness and inventory matching +guardscan vuln db status . + +# Use the snapshot without network access +guardscan vuln . --offline +``` + +Snapshots include the inventory digest, source endpoint, creation time, queried coordinates, and OSV records. The default freshness window is seven days. An offline scan fails closed when the snapshot is absent, stale, corrupt, or belongs to a different inventory. + +`--allow-partial` deliberately relaxes that completeness gate. Treat its results as incomplete evidence, not as a clean bill of health. `--refresh` forces online replacement and cannot be combined with offline mode. `--no-cache` prevents snapshot reads and writes, so it cannot supply an offline scan. + +Clear coverage independently of other caches: + +```bash +guardscan vuln db clear . --repo --force +guardscan vuln db clear --all --force +``` + +## Findings and deduplication + +GuardScan groups OSV records and aliases that describe the same advisory. Each result includes a canonical advisory ID, CVE aliases when available, ecosystem/package/version, dependency scope and paths, fixed versions, references, CWE IDs, and a stable SHA-256 fingerprint. + +When a fixed version is known, remediation recommends the lowest published fixed version later than the installed npm version, or the first published fixed version for other ecosystems. If OSV does not publish a fix, GuardScan reports that limitation instead of inventing an upgrade. + +## Severity and known-exploitation data + +Severity is derived from numeric OSV scores, CVSS 3.0/3.1 vectors, or qualitative ecosystem/database severity when available. Numeric scores and supported vectors map to low, medium, high, or critical policy severity. GuardScan preserves malformed or unsupported vectors without trusting them for policy. An advisory whose severity remains unknown is conservatively treated as medium for policy evaluation while retaining `advisorySeverity: "unknown"` in JSON. + +By default, online scans enrich CVE aliases with the official CISA Known Exploited Vulnerabilities catalog. The catalog is validated, size-bounded, fetched only over HTTPS, and cached for the configured vulnerability freshness window (seven days by default). Offline scans reuse the cached catalog—even when stale—and report the enrichment state as `fresh-cache`, `stale-cache`, or `unavailable`. Each ecosystem result includes `knownExploitedEnrichment` metadata, and a vulnerability is marked `knownExploited: true` when any of its CVE aliases occurs in the available catalog. + +When requested enrichment is unavailable, GuardScan emits `knownExploited: "unknown"`, preserves the enrichment source, freshness, status, and error metadata, and marks coverage partial. That condition exits with code `2` unless `--allow-partial` was explicitly supplied. `knownExploited: false` is reserved for a vulnerability that was checked against available KEV data and was not present. Enrichment can be disabled through `vulnerabilities.enrichKnownExploited: false` in `~/.guardscan/config.yml`. + +OSV coverage is advisory-database coverage, not proof of absence. Newly disclosed, private, withdrawn, incorrectly versioned, or ecosystem-specific vulnerabilities may be missing. + +## Formats and CI policy + +The dedicated command supports `table`, `json`, and `sarif`: + +```bash +guardscan vuln . --format table +guardscan vuln . --format json --output vulnerabilities.json +guardscan vuln . --format sarif --output vulnerabilities.sarif +``` + +CI mode defaults `--fail-on` to `high`. You can set a different threshold or a maximum count: + +```bash +guardscan vuln . --ci --fail-on critical +guardscan vuln . --max-vulnerabilities 0 +``` + +Exit codes are stable automation contracts: + +| Code | Meaning | +| --- | --- | +| `0` | Coverage completed and the finding policy passed | +| `1` | Coverage completed, but findings violated the policy | +| `2` | Operational or coverage failure, including missing offline coverage | + +The JSON schema identifier is `guardscan.vulnerability.v1`. SARIF uses SARIF 2.1.0 and points to the official OASIS errata schema. + +## Network and privacy + +OSV queries contain only package ecosystem, package name, and exact version. GuardScan does not include source code in advisory requests. The default endpoint is `https://api.osv.dev`; deployments can configure another compatible endpoint in `~/.guardscan/config.yml`. + +See [Privacy Policy](../PRIVACY.md) for offline boundaries, local cache contents, and telemetry behavior. diff --git a/docs/adrs/001-cloudflare-workers-backend.md b/docs/adrs/001-cloudflare-workers-backend.md index 3ac0c8c..1346e7c 100644 --- a/docs/adrs/001-cloudflare-workers-backend.md +++ b/docs/adrs/001-cloudflare-workers-backend.md @@ -1,17 +1,18 @@ # ADR 001: Cloudflare Workers for Backend Infrastructure ## Status -Accepted + +Superseded by +[ADR 007](./007-retire-hosted-cloudflare-telemetry.md); retained as historical +context for the retired backend. ## Date 2024-11-19 -> **Note (2026-05):** The Worker implementation has moved out of this -> repository to [ntanwir10/GuardScan-Monitoring](https://github.com/ntanwir10/GuardScan-Monitoring) -> (private). This ADR still captures the original platform decision; current -> code, deployment scripts, and the Supabase schema live in that repo under -> `worker/`. The CLI continues to integrate via HTTP only, configurable through -> the `GUARDSCAN_API_URL` env var. +> **Retirement note (2026-08):** This document records the original platform +> decision; it is not an active deployment or product contract. GuardScan no +> longer operates hosted telemetry. Self-hosted synchronization requires an +> explicit `GUARDSCAN_TELEMETRY_URL`. ## Context GuardScan needed a backend infrastructure for optional telemetry and monitoring. The backend requirements were: @@ -152,8 +153,8 @@ We chose **Cloudflare Workers** as the backend infrastructure for GuardScan's op - **Memory**: ~10-20MB per request ## Related Decisions -- [ADR 002: Supabase PostgreSQL](./002-supabase-postgresql.md) - Why we chose Supabase for database - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - Privacy guarantees +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) - Current decision ## References - [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) @@ -162,11 +163,6 @@ We chose **Cloudflare Workers** as the backend infrastructure for GuardScan's op - [Workers KV](https://developers.cloudflare.com/kv/) ## Review -This decision should be reviewed if: -- Cloudflare significantly changes pricing or features -- We need features not available on Workers -- We experience significant operational issues -- A clearly superior alternative emerges - -**Next review date**: 2025-05-19 (6 months) +This historical decision is closed. Any future GuardScan-hosted telemetry +service requires a new ADR and cannot reactivate this one implicitly. diff --git a/docs/adrs/003-privacy-first-architecture.md b/docs/adrs/003-privacy-first-architecture.md index c0186d5..d44e7cf 100644 --- a/docs/adrs/003-privacy-first-architecture.md +++ b/docs/adrs/003-privacy-first-architecture.md @@ -1,323 +1,41 @@ # ADR 003: Privacy-First Architecture ## Status -Accepted -## Date -2024-11-19 +Accepted; amended August 2, 2026. ## Context -GuardScan is a code analysis and security scanning tool that processes potentially sensitive source code. Users rightfully have concerns about: -1. **Code privacy** - Source code should never leave their machines -2. **Data ownership** - Users own their code and analysis results -3. **Vendor trust** - Minimal trust required in third-party services -4. **Compliance** - GDPR, CCPA, SOC 2, ISO 27001 requirements -5. **Transparency** - Clear understanding of what data (if any) is collected - -Traditional SaaS code analysis tools typically: -- Upload source code to cloud servers -- Analyze code on vendor infrastructure -- Store code and results in vendor databases -- Require significant trust from users - -This model is problematic for: -- Enterprises with strict data policies -- Developers working on confidential projects -- Organizations in regulated industries -- Privacy-conscious developers +GuardScan analyzes sensitive repositories. Its product contract must distinguish built-in local scanning, user-selected AI providers, public advisory services, local caches, and optional GuardScan telemetry. ## Decision -We adopted a **privacy-first, client-side architecture** where: - -1. **All code analysis happens locally** on the user's machine -2. **Source code never leaves the user's environment** -3. **Telemetry is optional** and **anonymized** -4. **AI features require user's own API keys** (BYOK - Bring Your Own Key) -5. **No user accounts or authentication required** -6. **Open source** for full transparency - -## Rationale - -### Core Privacy Principles - -1. **Zero Trust Model** - - We don't want access to user code - - We can't see what we don't receive - - No code = no liability, no compliance burden - - Users retain complete control - -2. **Local-First Processing** - - All scanning, analysis, and metrics computed locally - - No dependency on backend availability - - Works completely offline - - Fast (no network latency) - -3. **Optional, Anonymized Telemetry** - - **Opt-in only** (disabled by default with `--no-telemetry`) - - **No source code** sent - - **No file names** or paths - - **Only anonymized metadata**: LOC counts, action types, duration - - **Client ID**: Random UUID (not tied to identity) - - **Repo ID**: Cryptographic hash of git remote URL - -4. **BYOK for AI Features** - - Users provide their own API keys (OpenAI, Claude, Gemini, etc.) - - AI requests go directly from user to AI provider - - We never see API keys or AI requests/responses - - Users control costs and usage - -5. **Open Source Transparency** - - Full source code available on GitHub - - Users can audit exactly what is collected - - Can be forked and self-hosted - - Community can verify privacy claims - -### What We Collect (Optional Telemetry) - -**Metadata Only:** -```json -{ - "clientId": "uuid-generated-locally", // Random UUID, not tied to user - "repoId": "sha256-hash-of-git-remote", // One-way hash - "events": [{ - "action": "scan", // Action type (scan, review, etc.) - "loc": 10000, // Lines of code analyzed - "durationMs": 5000, // How long it took - "model": "gpt-4", // Which AI model used (if any) - "timestamp": 1700000000000, // When it happened - "metadata": { // Generic metadata - "language": "typescript" // Programming language - } - }] -} -``` -**Never Collected:** -- ❌ Source code -- ❌ File names or paths -- ❌ Variable/function names -- ❌ Code structure or AST -- ❌ Security findings (specific vulnerabilities) -- ❌ User identity (name, email, IP) -- ❌ API keys -- ❌ Git commit messages or diffs -- ❌ Environment variables - -### Why Collect Telemetry at All? - -**Product Improvement:** -- Understand which features are used -- Identify performance bottlenecks -- Prioritize development efforts -- Track adoption and growth - -**Error Monitoring:** -- Crash reports (stack traces only, no code) -- API errors (generic errors, no request content) -- Performance issues - -**Business Metrics:** -- Active users (counted anonymously) -- Feature adoption rates -- Geographic distribution (for CDN optimization) - -**Important:** All telemetry is **optional** and can be: -- Disabled with `--no-telemetry` flag -- Disabled in config: `"telemetryEnabled": false` -- Worked around by firewall/network blocks (graceful degradation) +- Built-in static analysis runs locally and does not upload source to GuardScan. +- Cloud AI is BYOK and sends selected context directly to the configured provider; loopback Ollama and LM Studio remain local options. +- Offline mode blocks GuardScan cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. +- Telemetry is disabled by default, requires explicit consent, queues locally, + and is delivered only by `guardscan telemetry sync` to a user-operated HTTPS + collector selected with `GUARDSCAN_TELEMETRY_URL`. +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. The former Cloudflare service at `api.guardscancli.com` is retired + under [ADR 007](./007-retire-hosted-cloudflare-telemetry.md). +- Telemetry contains only event ID, action category, aggregate LOC, duration, coarse execution mode, and timestamp. +- Telemetry excludes installation and repository identifiers, source, paths, prompts, responses, findings, model names, languages, errors, dependency data, and arbitrary metadata. +- Disabling telemetry deletes queued events. Local telemetry is retained for at most 30 days and 1,000 events while consent remains enabled. +- API credentials and source-derived caches are stored locally with restrictive permissions and explicit clearing commands. ## Consequences -### Positive -- **User trust**: Users know their code is safe -- **Compliance**: No data = no GDPR/CCPA/SOC2 compliance burden -- **Performance**: Local analysis is faster than cloud -- **Offline**: Works without internet connection -- **Cost**: No expensive cloud processing -- **Enterprise-friendly**: Meets strictest security policies - -### Negative -- **Limited insights**: Can't see actual code to help debug -- **Harder to support**: Can't reproduce issues without access -- **Feature limitations**: Some features harder without backend - - *Mitigation*: BYOK model for AI features - - *Mitigation*: Local vector embeddings for RAG -- **Adoption metrics**: Less detailed than typical SaaS - - *Mitigation*: Optional telemetry provides sufficient insights - -### Trade-offs - -**What We Give Up:** -1. **Detailed error reports**: Can't see user code causing errors - - *Mitigation*: Stack traces and logs still useful - - *Mitigation*: Users can share code voluntarily for debugging - -2. **Usage analytics**: Less granular than typical SaaS - - *Mitigation*: Anonymized telemetry sufficient for product decisions - - *Mitigation*: User surveys and feedback - -3. **Centralized features**: Can't offer cloud-based features easily - - *Mitigation*: BYOK model for AI - - *Mitigation*: Local-first alternatives (embeddings, caching) - -**What We Gain:** -1. **User trust**: Developers trust tools that respect privacy -2. **Enterprise adoption**: Can be used in highly regulated industries -3. **Competitive advantage**: Differentiation from SaaS competitors -4. **Simplicity**: No user accounts, authentication, or authorization -5. **Lower costs**: No expensive AI API bills on our end - -## Implementation Details - -### Client-Side Architecture -``` -User's Machine: -┌─────────────────────────────────────┐ -│ GuardScan CLI │ -│ │ -│ ┌─────────────────┐ │ -│ │ Code Scanners │ (all local) │ -│ │ - Secrets │ │ -│ │ - OWASP │ │ -│ │ - Dependencies │ │ -│ └─────────────────┘ │ -│ │ -│ ┌─────────────────┐ │ -│ │ AI Features │ (BYOK) │ -│ │ - Code Review │ ────────────┐ │ -│ │ - Explain │ │ │ -│ │ - Test Gen │ │ │ -│ └─────────────────┘ │ │ -│ │ │ -│ ┌─────────────────┐ │ │ -│ │ Telemetry │ (optional) │ │ -│ │ - Anonymizer │ ─────┐ │ │ -│ │ - Batch Sender │ │ │ │ -│ └─────────────────┘ │ │ │ -└──────────────────────────┼──────┼──┘ - │ │ - │ │ - Optional Direct - Metadata API - │ │ - ▼ ▼ - ┌──────────┐ ┌────────┐ - │ Backend │ │ AI │ - │(Our API) │ │Provider│ - └──────────┘ └────────┘ - Stores only User's API - anonymized key required - metadata -``` - -### Privacy Features in Code - -**1. Client ID Generation** -```typescript -// Generated once, stored locally -const clientId = crypto.randomUUID(); -// Never sent to any identity service -// Never tied to user information -``` - -**2. Repo ID Hashing** -```typescript -// One-way hash of git remote URL -const repoId = crypto.createHash('sha256') - .update(gitRemoteUrl) - .digest('hex'); -// Impossible to reverse to original URL -``` - -**3. Telemetry Anonymization** -```typescript -function anonymizeTelemetry(event: TelemetryEvent): SafeTelemetry { - return { - action: event.action, // Generic action name - loc: event.loc, // Numeric count only - durationMs: event.durationMs,// Timing only - model: event.model, // AI model name (if used) - // ❌ No file paths - // ❌ No code content - // ❌ No user identity - // ❌ No specific findings - }; -} -``` - -**4. BYOK Implementation** -```typescript -// User's API key stored locally only -const apiKey = config.get('providers.openai.apiKey'); - -// Direct request to AI provider (not through our backend) -const response = await fetch('https://api.openai.com/v1/chat/completions', { - headers: { - 'Authorization': `Bearer ${apiKey}`, // User's key - }, - body: JSON.stringify({ - messages: [{ role: 'user', content: prompt }] // User controls prompt - }) -}); - -// We never see the request or response -``` - -**5. Offline Mode** -```typescript -// All features work offline except AI (requires user's API key) -if (config.offlineMode) { - // Disable telemetry - // Disable update checks - // All scanning still works -} -``` - -### Privacy Documentation - -1. **README.md**: Clear privacy section -2. **Privacy Policy**: Simple, understandable -3. **Telemetry docs**: Exact data collected -4. **Source code**: Audit-able on GitHub - -## Related Decisions -- [ADR 001: Cloudflare Workers Backend](./001-cloudflare-workers-backend.md) - Backend for optional telemetry -- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) - User brings their own AI keys - -## Compliance - -### GDPR (EU General Data Protection Regulation) -- ✅ No personal data collected (anonymous UUIDs) -- ✅ Opt-in telemetry (consent) -- ✅ Right to be forgotten (delete config file) -- ✅ Data portability (local storage) -- ✅ Transparency (open source) - -### CCPA (California Consumer Privacy Act) -- ✅ No personal information sale -- ✅ Opt-out available (`--no-telemetry`) -- ✅ Data access (local files) -- ✅ Data deletion (delete config) - -### SOC 2 (Service Organization Control 2) -- ✅ Security: No code transmission -- ✅ Availability: Local-first architecture -- ✅ Confidentiality: No code access -- ✅ Processing integrity: Local validation -- ✅ Privacy: No personal data - -## References -- [GDPR Guidelines](https://gdpr.eu/) -- [CCPA Overview](https://oag.ca.gov/privacy/ccpa) -- [Privacy by Design](https://www.privacy-first.com/) -- [Open Source Privacy Benefits](https://opensource.com/article/21/12/open-source-privacy) - -## Review -This decision is fundamental and should rarely change. Review if: -- Major feature requires cloud processing -- Privacy regulations significantly change -- Competitive landscape shifts dramatically +- GuardScan cannot correlate anonymous telemetry across installations or repositories. +- GuardScan receives no first-party product telemetry. A user-operated + collector may receive only explicitly synchronized aggregate events. +- Cloud-provider and advisory-service privacy terms remain separate from GuardScan telemetry. +- The CLI remains useful without a GuardScan backend, account, or network connection. +- Privacy regressions require release-blocking tests for persistent and command-level offline controls. -**Next review date**: 2025-11-19 (1 year) +## Active contract +The self-hosted collector wire schema is `guardscan.telemetry.v1`; see +[API Documentation](../API.md). The earlier client-ID, repository-hash, +free-form metadata, hosted monitoring endpoint, automatic batching, and +`GUARDSCAN_API_URL` designs are retired and are not compatibility contracts. diff --git a/docs/adrs/005-byok-ai-model.md b/docs/adrs/005-byok-ai-model.md index cd0a170..6a44f97 100644 --- a/docs/adrs/005-byok-ai-model.md +++ b/docs/adrs/005-byok-ai-model.md @@ -18,7 +18,7 @@ GuardScan includes AI-enhanced features like code review, explanation, test gene Traditional SaaS approaches: - **Vendor-paid**: Company pays for all AI calls (expensive, unsustainable) - **Subscription**: Users pay monthly fee (bundled cost, inflexible) -- **Credit system**: Pre-purchase credits (complexity, vendor lock-in) +- **Hosted prepaid usage model**: rejected due to complexity and vendor lock-in ## Decision We adopted a **BYOK (Bring Your Own Key)** model where: @@ -95,8 +95,8 @@ Result: Sustainable at any scale ✅ # Create key: sk-proj-... # 2. Configure GuardScan -guardscan config set providers.openai.apiKey sk-proj-... -guardscan config set providers.openai.model gpt-4 +edit ~/.guardscan/config.yml: providers.openai.apiKey sk-proj-... +edit ~/.guardscan/config.yml: providers.openai.model gpt-4 # 3. Use AI features guardscan review src/app.ts @@ -333,12 +333,12 @@ Options: 2. Anthropic (Claude): https://console.anthropic.com/ 3. Ollama (Free, Local): https://ollama.ai/ -Or run: guardscan config set providers.openai.apiKey YOUR_KEY +Or run: edit ~/.guardscan/config.yml: providers.openai.apiKey YOUR_KEY ``` ## Related Decisions - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - Why direct requests -- [ADR 001: Cloudflare Workers Backend](./001-cloudflare-workers-backend.md) - Why no AI proxy +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) - Why GuardScan operates no hosted telemetry or AI proxy ## References - [OpenAI API Pricing](https://openai.com/pricing) @@ -353,4 +353,3 @@ This decision is strategic and should rarely change. Review if: - User feedback strongly negative **Next review date**: 2025-05-19 (6 months) - diff --git a/docs/adrs/006-node-sea-standalone-distribution.md b/docs/adrs/006-node-sea-standalone-distribution.md new file mode 100644 index 0000000..8c5c583 --- /dev/null +++ b/docs/adrs/006-node-sea-standalone-distribution.md @@ -0,0 +1,125 @@ +# ADR 006: Node.js SEA for standalone GuardScan distribution + +## Status + +Accepted + +Acceptance records the architecture and its host-native feasibility evidence. It +does not authorize publication by itself: production artifacts remain gated by +exact-source builds, platform signing/notarization, provenance, and release +verification. + +## Date + +2026-07-25 + +## Context + +GuardScan's npm package requires Node.js. Homebrew, Scoop, WinGet, Chocolatey, and an optional `pipx` channel need an immutable executable that runs when Node and npm are absent from `PATH`. Those channels must install the same GuardScan implementation and may not bootstrap through npm, `npx`, or an unversioned download. + +The CLI currently has characteristics that constrain the builder: + +- TypeScript compiles to CommonJS and the entry point uses dynamic imports for command modules. +- Package metadata is imported at runtime in several modules. +- TypeScript is a runtime dependency for AST-backed features. +- `tiktoken` and chart rendering are optional. Chart rendering includes native bindings and must degrade cleanly when unavailable. +- Node.js SEA executes one embedded script. Its injected `require()` loads built-ins only, so all required JavaScript dependencies must be bundled into that script. +- SEA code cache and V8 snapshots are platform-specific. They cannot be used for a cross-platform build. +- macOS and Windows executable mutation affects platform signatures, so final signing must happen after SEA blob injection. + +## Decision + +Use a two-stage host-native build: + +1. Bundle the compiled CLI and required JavaScript dependencies into one CommonJS file with a pinned `esbuild`. +2. Generate a Node.js SEA blob with `useCodeCache: false` and `useSnapshot: false`, copy the exact CI Node executable, inject the blob with a pinned `postject`, smoke-test it with Node absent from `PATH`, and only then archive and sign it. + +Each target is built on its native hosted runner: + +- macOS arm64 and x64 on macOS runners; +- Linux x64 and arm64 glibc on Linux runners; +- Windows x64 on a Windows runner. + +The host-platform feasibility stage emits explicitly non-publishable prototype metadata and cannot be consumed by adapter rendering. A separate production artifact stage generates publishable metadata only after archive reproducibility, platform signing, provenance, and the full standalone smoke contract pass. + +`tiktoken` and `chartjs-node-canvas` remain external optional capabilities. The standalone executable reports token estimates and omits chart images when those modules are unavailable. Core static scanning, dependency inventory, vulnerability snapshot use, and SPDX/CycloneDX SBOM generation remain required capabilities. + +Python wheels, if approved, bundle the exact already-tested platform executable. The Python package contains only a small launcher and metadata; it does not contain a second GuardScan implementation and does not download a runtime during installation or first use. + +## Rationale + +Node SEA keeps the runtime aligned with the implementation and avoids an unsupported JavaScript-runtime fork. A one-file CommonJS bundle satisfies SEA's module-loading constraint and lets the existing CLI remain the source of behavior. + +Host-native builds make signing and smoke testing explicit and avoid platform-specific code-cache or snapshot hazards. Disabling both features trades some startup optimization for portability and lower release risk. + +The alternatives were rejected for the initial implementation: + +- `pkg` and similar archived bundlers introduce a second runtime patch set and uncertain support for current Node releases. +- `bun build --compile` would make Bun runtime compatibility a product contract that the current Node-oriented test suite does not establish. +- Shipping a shell, Python, or PowerShell bootstrapper that downloads Node/npm violates offline, immutability, and install-time execution requirements. +- Reimplementing the CLI in Python for PyPI would create divergent behavior and release identities. + +## Consequences + +### Positive + +- Native package managers can install one immutable executable without Node after the production launch gates pass. +- npm and standalone channels retain one implementation and version. +- Every target is built and tested where its signing and runtime behavior can be observed. +- Optional native modules cannot block core standalone startup. +- PyPI can remain a thin transport for the same executable. + +### Negative + +- The executable includes a Node runtime and will be materially larger than the npm package. +- Bundling the TypeScript compiler increases artifact size. +- Optional accurate tokenization and chart rendering are unavailable in the initial standalone capability profile. +- Five host-native builds, notarization, Authenticode, and provenance add release cost. +- `postject` is an additional release-critical dependency and must remain exactly pinned, lockfile-verified, and covered by artifact smoke tests. + +## Implementation details + +- Builder dependencies are development-only, exact-version pinned, and never installed by end users. +- The bundle target is the minimum supported runtime (`node22`), CommonJS, one output file, no code splitting. +- The builder fails on unresolved required imports and externalizes only an explicit allowlist of optional native packages. +- Prototype output includes source version, commit, platform, architecture, Node runtime, bundle and executable SHA-256, size, capabilities, and `productionReady: false`. +- Smoke tests run `--version`, `--help`, an offline static-only scan, SBOM generation, and telemetry status in an isolated home. `PATH` excludes Node and package managers. +- The production artifact stage generates release archives with normalized paths, modes, ownership, timestamps, and ordering, then inspects their structure and digest before they can enter a release manifest. +- Production manifests require exact versioned GitHub Release URLs, checksums, signature evidence, and provenance. Adapters cannot render from prototype metadata. +- macOS signing/notarization and Windows signing happen after injection. Release publication fails if signature verification is unavailable or incomplete. + +## Implementation evidence and production launch gates + +The host-native CI feasibility matrix passes on all five supported targets, and +the implementation enforces the bundle allowlist, Node-free `PATH`, core static +scan, both SBOM formats, telemetry-disabled state, deterministic archive +structure, and production-evidence boundary. That evidence is sufficient to +accept Node SEA as the distribution architecture. + +Every release must still pass these launch gates before any artifact is marked +or advertised as production-ready: + +- Build and test each executable from the exact protected tag on its native host. +- Confirm required CLI commands pass with Node and package managers absent from `PATH`. +- Confirm bundle analysis has no undeclared runtime or filesystem dependency. +- Exercise missing optional modules and verify the documented reduced capability profile. +- Prove production archive reproducibility and archive extraction safety. +- Verify macOS notarization, Windows Authenticode, Linux Sigstore evidence, checksums, SBOMs, attestations, and provenance against the exact downloadable artifacts. + +## Related decisions + +- [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) +- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) + +## References + +- Node.js 22 single executable applications documentation +- esbuild JavaScript build API +- GuardScan multi-channel distribution and launch plan + +## Review + +Review after the first signed stable release, or whenever the Node, esbuild, +postject, target-platform, or signing contract changes. + +**Next review date**: 2026-08-15 diff --git a/docs/adrs/007-retire-hosted-cloudflare-telemetry.md b/docs/adrs/007-retire-hosted-cloudflare-telemetry.md new file mode 100644 index 0000000..d1eeafc --- /dev/null +++ b/docs/adrs/007-retire-hosted-cloudflare-telemetry.md @@ -0,0 +1,95 @@ +# ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry + +## Status + +Accepted. + +Supersedes [ADR 001](./001-cloudflare-workers-backend.md). + +## Date + +2026-08-02 + +## Context + +GuardScan originally operated an optional telemetry backend on Cloudflare +Workers at `api.guardscancli.com`. The current CLI is local-first, has no +account requirement, and already supports a stricter model: telemetry is +disabled by default, queued locally after explicit consent, and synchronized +only when the user names a collector and runs `guardscan telemetry sync`. + +Continuing to operate a first-party ingestion service adds infrastructure, +privacy, retention, incident-response, and legacy-client obligations without +being necessary for the CLI product. The private `GuardScan-Monitoring` +repository may still support future Prometheus/Grafana work, but it is not a +GuardScan telemetry service or a compatibility promise to CLI users. + +## Decision + +- Permanently retire the GuardScan-hosted Cloudflare Worker and + `api.guardscancli.com` telemetry service. +- GuardScan will not provide a default or implicit telemetry endpoint. +- Self-hosted telemetry remains available only through an explicit + `GUARDSCAN_TELEMETRY_URL` value and the `guardscan telemetry sync` command. +- The legacy `GUARDSCAN_API_URL` setting is not part of the new contract. +- Consent allows events to be queued locally; it does not authorize background + delivery. Queued events remain local until an explicit successful sync or an + explicit clear/disable/reset action. +- After `1.1.0` is published, the retired endpoint will return `410 Gone` for + seven days without accepting, storing, or redirecting telemetry. The hosted + route is then deleted. Ownership of the apex domain and GuardScan website is + retained. +- `GuardScan-Monitoring` remains private and unchanged for possible future + Grafana work. Reusing it for CLI ingestion would require a new ADR and a new + explicit user contract. + +## Migration + +- Users of `1.0.5` and earlier should upgrade to `1.1.0` or disable telemetry + before the hosted endpoint is retired: + + ```bash + guardscan config --telemetry=false + ``` + +- Operators of a compatible self-hosted collector must replace + `GUARDSCAN_API_URL` with `GUARDSCAN_TELEMETRY_URL`. +- No queued event is migrated to GuardScan or another provider. Existing local + queues remain on the user's machine and retain the documented inspection, + explicit-sync, and deletion controls. +- The retired endpoint must not redirect. Older clients followed redirects, + which could forward telemetry to a different origin without a new user + decision. + +## Consequences + +### Positive + +- GuardScan no longer operates a telemetry ingestion or storage service. +- The network contract is explicit, local-first, and controlled by the user or + self-hosted collector operator. +- Cloudflare deployment, Worker, KV, DNS-route, and telemetry-data obligations + can be retired independently of the CLI. + +### Negative + +- GuardScan receives no first-party product telemetry. +- Existing self-hosted operators must update the environment variable name. +- Older clients may attempt the retired endpoint until they are upgraded or + telemetry is disabled; the temporary `410 Gone` response makes that failure + explicit without forwarding or accepting data. + +## Unchanged contracts + +- `guardscancli.com`, the GuardScan website, schema identifiers, package + metadata, and SBOM namespaces remain active. +- BYOK AI requests continue to go directly to the provider selected by the + user. +- Local static analysis, advisory lookups, update checks, and the private + `GuardScan-Monitoring` roadmap are separate decisions. + +## Related decisions + +- [ADR 001: Cloudflare Workers for Backend Infrastructure](./001-cloudflare-workers-backend.md) +- [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) +- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 5c82ef7..5750223 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -16,23 +16,31 @@ Architecture Decision Records (ADRs) are a lightweight way to document important ### Backend & Infrastructure - [ADR 001: Cloudflare Workers for Backend](./001-cloudflare-workers-backend.md) - - **Status**: Accepted - - **Summary**: Use Cloudflare Workers for optional telemetry backend due to global performance, zero ops, and cost-effectiveness + - **Status**: Superseded by [ADR 007](./007-retire-hosted-cloudflare-telemetry.md) + - **Summary**: Historical platform decision for the retired hosted telemetry backend -- [ADR 002: Supabase PostgreSQL for Database](./002-supabase-postgresql.md) - - **Status**: Accepted - - **Summary**: Use Supabase PostgreSQL for telemetry storage due to REST API compatibility with Workers and excellent developer experience +ADR number 002 is intentionally unused. An earlier index entry referenced a +file that was never present in this repository, so it is not an active or +recoverable architecture decision. ### Architecture & Design - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - **Status**: Accepted - - **Summary**: All code analysis happens locally, source code never sent to servers, optional anonymized telemetry only + - **Summary**: Local analysis, direct BYOK providers, and optional explicit synchronization to user-operated telemetry collectors - [ADR 005: BYOK (Bring Your Own Key) AI Model](./005-byok-ai-model.md) - **Status**: Accepted - **Summary**: Users provide their own AI API keys for AI features, enabling privacy, cost control, and sustainability +- [ADR 006: Node.js SEA for standalone distribution](./006-node-sea-standalone-distribution.md) + - **Status**: Proposed + - **Summary**: Bundle GuardScan into host-native Node.js single executables for native package managers and optional pipx wheels + +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) + - **Status**: Accepted + - **Summary**: Retire the hosted endpoint while preserving explicit user-operated telemetry collectors + ### Development & Tooling - [ADR 004: TypeScript Strict Mode](./004-typescript-strict-mode.md) @@ -152,4 +160,3 @@ If you have questions about ADRs or need help writing one: - Open a GitHub Discussion - Ask in the team chat - Refer to existing ADRs as examples - diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..e6ea815 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,873 @@ +# GuardScan multi-channel distribution and launch plan + +## Active execution plan — v1.1.0 release closure (2026-08-02) + +### Objective + +Move GuardScan from a clean, CI-green `release/1.1.0` implementation branch to +an operational RC-first release train that publishes `1.1.0-rc.1`, observes it +for 24 hours, and promotes the unchanged stable source to `1.1.0` across npm, +GitHub Releases, the shared Homebrew/Scoop catalog, PyPI, WinGet, and +Chocolatey. Homebrew Core remains an optional discovery channel and must never +block the first-party Homebrew tap or the stable release. + +The plan separates repository work from external authority boundaries. Code, +tests, workflow definitions, documentation, and bootstrap branches are +automatable. Account creation, legal agreements, trusted-publisher approval, +Apple identity, Azure identity, and registry moderation require provider-owned +state and cannot be simulated by repository changes. + +### Current baseline + +- Source: clean `release/1.1.0` at `d94a07a5cf9c2bf06723cdcbb3ef80f3d6982636`. +- Review: PR #32 is open and draft; its exact-head 27-job CI run is green. +- Public state: npm and GitHub stop at `1.0.5`; every new 1.1.0 channel is + unpublished. +- Automation: release workflows exist only on the release branch, while + GitHub requires dispatch and scheduled workflows to exist on the default + branch. `RELEASE_AUTOMATION_ENABLED` is intentionally false. +- Provider state: release environments exist but are empty; the GitHub App, + signing identities, moderated-registry credentials, and trusted publishers + are not proven. The current local GitHub CLI credential is invalid. +- Shared catalog: `ntanwir10/homebrew-tap` is public, protected, and healthy in + its intentionally empty bootstrap state. + +### Architecture and operating decisions + +1. Keep `release/1.1.0` as the stable product source. Land inert workflow + definitions on `main` through a separate bootstrap PR, then merge that + bootstrap commit back into the release branch before deriving the RC. +2. Keep automation disabled until every release environment passes a + credential/preflight rehearsal. Tags alone never authorize publication. +3. Use the exact tested npm tarball for npm, pnpm, Yarn, and Bun. These clients + are compatibility channels, not independent publications. +4. Use the exact signed standalone executable for GitHub, the shared catalog, + WinGet, Chocolatey, and the platform wheels published to PyPI. +5. Keep Homebrew and Scoop in one generated, cryptographically locked catalog. + GuardScan is authoritative; the catalog is a projection and reports back by + signed dispatch plus scheduled reconciliation. +6. Treat Homebrew Core as optional. Include it in release state only when an + explicit provider setting enables submission. Never advertise + `brew install guardscan` until Core acceptance and a public canary succeed. +7. Treat WinGet and Chocolatey `submitted`, `accepted`, and `verified` as + different append-only states. Expected moderation delay is pending, not + failure. +8. Use tests first for behavioral fixes. Workflow text/structure tests must + reproduce each release bug before the YAML or release logic is changed. +9. Keep the Cloudflare retirement downstream of verified stable publication: + seven days of `410 Gone`, then deletion, with no collection or redirect. + +### Dependency graph + +```text +Release-content truth (changelog, docs, ADR, command contracts) + | + +--> Workflow correctness (PR readiness, per-version canaries, + | moderation states, native TestPyPI lifecycle) + | | + | +--> Default-branch workflow bootstrap + | | + | +--> GitHub App and repository protections + | +--> npm/PyPI trusted publishers + | +--> Apple/Azure signing + | +--> WinGet/Chocolatey publisher onboarding + | | + | +--> v1.1.0-rc.1 publication + | | + | +--> 24-hour canary evidence + | | + | +--> v1.1.0 promotion + | | + | +--> moderated acceptance + | +--> Homebrew Core optional PR + | +--> Cloudflare retirement + +--> Whole-product acceptance expansion -----------------------^ +``` + +### Phase 1 — release-content and product-contract truth + +#### Task 1.1 — Reconcile the 1.1.0 changelog + +**Description:** Move every 1.1.0-bound entry out of `Unreleased`, merge it into +one authoritative `1.1.0` section, and harden source validation so a stable +release cannot pass with populated release notes stranded above its version. + +**Acceptance criteria:** + +- One stable `1.1.0` section contains all release changes, including hosted + telemetry retirement. +- Candidate derivation adds only RC identity and does not duplicate stable + release notes. +- A focused regression test fails for the former split-changelog structure. + +**Verification:** `npm run test:release`; `npm run release:validate`. + +**Dependencies:** None. **Scope:** Medium, 3-4 files. + +#### Task 1.2 — Correct public command syntax and command inventory + +**Description:** Align README and quick-start examples with the actual +Commander definitions for `review`, `docs`, `test-gen`, and `refactor`, and add +a contract test that checks documented invocations against `--help` output. + +**Acceptance criteria:** + +- No documented positional argument is accepted only in prose. +- The documented 29-command inventory matches registered commands. +- Documentation tests fail if command syntax drifts again. + +**Verification:** focused documentation/CLI test; full build and tests. + +**Dependencies:** None. **Scope:** Small, 2-3 files. + +#### Task 1.3 — Promote the standalone ADR from proposal to accepted evidence + +**Description:** Define the exact evidence required to change ADR 006 from +`Proposed` to `Accepted`. Repository feasibility can accept the architecture; +production signing evidence remains an explicit launch gate. + +**Acceptance criteria:** + +- ADR status and evidence distinguish architectural acceptance from provider + rehearsal. +- Reduced standalone capabilities remain explicit and machine-readable. +- Documentation never claims chart rendering or accurate tokenization in SEA. + +**Verification:** release documentation contract tests and review. + +**Dependencies:** Task 1.1. **Scope:** Small, 2 files. + +#### Task 1.4 — Expand whole-product acceptance evidence + +**Description:** Replace placeholder RAG assertions with deterministic local +index/search/chat tests, and maintain an acceptance matrix that separates +offline core behavior, optional external tools, mocked BYOK providers, and live +provider rehearsals. + +**Acceptance criteria:** + +- RAG tests exercise real repository indexing and retrieval with deterministic + local doubles only at the network/provider boundary. +- Every advertised command has more than a help-only contract or is explicitly + classified as requiring an external tool/provider. +- No CI test requires paid API credentials or uploads source code. + +**Verification:** focused RAG/command tests; full coverage suite. + +**Dependencies:** Task 1.2. **Scope:** split into multiple medium follow-ups. + +### Phase 2 — release workflow correctness + +#### Task 2.1 — Make canary execution version-safe + +**Description:** Stop selecting the first active train as a shared checkout. +Use default-branch release tooling for ledger recording while every matrix item +continues to verify its own immutable public version. + +**Acceptance criteria:** + +- Two simultaneous active trains cannot use one another's tag as tooling. +- Manual single-version canaries remain supported. +- Workflow contract tests reproduce and prevent the former first-train bug. + +**Verification:** release workflow tests and YAML parse. + +**Dependencies:** Phase 1 plan only. **Scope:** Small, 2 files. + +#### Task 2.2 — Preserve pending moderation states + +**Description:** Make WinGet and Chocolatey absence during normal review an +explicit pending result. Only install, signature, checksum, or invocation +failures after discovery may produce `failed`. + +**Acceptance criteria:** + +- “Package not public yet” remains pending. +- A discovered package that fails installation/invocation is failed. +- Reconciliation keeps polling pending channels without opening an incident. + +**Verification:** workflow regression test plus release-state unit tests. + +**Dependencies:** None. **Scope:** Small, 2-3 files. + +#### Task 2.3 — Add idempotent moderated-registry submission evidence + +**Description:** Capture WinGet PR identity and Chocolatey package submission +identity, distinguish matching prior submissions from integrity conflicts, and +persist these identities in the ledger. + +**Acceptance criteria:** + +- Rerunning a matching submission records/reuses the same identity. +- Conflicting remote content stops as an integrity incident. +- WinGet submission and catalog acceptance are separate states. + +**Verification:** mocked remote-contract tests and workflow text contracts. + +**Dependencies:** Task 2.2. **Scope:** Medium, 4-5 files. + +#### Task 2.4 — Test every TestPyPI wheel natively before PyPI + +**Description:** After TestPyPI publication, install the exact version with pip +and pipx on Linux x64/arm64, macOS x64/arm64, and Windows x64. Production PyPI +must depend on the complete matrix. + +**Acceptance criteria:** + +- All five platform tags select and run their intended executable. +- Version, help, offline scan, and uninstall pass for pip and pipx. +- PyPI publication cannot start if any native TestPyPI lifecycle fails. + +**Verification:** workflow structure tests, then hosted RC rehearsal. + +**Dependencies:** Task 2.1. **Scope:** Medium, 2 files. + +#### Task 2.5 — Exercise reduced standalone capabilities + +**Description:** Add a deterministic runtime capability diagnostic and smoke +that actually invokes token-count estimation and chart-unavailable degradation +with optional native modules absent. + +**Acceptance criteria:** + +- SEA reports `accurateTokenCounting=false` and demonstrates estimated token + counting without `tiktoken`. +- A report path that requests charts completes safely without + `chartjs-node-canvas` and records the reduced capability. +- Artifact metadata is derived from the observed smoke, not an unconditional + boolean. + +**Verification:** failing focused tests first, local package tests, five hosted +SEA jobs. + +**Dependencies:** Task 1.3. **Scope:** Medium, 4-5 files. + +#### Task 2.6 — Make release-PR readiness zero-touch and fail closed + +**Description:** Candidate preparation must inspect the exact PR, mark the +bot-owned release PR ready when allowed, and fail if it is closed, from a fork, +or no longer targets `main`. Promotion still requires unchanged head and all +required checks. + +**Acceptance criteria:** + +- The current draft state cannot silently deadlock promotion. +- Untrusted/fork PRs never receive release credentials or tags. +- Ready state, base branch, head SHA, and mergeability are recorded. + +**Verification:** workflow contract tests and a non-publishing GitHub rehearsal. + +**Dependencies:** Tasks 2.1-2.5. **Scope:** Small, 2 files. + +#### Task 2.7 — Make Homebrew Core honestly optional + +**Description:** Remove phantom planned state when Core submission is not +configured. If enabled, render, validate, submit, record PR identity, poll +acceptance, and verify public installation without blocking required channels. + +**Acceptance criteria:** + +- Stable completion is possible with the first-party tap while Core is off. +- When on, Core progresses `submitted -> accepted -> verified` with evidence. +- User docs expose `brew install guardscan` only after verification. + +**Verification:** release-state tests, renderer tests, workflow contracts. + +**Dependencies:** Tasks 2.2-2.3. **Scope:** split into medium implementation +and external onboarding tasks. + +### Phase 3 — default-branch bootstrap and repository controls + +#### Task 3.1 — Create an inert workflow-bootstrap change + +**Description:** Prepare a branch from `origin/main` containing the reusable +release workflows and their default-branch entrypoints, with automation still +off. Do not merge the product release or create a release tag. + +**Acceptance criteria:** + +- GitHub lists the train, canary, build, publish, and Release Please workflows + on `main` after merge. +- Schedules and dispatches are inert while the repository variable is false. +- Bootstrap CI passes and no registry receives a publication request. + +**Verification:** PR checks, GitHub workflow listing, dry dispatch rejection. + +**Dependencies:** Phase 2 complete. **Scope:** remote Git/GitHub operation. + +#### Task 3.2 — Finish GitHub App and protection setup + +**Description:** Install `guardscan-release-bot` on GuardScan and the shared +catalog; configure short-lived token inputs; protect release tags and restrict +ledger writes to the App while preserving break-glass recovery. + +**Acceptance criteria:** + +- No long-lived general-purpose token publishes a release. +- Release tags and ledger writes are limited to the intended identity. +- Catalog notifications authenticate cross-repository and are idempotent. + +**Verification:** permission-negative tests from a fork and positive dry runs. + +**Dependencies:** Valid GitHub authentication and Task 3.1. + +#### Task 3.3 — Merge bootstrap truth back into the stable release PR + +**Description:** Merge `main` into `release/1.1.0`, resolve only release-owned +files, rerun the exact source gate, and confirm PR #32 remains unchanged after +the final approved head is selected. + +**Acceptance criteria:** clean worktree, green exact-SHA CI, PR ready for +review, no unreviewed product change after RC derivation. + +**Verification:** full local gate and hosted CI. + +**Dependencies:** Tasks 3.1-3.2. + +### Phase 4 — provider onboarding and production rehearsals + +#### Task 4.1 — npm and PyPI trusted publishing + +Configure exact workflow/environment bindings for npm, TestPyPI, and PyPI; +rehearse OIDC issuance; verify provenance; remove the legacy `NPM_TOKEN` only +after OIDC succeeds. + +#### Task 4.2 — Apple and Windows signing + +Provision Developer ID/notary credentials and Azure Artifact Signing OIDC; +build, sign, notarize/timestamp, staple, and verify representative artifacts. + +#### Task 4.3 — WinGet and Chocolatey publisher authority + +Complete the Microsoft CLA and scoped GitHub credential; confirm Chocolatey +publisher ownership and API key; submit non-public validation fixtures where +the provider allows it. + +#### Task 4.4 — Homebrew Core optional authority + +Configure a narrowly scoped contribution credential and enable the optional +channel only after the first-party stable formula is verified. + +#### Task 4.5 — Credential-expiry monitoring + +Add scheduled checks for certificate dates, App installation health, OIDC +bindings, and provider identity revalidation, with alerts that contain no +secret material. + +**Phase 4 acceptance:** every required environment passes a preflight, secrets +stay out of logs/artifacts, fork PRs cannot mint tokens, and automation remains +off until the final go/no-go decision. + +### Phase 5 — RC, soak, stable promotion, and recovery + +#### Task 5.1 — Publish `v1.1.0-rc.1` + +Enable automation and dispatch the candidate from `main` using PR #32. Publish +npm `next`, an immutable GitHub prerelease, TestPyPI then PyPI `1.1.0rc1`, and +isolated shared-catalog preview branches. Render but do not submit RC WinGet or +Chocolatey packages. + +#### Task 5.2 — Complete the 24-hour soak + +Require at least 24 hourly samples per required channel, fresh final evidence, +unchanged source head, no high/critical dependency vulnerability, valid public +digests/signatures, and no open release/security incident. + +#### Task 5.3 — Promote stable `v1.1.0` + +Materialize the machine promotion decision, auto-merge the unchanged PR, +rebuild/sign/attest from the exact merge commit, publish npm `latest`, GitHub, +PyPI, the shared catalog, then submit WinGet and Chocolatey. + +#### Task 5.4 — Reconcile moderated channels and optional Core + +Poll external review without misclassifying delay. Mark release completion only +when every required selected channel is verified; keep optional Core evidence +separate. + +#### Task 5.5 — Execute post-release Cloudflare retirement + +After stable public verification, serve seven days of `410 Gone` without +collection or redirect, monitor old-client traffic only through aggregate +infrastructure signals if already available, then remove the hosted Worker and +DNS/runtime resources according to ADR 007. + +### Verification checkpoints + +#### Checkpoint A — repository correctness + +- Focused regression tests are red before each behavioral fix and green after. +- `npm run typecheck`, `npm run build`, `npm run test:release`, release package + verification, documentation contracts, and `git diff --check` pass. + +#### Checkpoint B — whole product + +- Full coverage suite passes in band. +- Production dependency audit has no high or critical result. +- Packed npm artifact and all five package-manager smokes pass. +- Offline scan, SPDX, CycloneDX, telemetry disabled, and declared optional + degradation pass from installed artifacts. + +#### Checkpoint C — hosted release feasibility + +- Exact-SHA 27-job or expanded CI matrix passes. +- Five signed native artifacts pass platform verification. +- Five TestPyPI wheels pass native pip/pipx lifecycles. + +#### Checkpoint D — public RC + +- Manifest, signatures, SBOMs, checksums, attestations, URLs, and public bytes + agree for every artifact. +- Required channel canaries remain green for 24 hours. + +#### Checkpoint E — stable completion + +- Every required public install command reports `1.1.0`. +- Moderated channels are tracked as pending/submitted until truly public. +- Rollback/forward-fix procedures are executable without overwriting artifacts. + +### Risks and mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Workflow bootstrap accidentally publishes | Critical | Keep automation false; no provider credentials in bootstrap environments | +| RC source changes during soak | Critical | Bind candidate metadata to PR head and fail promotion on any head change | +| Registry rerun overwrites/conflicts | Critical | Digest preflight; identical means continue, different means incident | +| Apple/Azure setup blocks native launch | High | Rehearse before enabling; never publish unsigned substitutes | +| Moderation lasts days | Medium | Separate submitted/accepted/verified; do not block already verified first-party channels | +| Homebrew Core rejects formula | Low | First-party tap remains supported; Core stays optional | +| Shared catalog drifts | High | One generated projection, lock file, protected CI, dispatch plus reconciliation | +| Optional native modules crash SEA | High | Exercise actual fallback paths and bind observed capabilities into metadata | +| Documentation overstates availability | High | Publish install commands only from verified ledger state | +| Legacy Cloudflare clients keep sending | Medium | Stable upgrade notice, seven-day 410 window, then teardown | + +### External authority boundaries + +Implementation may prepare every command and validation, but it must stop for +the owner when a provider requires identity verification, MFA, legal terms, +certificate purchase/renewal, CLA acceptance, trusted-publisher approval, or +moderator action. These are not code defects and must not be bypassed with +long-lived or over-scoped credentials. + +Status: historical design record, superseded by the approved RC-first zero-touch release train. This document does not authorize a release by itself. The current operating contract is in `docs/RELEASE_AUTOMATION.md`, and provider onboarding is in `docs/RELEASE_ONBOARDING.md`. + +## Implementation status — 2026-07-25 + +The complete local implementation now includes: + +- an append-only, hash-chained release ledger and machine-generated 24-hour promotion decision; +- exact-source RC derivation and stable promotion through a protected GitHub App identity; +- production artifact manifests covering npm, five signed SEA archives, and five executable-bearing PyPI wheels; +- OIDC publication for npm and PyPI, immutable GitHub assets, and generated Homebrew, Scoop, WinGet, and Chocolatey adapters; +- scheduled public canaries, deterministic reconciliation, and append-only rollback/forward-fix planning; +- required local and hosted gates for Node 22/24/26, npm/pnpm/Yarn/Bun, offline scan, SPDX, CycloneDX, signatures, provenance, and package lifecycle checks. + +Local tests and contract gates pass. Hosted signing, registry publication, the 24-hour RC soak, and moderated-channel acceptance remain pending the one-time external onboarding in `docs/RELEASE_ONBOARDING.md`. + +## Outcome + +Ship GuardScan through trustworthy, testable installation channels without creating divergent implementations: + +- npm remains the canonical Node package. +- npm-compatible clients (pnpm, Yarn, and Bun) consume that same npm package; they are compatibility targets, not separate publications. +- GitHub Releases becomes the canonical source for versioned standalone executables and their integrity metadata. +- Homebrew, Scoop, WinGet, and Chocolatey become thin installation adapters over immutable release artifacts. +- PyPI publishes platform wheels that bundle the exact signed release binary and expose a console entry point; it does not invoke npm, npx, or an unverified runtime download. +- Every channel carries the same GuardScan version and has an explicit install, upgrade, uninstall, smoke-test, rollback, and ownership contract. +- Routine future releases are prepared, built, verified, published, and monitored through reusable automation; a machine policy promotes an unchanged RC after its 24-hour soak without hand-edited versions, checksums, URLs, manifests, or human approval. + +## Historical baseline at plan approval + +- The repository package is `guardscan@1.1.0`; npm `latest` is currently `1.0.5`. +- The npm package has a valid `bin` entry and Node shebang, and the package smoke suite verifies packed contents and important offline/privacy contracts. +- CI tests Node 18/20/22/24/26 on Ubuntu and Windows, but package smoke runs only with Node 24 and does not test macOS or invoke a globally installed `guardscan` shim. +- The declared runtime floor is Node 18 even though Node 18 and Node 20 are end-of-life. Optional native chart dependencies also have a narrower Node 18 floor than the package declaration. +- Tag releases publish npm first and then create a notes-only GitHub Release. There are no standalone binaries, checksums, release manifest, signatures, attestations, Homebrew formulae, Python distributions, Chocolatey packages, Scoop manifests, or WinGet manifests. +- Release logic currently lives in one growing CI workflow, has no reusable release-tool contract, and does not persist enough machine-readable state to safely resume a partially published multi-channel release. +- The current worktree is a large, dirty remediation branch. It must be split and reviewed before it becomes a release source. + +## Architectural decisions + +1. **One implementation and one version.** The TypeScript/Node CLI remains the product implementation. Adapters may launch it but may not fork behavior. +2. **Separate runtime-bearing and runtime-free channels.** npm, pnpm, Yarn, and Bun installs require a supported Node runtime unless GuardScan explicitly passes a future Bun-runtime compatibility gate. Native channels use standalone executables and must run with Node absent from `PATH`. +3. **Build once per immutable tag.** A release workflow creates all publishable artifacts from one protected tag/commit, tests the exact artifacts, then promotes them. Downstream package metadata references those exact assets and SHA-256 values. +4. **No install-time execution of unversioned code.** Installers may download only versioned immutable assets over HTTPS and must verify checksums. PyPI will not bootstrap through npm/npx, and package adapters will not use a `latest` download URL. +5. **No big-bang multi-registry publish.** Ship in checkpoints: npm-compatible clients, standalone artifacts, first-party native channels, community registries, then optional PyPI. +6. **Truthful support labels.** Documentation identifies channels as stable, preview, community-reviewed, or deferred. Availability in a package manager is not described as runtime compatibility with that package manager's JavaScript engine. +7. **Rollback is designed before publishing.** Every channel gets an emergency procedure and retained-version policy before its first stable release. +8. **One declarative release authority.** `cli/package.json` remains the product-version source, while a generated release manifest becomes the authority for artifact names, digests, URLs, capabilities, and channel rendering. Derived data is never copied manually between workflows or package-manager files. +9. **Thin workflows, testable release code.** GitHub Actions YAML coordinates jobs and permissions; deterministic version resolution, manifest generation, validation, rendering, and release-state logic lives in small, unit-tested scripts that run identically locally and in CI. +10. **Generated adapters with checked-in review diffs.** Homebrew, Scoop, WinGet, Chocolatey, optional PyPI, and documentation snippets are rendered from templates plus the release manifest. Automation opens reviewable update pull requests instead of directly mutating every downstream stable channel. +11. **Idempotent, resumable publication.** Each publish step records immutable input/output identity and treats an already-published matching artifact as success; a mismatched artifact is a hard failure. A retry resumes from verified state and never rebuilds or overwrites the version. +12. **Cost-aware verification tiers.** Pull requests run fast package and generator checks, release candidates run the full platform/artifact matrix, stable promotion reuses those exact tested artifacts, and scheduled canaries use a minimal representative matrix. +13. **One authoritative repository and one shared generated catalog.** + GuardScan owns release state. `ntanwir10/homebrew-tap` contains both + `Formula/guardscan.rb` and `bucket/guardscan.json`, bound by + `channel-lock.json`; it never changes GuardScan state directly. A + post-merge dispatch reduces latency and 30-minute reconciliation guarantees + convergence. The repositories do not use submodules, subtrees, mirroring, + or bidirectional synchronization. +14. **Homebrew Core is an optional discovery layer.** The first-party tap is + the stable fallback. After a stable release, automation may submit a + source-building Homebrew Core formula using Homebrew `node` and + `std_npm_args`; Core acceptance and its public canary are tracked but do not + block the release. + +## Target channel matrix + +| Channel | Publication model | Initial user command | Initial status | +| --- | --- | --- | --- | +| npm | Publish `guardscan` to npm with trusted publishing | `npm install -g guardscan` | Canonical Node channel | +| pnpm | Reuse npm package; no second publication | `pnpm add -g guardscan` or `pnpm dlx guardscan@VERSION` | Phase 1 | +| Yarn Modern | Reuse npm package; prefer one-shot/project-local use | `yarn dlx guardscan@VERSION` | Phase 1 | +| Yarn Classic | Reuse npm package; legacy compatibility only | `yarn global add guardscan` | Best effort, tested separately | +| Bun | Reuse npm package; Node remains required until proven otherwise | `bun install --global guardscan` or `bunx guardscan@VERSION` | Phase 1 preview until matrix passes | +| GitHub Releases | Signed/attested archives per OS/CPU plus manifest/checksums | Download a versioned archive | Phase 2 canonical binary channel | +| Homebrew | Formula in the shared first-party catalog referencing immutable release assets | `brew install ntanwir10/tap/guardscan` | Phase 3; Core later | +| Homebrew Core | Source-building formula using Homebrew `node` | `brew install guardscan` | Optional after stable acceptance and public canary | +| Scoop | JSON manifest in the same shared catalog referencing the Windows portable archive | `scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap`, then `scoop install guardscan` | Phase 3 | +| WinGet | Community manifest referencing signed Windows artifact | `winget install .GuardScan` | Phase 3 after binary stability | +| Chocolatey | `.nupkg` referencing or embedding the official Windows artifact | `choco install guardscan` | Phase 3 after binary stability | +| PyPI/pipx | Platform wheels bundling the exact standalone binary | `pipx install guardscan-cli` (name to be reserved) | Phase 4 decision gate | +| GHCR | Versioned OCI image for CI and isolated scans | `docker run ... ghcr.io/ntanwir10/guardscan:VERSION` | Optional Phase 4 | + +## Workstream 0: release baseline and product contract + +### GS-DIST-001 — Split and stabilize the current release candidate + +- **Depends on:** none. +- **Files/systems:** current worktree, changelog, branch protection, CI. +- **Scope:** split the broad remediation into independently reviewable commits or stacked pull requests; preserve user changes; reconcile the `1.1.0` changelog and release notes; run all existing release gates from a clean checkout. +- **Acceptance:** no unexplained dirty files; every release-critical change is reviewed; `git diff --check`, typecheck, build, full tests, coverage, lint ratchet, audit, package smoke, and packed-artifact inspection pass on the exact release commit. +- **Verification:** clone or checkout the protected commit into a clean worktree and execute the documented release-gate sequence. + +### GS-DIST-002 — Define supported runtimes, operating systems, and capabilities + +- **Depends on:** GS-DIST-001. +- **Files:** `cli/package.json`, CI matrix, README/support policy, optional-chart code and tests. +- **Scope:** raise the supported Node floor to a non-EOL line, recommended Node `>=22`; define stable OS/CPU targets; state whether chart rendering is optional; distinguish npm-installed and standalone capabilities. +- **Acceptance:** package metadata, docs, CI, native dependency behavior, and error messages agree; unsupported runtimes fail clearly; no EOL Node line is advertised as supported. +- **Verification:** test the exact minimum Node release plus current supported LTS lines; test core CLI both with and without optional native chart dependencies. + +### GS-DIST-003 — Reserve names and secure publisher identities + +- **Depends on:** none. +- **Systems:** npm, GitHub, Homebrew tap, Chocolatey, PyPI/TestPyPI, WinGet, Scoop. +- **Scope:** search for existing packages and ownership conflicts; reserve available names; define publisher IDs; enable hardware-backed 2FA; document owners and recovery contacts; use OIDC/trusted publishing where supported. +- **Acceptance:** each proposed identifier has an owner and collision decision; no production credential is stored in the repository; at least two trusted maintainers can recover release access. +- **Verification:** read-only ownership audit and a non-production/TestPyPI or draft-package authentication rehearsal. + +### GS-DIST-004 — Automate version, changelog, and release-candidate preparation + +- **Depends on:** GS-DIST-001. +- **Files:** release automation configuration, `cli/package.json`, `cli/package-lock.json`, `cli/CHANGELOG.md`, contribution/release docs. +- **Scope:** select and configure a maintained release-PR mechanism suitable for this single-package repository; derive the next semantic version from reviewed change metadata; update package and lock versions plus changelog in one reviewable pull request; create a protected tag only from the merged release commit. +- **Acceptance:** maintainers do not manually synchronize version strings or changelog headings; a no-change run is a no-op; prerelease and stable versions are deterministic; breaking/minor/patch intent is visible before merge. +- **Verification:** fixture-based dry runs for patch, minor, major, prerelease, no-change, and malformed-history cases, followed by a non-publishing release-PR rehearsal. + +## Workstream 1: npm-compatible client launch + +### GS-DIST-101 — Harden the npm artifact and global binary contract + +- **Depends on:** GS-DIST-001, GS-DIST-002. +- **Files:** `cli/package.json`, `cli/package-lock.json`, `cli/scripts/package-smoke.js`, `.github/workflows/ci.yml` or a dedicated package workflow. +- **Scope:** test an actual global install and the generated `guardscan` shim, not only `node dist/index.js`; validate `--version`, `--help`, initialization, offline static scan, SBOM, telemetry-disabled state, upgrade, and uninstall; add macOS. +- **Acceptance:** the packed tarball passes on Linux, macOS, and Windows using the supported Node floor and primary LTS; optional native dependencies either install successfully or produce documented graceful degradation. +- **Verification:** PR jobs install the locally packed tarball globally in isolated homes with install scripts both enabled and disabled. + +### GS-DIST-102 — Add pnpm, Yarn, and Bun compatibility smoke jobs + +- **Depends on:** GS-DIST-101. +- **Files:** new package-manager smoke script(s), CI workflow, install documentation. +- **Scope:** test pnpm global and `dlx`; Yarn Modern `dlx` and project-local execution; Yarn Classic global only as legacy coverage; Bun global and `bunx`; pin tested package-manager versions in CI. +- **Acceptance:** each advertised command launches the packed GuardScan artifact, reports the expected version, completes a non-destructive offline scan, and leaves telemetry disabled. Bun documentation explicitly says Node is required unless a Node-free Bun runtime test passes. +- **Verification:** run the client matrix against the local tarball on PRs and against `guardscan@VERSION` after publication. Do not create extra application lockfiles solely to publish through these clients. + +### GS-DIST-103 — Add post-publication registry canaries + +- **Depends on:** GS-DIST-102. +- **Files/systems:** release workflow, scheduled workflow. +- **Scope:** after npm publish, install the exact version through npm, pnpm, Yarn Modern, and Bun; add a daily read-only canary for the latest stable version. +- **Acceptance:** a release is not marked complete until the registry version is resolvable and the client matrix passes; failures produce a channel-status issue or alert without collecting end-user telemetry. +- **Verification:** rehearse with an npm prerelease/dist-tag before the stable release. + +## Workstream 2: standalone artifact foundation + +### GS-DIST-201 — Decide the standalone build technology through an ADR and spike + +- **Depends on:** GS-DIST-002. +- **Files:** `docs/adrs/`, isolated build prototypes, no production packaging switch until approved. +- **Scope:** compare Node Single Executable Applications, a maintained Node bundler/packager, and Bun compilation against GuardScan's dynamic imports, `package.json` access, schemas, optional native modules, subprocesses, and private-state behavior. +- **Acceptance:** the ADR records security maintenance, licensing, platform coverage, binary size/startup, native-module support, asset embedding, reproducibility, code signing, and failure behavior. The chosen prototype passes the core offline smoke suite with Node removed from `PATH`. +- **Verification:** build and execute prototypes on native Linux, macOS, and Windows runners; reject any option that silently omits required scanners or schemas. + +### GS-DIST-202 — Make the CLI bundle/standalone safe + +- **Depends on:** GS-DIST-201. +- **Files:** entrypoint/module loading, schema asset resolution, version loading, report/chart boundaries, build configuration and focused tests. +- **Scope:** eliminate unsupported dynamic loading in the selected builder; embed or colocate versioned schemas; isolate optional chart rendering; preserve safe project-code and offline policies. +- **Acceptance:** help/version/startup do not load optional native modules; static scan and both SBOM formats work; unsupported optional features return explicit status rather than crashing; no source token, environment file, test fixture, or local state is embedded. +- **Verification:** inspect artifact contents and run adversarial package smoke in temporary homes and untrusted fixture repositories. + +### GS-DIST-203 — Build the initial native target matrix + +- **Depends on:** GS-DIST-202. +- **Files:** dedicated release workflow and packaging scripts. +- **Scope:** initially target `darwin-arm64`, `darwin-x64`, `linux-x64-gnu`, `linux-arm64-gnu`, and `windows-x64`; defer Alpine/musl and Windows ARM64 until tested. +- **Acceptance:** each archive uses a stable name such as `guardscan-vVERSION-OS-ARCH`; the executable reports the tag version and operates with no Node installation; archives include license/notices. +- **Verification:** build on native runners where practical and run install, help, init, offline scan, SBOM, upgrade-replacement, and uninstall/removal tests. + +### GS-DIST-204 — Add release integrity, provenance, and machine-readable metadata + +- **Depends on:** GS-DIST-203. +- **Files:** release workflow, `release-manifest` schema/script, security and install docs. +- **Scope:** generate SHA-256 sums, per-artifact SBOMs, GitHub artifact attestations, and a release manifest containing version, commit, target, size, digest, URL, capability flags, and signature/attestation references; version the manifest schema, use stable key/list ordering, define compatibility and migration policy, retain a release-evidence bundle, and add macOS signing/notarization plus Windows code-signing workstreams. +- **Acceptance:** every executable asset is represented in the manifest and checksums; users have documented verification commands; signing failures block stable promotion; release assets are immutable after promotion; the evidence bundle records resolved metadata, tool versions, validation results, and artifact inventory for future audits and resumes. +- **Verification:** verify checksums and attestations from a clean machine; verify platform signatures using native tools; verify the manifest against its schema; run golden compatibility tests against current and prior supported manifest versions. + +### GS-DIST-205 — Create native artifact smoke and compatibility gates + +- **Depends on:** GS-DIST-204. +- **Files:** native smoke harness, CI workflows, fixtures. +- **Scope:** test no-Node execution, offline/no-egress behavior, privacy defaults, file permissions, path handling, Unicode/spaces, exit codes, project-code opt-in, malformed state, and optional feature degradation. +- **Acceptance:** every target passes before release promotion; failures identify target and capability; no target is published with a reduced capability set unless the release manifest and docs say so. +- **Verification:** exact downloaded release assets, not rebuilt substitutes, pass the same tests after draft upload. + +### GS-DIST-206 — Build deterministic package-adapter renderers + +- **Depends on:** GS-DIST-204, GS-DIST-500. +- **Files:** versioned adapter templates, renderer modules, golden fixtures, native-validator wrappers. +- **Scope:** define the narrow input/output contract that converts one release manifest into Homebrew, Scoop, WinGet, Chocolatey, optional PyPI, and installation-document metadata; keep rendering pure and side-effect free; emit stable, reviewable output with generated-file provenance headers where the format permits. +- **Acceptance:** identical manifest/template inputs produce byte-identical output; every emitted version, URL, digest, architecture, and capability comes from canonical metadata; generated output passes its ecosystem validator before any channel publication. +- **Verification:** golden tests, repeated-build identity checks, malformed/unknown-schema fixtures, and each native package-manager validator against non-publishing fixtures. + +## Workstream 3: native package-manager adapters + +### GS-DIST-301 — Launch a first-party Homebrew tap + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** shared `ntanwir10/homebrew-tap` catalog, formula + template/update automation, `channel-lock.json`, release docs. +- **Scope:** generate `Formula/guardscan.rb` as a binary adapter using immutable + versioned assets and checksums; support Apple Silicon, Intel macOS, and + Linuxbrew where artifacts exist; avoid self-update behavior. Update it in the + same catalog PR and lock as the Scoop manifest. +- **Acceptance:** `brew audit`, `brew style`, install, test, upgrade, and uninstall pass; formula test executes `guardscan --version` and a safe offline command; formula version/digest match the release manifest. +- **Verification:** test from a clean macOS runner on both available + architectures and a Linuxbrew runner. After a stable release, optionally + submit a separate source-building formula to `homebrew/core` using Homebrew + `node` and `std_npm_args`; only advertise `brew install guardscan` after Core + acceptance and a clean public canary. + +### GS-DIST-302 — Launch Scoop and prepare WinGet + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** `bucket/guardscan.json` in the shared + `ntanwir10/homebrew-tap` catalog, WinGet manifests or submission automation. +- **Scope:** create an architecture-aware Scoop manifest with immutable URLs + and SHA-256; map the executable to `guardscan`; generate it in the same + catalog PR and cryptographic lock as the Homebrew formula; define update + automation and retained-version behavior. +- **Acceptance:** Scoop install/update/uninstall and `checkver` pass; WinGet manifests validate and pass Windows Sandbox install/upgrade/uninstall before submission. +- **Verification:** test Windows without Node installed, and verify the executable hash against the release manifest before and after each adapter install. + +### GS-DIST-303 — Launch Chocolatey + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** Chocolatey packaging source, `.nuspec`, install/uninstall scripts, community repository account. +- **Scope:** package the official portable Windows artifact or download it from its immutable release URL; enforce checksum verification; include license, project URLs, release notes, and silent install behavior; automate version/checksum updates only after the first package is approved. +- **Acceptance:** `choco pack`, package validation, local install, upgrade, uninstall, verification, and cleanup pass in Windows Sandbox; no unversioned URL or mutable script executes; community moderation requirements are satisfied. +- **Verification:** local feed test followed by a prerelease/community moderation rehearsal; stable documentation is enabled only when the public package is approved. + +## Workstream 4: PyPI decision and optional launcher + +### GS-DIST-401 — Validate Python-channel product value and naming + +- **Depends on:** GS-DIST-205, GS-DIST-003. +- **Files:** ADR/product decision, no PyPI publication yet. +- **Scope:** measure whether Python users need a PyPI-native installation path; check `guardscan` and `guardscan-cli` ownership; compare platform-wheel maintenance with the simpler native channels. +- **Acceptance:** an explicit go/no-go decision identifies supported Python versions, operating systems, wheel tags, package name, support burden, and deprecation policy. +- **Verification:** user/support evidence and a TestPyPI prototype. Default decision is no-go if the package only wraps npm/npx or downloads unverified code at runtime. + +### GS-DIST-402 — If approved, build a thin, offline-capable Python launcher + +- **Depends on:** approved GS-DIST-401, GS-DIST-204. +- **Files:** separate Python packaging directory or repository, `pyproject.toml`, launcher, wheel tests, trusted-publishing workflow. +- **Scope:** build platform-specific wheels that contain the exact version-matched GuardScan executable and expose the `guardscan` console command; publish with PyPI trusted publishing; prefer `pipx install` in docs. +- **Acceptance:** installation performs no npm/npx bootstrap and no first-run download; wheel version equals GuardScan version; install, run, upgrade, uninstall, offline use, and artifact identity pass on every declared wheel target. +- **Verification:** build/check wheels, install from TestPyPI with pipx in clean environments, compare embedded binary digest with the release manifest, then rehearse yanking a test release. + +## Workstream 5: release orchestration, operations, and launch + +### GS-DIST-500 — Establish the reusable release automation foundation + +- **Depends on:** GS-DIST-004, GS-DIST-101. +- **Files:** focused `cli/scripts/release/` modules and tests, release-manifest schema, `.gitignore`, reusable CI workflow(s), maintainer documentation. +- **Scope:** define stable commands for `plan`, `prepare`, `validate`, `render`, `dry-run`, `publish`, `status`, and `resume`; separate pure planning/rendering/validation from credentialed mutation; keep each command deterministic and non-interactive; define a versioned release-manifest and release-state schema; make workflow YAML call these commands instead of reimplementing logic in shell fragments. +- **Acceptance:** local and CI runs produce byte-equivalent metadata from the same inputs; every command supports check-only behavior and structured output; generated files cannot drift unnoticed; release scripts are explicitly included by `.gitignore` and package/repository checks. +- **Verification:** unit and golden-fixture tests plus a workflow test that compares local and CI-generated manifest output for the same commit. + +### GS-DIST-501 — Replace the linear tag workflow with staged promotion + +- **Depends on:** GS-DIST-103, GS-DIST-204, GS-DIST-500. +- **Files:** split CI/release workflows, GitHub environments, release scripts. +- **Scope:** compose small reusable workflows for validation, target builds, artifact tests, signing/attestation, draft release, registry publication, adapter updates, and canaries. Use protected-tag and environment gates, a single-release concurrency lock, matrix builds for independent targets, and immutable artifact handoffs so stable promotion never rebuilds release inputs. +- **Acceptance:** version/tag mismatch, duplicate version, missing artifact, + failed signature, failed smoke, denied machine promotion policy, or changed + artifact identity blocks promotion; reruns never overwrite a released version; + channel status and artifact lineage are visible in a concise job summary. +- **Verification:** full prerelease dry run with an intentionally failed channel, concurrent release attempt, cancellation, and safe retry/resume using the original tested artifacts. + +### GS-DIST-502 — Harden the release supply chain + +- **Depends on:** GS-DIST-501. +- **Files/systems:** Actions workflows, npm/PyPI publisher configuration, repository settings. +- **Scope:** pin third-party actions to reviewed commit SHAs; minimize workflow permissions; use GitHub environments and OIDC; remove long-lived npm-token fallback after trusted publishing is proven; enable tag protection and immutable releases; retain provenance and SBOMs; configure grouped, reviewed updates for Actions, builders, signing tools, release dependencies, schemas, and package-manager test versions. +- **Acceptance:** release jobs have least privilege; no reusable long-lived publish secret exists where OIDC is available; workflow provenance links to the protected source commit; automated dependency updates cannot merge without the complete release-contract dry run and named-owner review. +- **Verification:** permissions review, secret inventory, provenance verification, and a release from an authorized environment only. + +### GS-DIST-503 — Document channel-specific rollback and incident response + +- **Depends on:** first implementation of each channel. +- **Files:** release runbook, security docs, maintainer checklist, CODEOWNERS. +- **Scope:** define npm dist-tag/deprecation response, GitHub release revocation guidance, Homebrew tap revert, Scoop manifest rollback, WinGet replacement/removal, Chocolatey unlisting/superseding, and PyPI yanking; retain historical artifacts according to policy; assign primary and backup release owners plus credential-recovery responsibility. +- **Acceptance:** each channel has primary/backup owners, rollback command/process, communication template, and maximum expected response; release automation has CODEOWNERS coverage; no plan relies on overwriting an existing version. +- **Verification:** tabletop exercise using a fake compromised/broken version and a non-production channel. + +### GS-DIST-504 — Publish truthful installation and support documentation + +- **Depends on:** the relevant channel acceptance gate. +- **Files:** root/CLI README, quick start, website/docs, support matrix, security verification guide, changelog. +- **Scope:** provide stable/preview labels, prerequisites, exact install/update/uninstall commands, Node requirement for JS-client installs, no-Node promise for native artifacts, checksum/attestation verification, privacy/offline expectations, and known limitations; generate the channel/support/version table and command snippets from canonical metadata where practical. +- **Acceptance:** no command is documented before its public artifact exists and passes canaries; all docs identify canonical source, version policy, and support route. +- **Verification:** automated documentation command checks plus manual copy/paste tests on clean systems. + +### GS-DIST-505 — Add distribution health without product telemetry + +- **Depends on:** GS-DIST-103 and native channel launch. +- **Files/systems:** scheduled CI, issue automation/status documentation. +- **Scope:** query public registry/release metadata and perform clean installs on a schedule; compare every public channel with the canonical manifest; detect version drift, broken URLs, checksum mismatches, expired signing credentials, and install failures; encode expected moderation lag, maximum version skew, severity, and reconciliation action for each channel. +- **Acceptance:** stale or broken channels create one idempotent actionable issue or update pull request at the correct severity; expected moderation lag does not create alert noise; no user machine, repository, or usage data is collected. +- **Verification:** inject fixture mismatches inside and outside each channel's allowed lag and prove detection, deduplication, escalation, and reconciliation behavior. + +### GS-DIST-506 — Automate downstream channel update pull requests + +- **Depends on:** GS-DIST-206, first implementation of each adapter. +- **Files/systems:** release renderers/templates, renderer fixtures, shared + `ntanwir10/homebrew-tap` catalog, moderated-registry packaging sources, + documentation snippets. +- **Scope:** use GS-DIST-206 output to open one reviewable shared-catalog PR + containing the Homebrew formula, Scoop manifest, and + `guardscan.channel-catalog.v1` lock. Catalog CI refetches the immutable + release manifest and rerenders from the exact GuardScan source commit. + Catalog merge sends a `catalog_updated` dispatch as a hint; GuardScan + independently validates the merged commit and reconciles every 30 minutes. +- **Acceptance:** no adapter contains a hand-copied version, URL, or checksum; + `render --check` fails on drift; unchanged output creates no commit or pull + request; same-release digest conflicts open an integrity incident; remote + identities bind the catalog commit and channel path. The catalog never + independently updates GuardScan. +- **Verification:** golden tests for every renderer, lock schema/digest tests, + native manifest validation, missed-dispatch recovery, catalog + older/exact/conflicting/unexpected-newer fixtures, and a dry-run against the + shared catalog fixture. + +### GS-DIST-507 — Make releases dry-runnable, observable, and safely resumable + +- **Depends on:** GS-DIST-500, GS-DIST-501. +- **Files/systems:** release-state schema, orchestration scripts, workflow summaries/artifacts, runbook. +- **Scope:** persist a release ledger containing commit, version, artifact + digests, signatures, attestations, per-channel publication identity, machine + policy decisions, and errors; add `dry-run`, `status`, and `resume` paths; + query remote state before every mutation; never infer completion only from a + previous job's exit code. +- **Acceptance:** maintainers can determine exactly what published and what remains from one status command; retrying a completed matching step is harmless; conflicting remote state stops with an actionable error; dry-run performs every validation without publishing. +- **Verification:** deterministic scenarios for no-op rerun, failure before publication, failure after one channel, lost CI job state, remote match, remote conflict, and successful resume. + +### GS-DIST-508 — Automate release-system upkeep + +- **Depends on:** GS-DIST-500, GS-DIST-502. +- **Files/systems:** dependency-update configuration, scheduled workflows, CODEOWNERS, signing/publisher inventory. +- **Scope:** automatically propose reviewed updates for Actions, release tools, package managers, schemas, and packaging templates; alert before certificate, token, or publisher-configuration expiry; assign release-code ownership; run a scheduled non-publishing rehearsal and stale-channel audit. +- **Acceptance:** automation dependencies have named owners and update cadence; release-critical updates must pass the dry-run/artifact matrix; expiring credentials or signing identities alert with enough lead time; two maintainers can execute recovery. +- **Verification:** dependency-update fixture, simulated expiry alert, CODEOWNERS review routing, and scheduled rehearsal on a synthetic version. + +### GS-DIST-509 — Enforce a maintainable release-automation contract + +- **Depends on:** GS-DIST-500 through GS-DIST-508. +- **Files:** release developer guide, test fixtures, contribution checklist, CI policy checks. +- **Scope:** document module boundaries and the procedure for adding a target or channel; cap shell/YAML duplication; require schema migration notes for release-manifest changes; keep platform-specific behavior inside adapters; define retention and deprecation rules for obsolete automation. +- **Acceptance:** adding a channel requires only a renderer/template, native validator, smoke test, rollback entry, and ownership metadata; core orchestration does not need channel-specific branching; all release-state/schema changes are backward-compatible or explicitly migrated. +- **Verification:** implement a no-publish fixture adapter and demonstrate that it participates in render, validate, dry-run, status, and drift checks without modifying orchestration. + +## Optional follow-on channels + +- **GHCR image:** valuable for CI and hermetic use. Run as a non-root user, support read-only repository mounts plus an explicit output mount, publish multi-architecture images by digest, and attest them. +- **Shell/PowerShell installer:** only after signed standalone assets exist. Default to a user-local directory, accept an explicit version, verify checksum/signature, and avoid `curl | sh` as the only documented path. +- **APT/RPM:** add only after usage justifies repository signing, mirror operations, distro compatibility, and long-term update maintenance. +- **Homebrew Core:** submit only after a stable first-party release. Build from + the tested npm source tarball with Homebrew `node` and `std_npm_args`; keep + Core acceptance non-blocking and switch the primary install command to + `brew install guardscan` only after public-Core verification. The tap remains + the fallback. + +## Release checkpoints + +### Checkpoint A — Node ecosystem ready + +- GS-DIST-001 through GS-DIST-103 plus GS-DIST-500 complete. +- Release `1.1.0` or its successor to npm only after clean exact-artifact tests. +- Document npm, pnpm, Yarn, and Bun commands with accurate Node prerequisites. +- A routine Node-only release can be prepared through one release PR, exercised + in dry-run mode, machine-gated, and resumed without manually editing version + or registry metadata. + +### Checkpoint B — Binary foundation ready + +- GS-DIST-201 through GS-DIST-206 complete. +- GitHub Release has tested, signed/attested artifacts, checksums, SBOMs, and manifest. +- No native package-manager publication before this checkpoint. + +### Checkpoint C — Native channels ready + +- The first-party Homebrew formula and Scoop manifest pass together from the + shared, cryptographically locked catalog. +- WinGet and Chocolatey submissions follow after Windows signing and stable binary canaries. +- Channel rollback runbooks are exercised. + +### Checkpoint D — Optional ecosystem expansion + +- PyPI proceeds only on an approved ADR and platform-wheel prototype. +- GHCR is favored before PyPI when CI/container users have clearer demand. + +## Principal risks and controls + +| Risk | Control | +| --- | --- | +| Divergent behavior across wrappers | One implementation; exact-version binary digest checks | +| EOL runtime exposure | Raise Node floor and test only supported release lines | +| Native module/bundler failure | Builder spike; isolate optional charts; exact native smoke | +| Registry partial release | Draft/staged promotion; idempotent channel jobs; visible status | +| Release logic duplicated in YAML/shell | Thin reusable workflows backed by unit-tested release modules | +| Release cannot resume after CI loss | Persisted release ledger plus remote identity checks and idempotent steps | +| Supply-chain compromise | OIDC, least privilege, pinned actions, signatures, attestations, immutable assets | +| Package-name collision | Reserve names and document canonical publisher IDs before launch | +| Channel drift | Generated manifests from one release manifest plus scheduled canaries | +| Misleading Bun/PyPI claims | Explicit runtime labels and go/no-go decision gates | +| Unsustainable maintenance | Launch checkpoints, named owners, and rollback/upgrade tests per channel | +| Automation dependency or certificate decay | Reviewed automated updates, rehearsals, ownership, and expiry alerts | + +## Definition of multi-channel launch complete + +- A clean protected commit produced all artifacts for one version. +- Every advertised installation command has passed install, version/help, offline smoke, upgrade, and uninstall on its supported platforms. +- npm and standalone artifacts have verifiable provenance; native artifacts have checksums and platform signatures where applicable. +- All channels resolve to the same product version and artifact identity. +- Routine releases require one reviewed release PR and a machine-approved + 24-hour RC soak, not manual edits across package-manager files. +- The release can be dry-run, inspected, retried, and resumed from persisted machine-readable state without rebuilding or overwriting artifacts. +- Channel manifests and install documentation are generated and drift-checked + from the canonical release manifest; shared-catalog state is bound by its lock + and exact merged commit. +- Privacy, offline, safe-execution, SBOM, and exit-code contracts remain unchanged across channels. +- Documentation, support ownership, monitoring, and rollback are live before stable channel labels are applied. diff --git a/tasks/session-handoff-2026-08-02.md b/tasks/session-handoff-2026-08-02.md new file mode 100644 index 0000000..d2ac514 --- /dev/null +++ b/tasks/session-handoff-2026-08-02.md @@ -0,0 +1,125 @@ +# GuardScan 1.1.0 Release Session Handoff + +Checkpoint date: 2026-08-02 + +Branch: `release/1.1.0` + +Starting commit before this work: `d94a07a` + +## Purpose + +This checkpoint preserves the in-progress implementation of the zero-touch +GuardScan `1.1.0-rc.1` to `1.1.0` release train. It is intentionally a WIP +checkpoint: do not publish, enable release automation, or create release tags +from this commit until the remaining security fixes and full verification gate +are complete. + +## Completed in this session + +- Added the detailed execution plan and working checklist in `tasks/plan.md` + and `tasks/todo.md`. +- Tightened changelog and release-train channel contracts. +- Added real deterministic RAG end-to-end acceptance tests. +- Added a public `guardscan capabilities --json` contract and standalone + reduced-capability evidence. +- Hardened standalone artifact validation and release-manifest capability + metadata. +- Added cross-platform package-manager, TestPyPI, pip, pipx, native artifact, + and exact-version canary coverage. +- Added deterministic WinGet tooling pinning and stronger WinGet/Chocolatey + submission evidence. +- Added functional acceptance documentation and corrected public command + examples. +- Marked ADR 006 accepted while retaining signing/notarization launch gates. +- Added a credential-health workflow and its contract tests. +- Began executable rollback and forward-fix orchestration. + +## Verification completed before the final WIP edits + +- `release-workflows.test.ts`: 14/14 passing after the canary and publisher + hardening changes. +- Standalone focused tests: 33/33 passing. +- Release-contract suite: 86/86 passing before the final credential-health and + rollback edits. +- Documentation command contracts: 7/7 passing. +- Typecheck passed before the final workflow/recovery edits. + +These results are historical evidence only. The entire gate must be rerun from +this checkpoint because later edits were not covered. + +## Critical blockers found by the final audit + +1. Privileged workflow shell injection: `workflow_dispatch` inputs such as + `release_pr`, `version`, and `known_good` are interpolated directly into + shell in `release-train.yml`; the manual version path in + `release-canary.yml` has the same pattern. Move inputs through `env`, validate + them before privileged steps, and quote variables. +2. Stable publication is not resumable after the release PR has merged. Add an + idempotent resume path bound to the persisted promotion decision, merge SHA, + and stable tag. +3. Rollback/recovery can deadlock the train. Finish channel recovery, + active-version removal, forward-fix creation, and protected-ledger evidence. +4. The release ledger currently substitutes one manifest digest for provider + identities that actually have distinct npm tarball and PyPI wheel digests. + Persist and validate per-provider evidence. +5. WinGet/Chocolatey moderation can remain pending forever after rejection or + closure. Model rejected, corrected, and resubmitted states explicitly. +6. Promotion verifies the release PR head but not the soaked base/tree. Record + the base SHA and require the stable merge tree to equal the approved RC tree, + or force a new RC when `main` changes during the soak. + +## Work interrupted at checkpoint + +- The rollback/forward-fix workflow implementation was interrupted while being + edited. Review `release-train.yml`, release event/state schemas, + `events.js`, `reconcile.js`, `index.js`, and `recovery-source.js` as one unit. +- The credential-health workflow implementation and its five focused tests were + reported green, but the worker's final audit was interrupted. +- A read-only whole-release audit was interrupted after reporting the six + blockers above. +- Four placeholder assertions remain in + `cli/__tests__/integration/ai-providers-enhanced.test.ts` and should be + replaced with deterministic decorator-composition tests. +- `cli/src/core/cost-guard.ts` contains a `resetDailyBudget()` placeholder; its + product and command behavior still need review. + +## Required next sequence + +1. Read this handoff, `tasks/plan.md`, and `tasks/todo.md`; inspect `git status` + and the checkpoint diff. +2. Fix all six audit blockers before running or enabling privileged workflows. +3. Review and complete the interrupted rollback/forward-fix implementation. +4. Finish the placeholder-functionality review. +5. Run from `cli/`: + - `npm run typecheck` + - `npm run build` + - `npm run test:release` + - `npm test -- --coverage --runInBand` + - `npm run lint:ratchet` + - `git diff --check` + - `npm audit --omit=dev --audit-level=high` + - `npm run test:package` + - `npm run test:package-manager` + - `npm pack --dry-run` using a temporary npm cache if needed +6. Split the verified WIP checkpoint into reviewable implementation commits if + desired; never publish from a dirty or unverified source tree. +7. Reauthenticate GitHub CLI. At checkpoint time `gh auth status` reported an + invalid token for `ntanwir10`. +8. Bootstrap inert release automation on the default branch with automation + disabled, then complete the provider-owned onboarding steps. +9. Only after all gates and onboarding are verified, create and soak + `v1.1.0-rc.1`; promote stable from the exact approved tree after 24 hours. + +## External boundaries still outstanding + +- GitHub App installation and protected environments. +- npm and PyPI trusted publishers. +- Apple Developer signing and notarization identity. +- Azure Artifact Signing OIDC resources. +- Chocolatey publisher credentials and moderation. +- WinGet credential/CLA and upstream acceptance. +- First-party Homebrew/Scoop shared-catalog publication and hosted native + validation. + +The release is not published, the 24-hour RC soak has not started, and no +public channel should be considered verified at this checkpoint. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..419e321 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,176 @@ +# GuardScan distribution launch checklist + +## Active v1.1.0 execution checklist — 2026-08-02 + +### Phase 1 — release truth and product contracts + +- [ ] 1.1 Merge populated `Unreleased` content into one authoritative `1.1.0` changelog section. +- [ ] 1.1 Add a regression test and validator for split stable release notes. +- [ ] 1.2 Correct README positional/option syntax for review, docs, test-gen, and refactor. +- [ ] 1.2 Add a documented-command contract test for all 28 commands. +- [ ] 1.3 Accept ADR 006 architecture with production signing evidence still gated. +- [ ] 1.4 Replace placeholder RAG assertions with deterministic indexing/retrieval behavior. +- [ ] 1.4 Classify each command as offline-proven, external-tool, mocked-provider, or live-rehearsal. + +### Phase 2 — workflow correctness + +- [ ] 2.1 Reproduce and fix first-active-train canary checkout selection. +- [ ] 2.2 Reproduce and fix WinGet/Chocolatey pending moderation classification. +- [ ] 2.3 Persist idempotent WinGet PR and Chocolatey submission identities. +- [ ] 2.4 Add five-platform native TestPyPI pip/pipx lifecycle jobs. +- [ ] 2.5 Exercise token-estimation and chart-unavailable SEA fallbacks. +- [ ] 2.5 Derive standalone capability evidence from observed smoke results. +- [ ] 2.6 Make candidate preparation validate and ready the exact trusted release PR. +- [ ] 2.7 Remove phantom Homebrew Core state when optional submission is disabled. +- [ ] 2.7 Track Core submission, acceptance, and public verification when enabled. + +### Checkpoint A — local repository gate + +- [ ] Focused regressions pass. +- [ ] `npm run typecheck` passes. +- [ ] `npm run build` passes. +- [ ] `npm run test:release` passes. +- [ ] Full coverage suite passes. +- [ ] Lint ratchet and `git diff --check` pass. +- [ ] Production dependency audit passes. +- [ ] npm pack and package-manager smokes pass. + +### Phase 3 — GitHub bootstrap and controls + +- [ ] 3.1 Reauthenticate GitHub CLI as `ntanwir10`. +- [ ] 3.1 Create inert workflow-bootstrap branch/PR from `origin/main`. +- [ ] 3.1 Confirm release workflows are registered on `main` with automation off. +- [ ] 3.2 Install/configure `guardscan-release-bot` on GuardScan and shared catalog. +- [ ] 3.2 Protect release tags and restrict ledger writes to the release identity. +- [ ] 3.2 Configure and verify cross-repository catalog notifications. +- [ ] 3.3 Merge bootstrap truth back into `release/1.1.0` and rerun exact-SHA CI. +- [ ] 3.3 Mark PR #32 ready and complete real review. + +### Phase 4 — provider onboarding + +- [ ] 4.1 Configure and rehearse npm trusted publishing; remove `NPM_TOKEN` after success. +- [ ] 4.1 Configure and rehearse TestPyPI/PyPI pending trusted publishers. +- [ ] 4.2 Configure Apple Developer ID and notarization credentials. +- [ ] 4.2 Configure Azure Artifact Signing and GitHub OIDC federation. +- [ ] 4.3 Complete Microsoft CLA and configure scoped WinGet token. +- [ ] 4.3 Confirm Chocolatey ownership and configure API key. +- [ ] 4.4 Configure optional Homebrew Core submission authority, or leave it disabled. +- [ ] 4.5 Add credential/certificate/App health and expiry monitoring. + +### Checkpoint B — provider rehearsal + +- [ ] Fork PRs cannot access secrets or mint publication OIDC tokens. +- [ ] Five native targets are signed and verified on native runners. +- [ ] Five TestPyPI wheels pass pip and pipx lifecycle tests. +- [ ] Registry preflights distinguish absent, identical, and conflicting state. +- [ ] `RELEASE_AUTOMATION_ENABLED` remains false until all required checks pass. + +### Phase 5 — RC and stable execution + +- [ ] 5.1 Enable automation and dispatch `v1.1.0-rc.1` from `main` against PR #32. +- [ ] 5.1 Verify npm `next`, GitHub prerelease, PyPI `1.1.0rc1`, and catalog previews. +- [ ] 5.2 Collect 24 hourly samples for every required channel. +- [ ] 5.2 Verify unchanged source head, signatures, digests, audit, and incident state. +- [ ] 5.3 Materialize promotion decision and auto-merge the unchanged stable PR. +- [ ] 5.3 Publish and verify stable GitHub, npm, PyPI, Homebrew tap, and Scoop. +- [ ] 5.3 Submit WinGet and Chocolatey stable packages. +- [ ] 5.4 Poll moderated channels until public and installable. +- [ ] 5.4 Submit/poll optional Homebrew Core without blocking stable completion. +- [ ] 5.5 After stable verification, run seven-day Cloudflare 410 retirement and delete it. + +### Final acceptance + +- [ ] Every required public command reports `1.1.0`. +- [ ] Offline scan, SPDX, CycloneDX, telemetry-disabled status, install, upgrade, and uninstall pass. +- [ ] Public provenance, signatures, checksums, SBOMs, manifest, and immutable assets agree. +- [ ] Required ledger channels are `verified`; moderated/optional states remain truthful. +- [ ] Rollback and forward-fix paths are rehearsed and append-only. +- [ ] User-facing docs advertise only channels verified in public registries. + +## Implementation progress — local automation complete, channel acceptance pending + +- [x] Implement the exact-source RC and stable train with 30-minute reconciliation and a 24-hour machine promotion policy. +- [x] Add an append-only hash-chained ledger, idempotent remote classification, integrity incidents, and forward-fix rollback planning. +- [x] Build the full npm/native/PyPI manifest, deterministic archives and wheels, artifact SBOMs, signed checksums, and build attestations. +- [x] Implement OIDC npm/PyPI publication plus GitHub, Homebrew, Scoop, WinGet, and Chocolatey workflows. +- [x] Consolidate Homebrew and Scoop into the generated + `ntanwir10/homebrew-tap` shared-catalog contract with a cryptographic lock, + dispatch hint, and 30-minute reconciliation. +- [x] Add hourly fail-closed canaries for every selected install contract and serialize protected ledger updates. +- [x] Add release CLI interfaces for build, manifest, publish, verify, reconcile, promote, rollback, and status. +- [x] Pass local typecheck, build, 787-test coverage suite, 53 release contracts, lint ratchet, production audit, and packed-artifact inspection. +- [ ] Complete the one-time provider/account onboarding in `docs/RELEASE_ONBOARDING.md`. +- [ ] Confirm hosted signing and cross-platform matrices, publish `1.1.0-rc.1`, and complete its 24-hour soak. +- [ ] Promote and verify `1.1.0` on every public channel; moderated registries remain pending until external acceptance. + +## Checkpoint A — clean npm and npm-client release + +- [ ] GS-DIST-001 split the dirty remediation branch into reviewable delivery units and verify a clean release commit. +- [ ] GS-DIST-002 raise the supported Node floor to a non-EOL line, recommended Node 22, and publish the OS/capability support policy. +- [ ] GS-DIST-003 reserve channel names and secure publisher identities with 2FA/OIDC. +- [ ] GS-DIST-004 automate one reviewable version/changelog/lockfile release PR and protected-tag creation. +- [ ] GS-DIST-101 make package smoke invoke an actual global `guardscan` shim on Linux, macOS, and Windows. +- [ ] GS-DIST-101 test optional native dependencies in full and graceful-degradation modes. +- [ ] GS-DIST-102 add pnpm global/dlx smoke. +- [ ] GS-DIST-102 add Yarn Modern dlx/project-local smoke and separate Yarn Classic legacy smoke. +- [ ] GS-DIST-102 add Bun global/bunx smoke and document whether Node is required. +- [ ] GS-DIST-103 run post-publish canaries against the exact npm version and add scheduled registry health. +- [ ] GS-DIST-500 add tested `plan`, `prepare`, `validate`, `render`, `dry-run`, `publish`, `status`, and `resume` commands plus versioned manifest/state schemas. +- [ ] GS-DIST-500 keep workflow YAML thin by moving deterministic release logic into unit-tested modules shared by local and CI runs. +- [ ] Rehearse `1.1.0` as a prerelease/dist-tag before promoting it to `latest`. + +## Checkpoint B — standalone release assets + +- [ ] GS-DIST-201 confirm the proposed standalone-builder ADR by passing the Node SEA host matrix; the builder and CI feasibility jobs are implemented, while Bun compile remains rejected unless product runtime compatibility is separately established. +- [ ] GS-DIST-202 make schemas, version metadata, dynamic modules, and optional charts safe for the selected builder. +- [ ] GS-DIST-203 build macOS arm64/x64, Linux arm64/x64 glibc, and Windows x64 artifacts. +- [ ] GS-DIST-204 generate a machine-readable release manifest, SHA-256 sums, per-artifact SBOMs, and attestations. +- [ ] GS-DIST-204 establish macOS signing/notarization and Windows code signing. +- [ ] GS-DIST-205 pass the exact downloaded-artifact smoke suite with Node absent. +- [ ] GS-DIST-206 build deterministic, schema-aware renderers and golden tests for every planned package adapter. +- [ ] GS-DIST-206 validate generated adapter fixtures with each ecosystem's native tooling before publication. +- [ ] Publish immutable GitHub Release assets only after all target gates pass. + +## Checkpoint C — native package managers + +- [ ] GS-DIST-301 create and test `Formula/guardscan.rb` in the shared + `ntanwir10/homebrew-tap` catalog on macOS and Linuxbrew. +- [ ] GS-DIST-302 create and test `bucket/guardscan.json` in that same shared + catalog, including lock verification and missed-dispatch reconciliation. +- [ ] Submit the non-blocking source-building Homebrew Core formula after the + stable release; advertise `brew install guardscan` only after acceptance and + a public canary. +- [ ] GS-DIST-302 validate and submit WinGet manifests after Windows artifact stability. +- [ ] GS-DIST-303 build, locally test, submit, and obtain approval for the Chocolatey package. +- [ ] Generate adapter versions, URLs, and hashes from the canonical release manifest. +- [ ] Exercise install, upgrade, uninstall, and rollback for every native channel. + +## Checkpoint D — optional channels + +- [ ] GS-DIST-401 decide whether Python users justify a PyPI/pipx channel. +- [ ] If approved, reserve the PyPI project and prove platform wheels on TestPyPI. +- [ ] GS-DIST-402 bundle the exact native executable in each wheel; do not bootstrap npm/npx or download on first run. +- [ ] Prefer `pipx install` in end-user docs and test install/upgrade/uninstall/yank behavior. +- [ ] Evaluate a signed multi-architecture GHCR image before APT/RPM or shell installers. + +## Cross-cutting release and operations + +- [ ] GS-DIST-501 compose reusable, matrix-based workflows with immutable artifact handoffs and a single-release concurrency lock. +- [ ] GS-DIST-502 pin release actions, minimize permissions, remove long-lived token fallback after OIDC is proven, and protect tags/releases. +- [ ] GS-DIST-503 document and rehearse rollback for npm, GitHub, Homebrew, Scoop, WinGet, Chocolatey, and optional PyPI. +- [ ] GS-DIST-504 publish only commands that resolve to tested public artifacts; label preview channels explicitly. +- [ ] GS-DIST-505 detect channel drift, broken URLs, bad checksums, signing expiry, and installation failures without end-user telemetry. +- [ ] GS-DIST-506 open reviewable, no-op-aware downstream update pull requests from GS-DIST-206 output. +- [ ] GS-DIST-507 persist a release ledger and prove dry-run, status, no-op rerun, remote-conflict detection, and partial-release resume. +- [ ] GS-DIST-508 automate reviewed release-tool/Action/template updates, ownership routing, credential-expiry alerts, and scheduled rehearsals. +- [ ] GS-DIST-509 document and test the narrow renderer/validator/smoke/rollback interface required to add a future channel. + +## Final go/no-go gate + +- [ ] One protected source commit and one version produced every advertised artifact. +- [ ] Exact-artifact install/version/help/offline-scan/SBOM/upgrade/uninstall tests pass on every supported target. +- [ ] Provenance, checksums, platform signatures, release manifest, SBOMs, and verification instructions are public. +- [ ] Channel owners, support route, incident communication, and rollback runbooks are active. +- [ ] A routine release requires one release PR plus a machine-approved 24-hour RC soak and no manual version, URL, checksum, manifest, or promotion synchronization. +- [ ] Release automation can be dry-run, inspected, safely retried, and resumed without rebuilding artifacts. +- [ ] No documentation overstates Bun-runtime, Python-native, Homebrew-core, offline, privacy, or platform support.