From ac005bfb6cbae3a4b2f8ab98504b7028f46c06ec Mon Sep 17 00:00:00 2001 From: Mir Sameer Date: Fri, 19 Jun 2026 13:38:05 -0700 Subject: [PATCH] Harden release artifact gates Signed-off-by: Mir Sameer --- .github/workflows/release.yml | 12 ++ SECURITY.md | 4 +- docs/research-notes.md | 6 +- scripts/github-security-summary.mjs | 188 ++++++++++++++++------------ tests/security-fixes.test.ts | 44 +++++++ 5 files changed, 169 insertions(+), 85 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2377dd2..f171d04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,16 +25,26 @@ jobs: node-version: "24" cache: npm - run: npm ci + - run: npm run test:skip-gate - run: npm run verify - run: npm run audit:prod + - run: npm pack --dry-run --json + - run: npm run package:check + - run: npm run installer:audit + - run: pwsh -NoLogo -NoProfile -Command '$parseErrors = $null; [System.Management.Automation.Language.Parser]::ParseFile("install.ps1", [ref]$null, [ref]$parseErrors) | Out-Null; if ($parseErrors.Count) { $parseErrors; exit 1 }' - name: Fail release on actionable GitHub security alerts env: GITHUB_TOKEN: ${{ github.token }} run: npm run release:security-gate + - name: Fail release on open CodeQL alerts + env: + GITHUB_TOKEN: ${{ github.token }} + run: npm run release:codeql-gate - run: node --experimental-sqlite dist/src/cli.js demo - run: npm pack --json - run: npm sbom --sbom-format cyclonedx --json > sbom.cyclonedx.json - run: sha256sum *.tgz sbom.cyclonedx.json > checksums.txt + - run: sha256sum --check checksums.txt - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: repolens-release-package @@ -55,6 +65,8 @@ jobs: - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: repolens-release-package + - name: Verify release artifact checksums + run: sha256sum --check checksums.txt - name: Require npm publish token env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/SECURITY.md b/SECURITY.md index 08a433b..64bf661 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,7 @@ npm run package:check npm run audit:prod ``` -Release publishing also runs dependency audit and CodeQL alert gates before package creation. Tag releases publish npm provenance from a separate privileged job and fail if `NPM_TOKEN` is missing. +Release publishing also mirrors CI package gates, runs dependency audit and GitHub alert gates before package creation, verifies SHA-256 checksums before artifact upload and again after artifact download, publishes npm provenance from a separate privileged job, and fails if `NPM_TOKEN` is missing. ## GitHub Security Summary @@ -44,7 +44,7 @@ Maintainers can summarize the live GitHub Security tab state with: GITHUB_REPOSITORY=sameer2191/repolens-mcp GH_TOKEN="$(gh auth token)" npm run security:github ``` -Use `-- --format json` for automation or `-- --fail-on-actionable` to exit non-zero when CodeQL, Dependabot, or secret-scanning alerts are open. OpenSSF Scorecard alerts are reported separately as process signals so they are visible without being confused with code vulnerabilities. +Use `-- --format json` for automation or `-- --fail-on-actionable` to exit non-zero when CodeQL, Dependabot, or secret-scanning alerts are open or when an actionable alert endpoint cannot be verified. OpenSSF Scorecard alerts are reported separately as process signals so they are visible without being confused with code vulnerabilities. ## Reporting A Vulnerability diff --git a/docs/research-notes.md b/docs/research-notes.md index 57206a8..e718c62 100644 --- a/docs/research-notes.md +++ b/docs/research-notes.md @@ -31,10 +31,10 @@ RepoLens MCP is not a fork or a drop-in static C replacement. It is an original - Browser dashboard without a bundler so the project is easy to build and inspect. - Dashboard APIs expose architecture, fleet summaries, graph schema relationship/property hints, graph search, semantic search, local vector search, reference lookup, read-only graph queries, source snippets, import-resolved dependency cycles, dead-code candidates, graph previews, code search, and live Markdown/HTML architecture reports from the same local server. - Self-contained graph and architecture report exports for sharing HTML or Markdown artifacts without running a server, plus compressed checksummed `.rlgz` graph packages for reusing a SQLite graph without reindexing. A successful index can write a fresh package with `--write-package`, and a missing database can bootstrap from `.repolens/graph.rlgz` before the incremental pass. -- CI runs explicit test-skip governance, type-check, tests, production dependency audit, package dry-run, package contents gating, installer dry-run auditing, CycloneDX SBOM generation, self-indexing, and architecture output; separate workflows cover OpenSSF Scorecard and release build-provenance attestations. +- CI runs explicit test-skip governance, type-check, tests, production dependency audit, package dry-run, package contents gating, installer dry-run auditing, CycloneDX SBOM generation, self-indexing, and architecture output; release verification mirrors those package gates before publishing. - `llms.txt`, `docs/agent-guide.md`, and `docs/BENCHMARK.md` provide concise agent-facing operating instructions, sanitized validation evidence, and local-data boundaries in the npm package. -- `install.ps1` mirrors the Unix installer for Windows users, and `scripts/github-security-summary.mjs` gives maintainers a repeatable GitHub Security tab summary that separates actionable alerts from Scorecard process signals. -- The release workflow separates unprivileged verify/package work from privileged attestation, GitHub release, and npm publish work. +- `install.ps1` mirrors the Unix installer for Windows users, and `scripts/github-security-summary.mjs` gives maintainers a repeatable GitHub Security tab summary that separates actionable alerts from Scorecard process signals while failing closed when actionable alert endpoints are unavailable. +- The release workflow separates unprivileged verify/package work from privileged attestation, GitHub release, and npm publish work, and verifies SHA-256 checksums before artifact upload and after artifact download. ## Improvements To Highlight diff --git a/scripts/github-security-summary.mjs b/scripts/github-security-summary.mjs index e07af65..cc4308a 100644 --- a/scripts/github-security-summary.mjs +++ b/scripts/github-security-summary.mjs @@ -1,62 +1,91 @@ #!/usr/bin/env node -const repository = process.env.GITHUB_REPOSITORY; -const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; -const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; -const format = getArgValue("--format") ?? "text"; -const failOnActionable = process.argv.includes("--fail-on-actionable"); - -if (!repository) { - fail("GITHUB_REPOSITORY is required, for example sameer2191/repolens-mcp."); -} -if (!token) { - fail("GITHUB_TOKEN or GH_TOKEN is required."); -} -if (!["text", "json"].includes(format)) { - fail("--format must be text or json."); -} +import { pathToFileURL } from "node:url"; + +export async function runSecuritySummary(options = {}) { + const repository = options.repository ?? process.env.GITHUB_REPOSITORY; + const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + const apiUrl = options.apiUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com"; + const format = options.format ?? getArgValue("--format") ?? "text"; + const failOnActionable = options.failOnActionable ?? process.argv.includes("--fail-on-actionable"); + const fetchImpl = options.fetchImpl ?? fetch; + const output = options.output ?? console; + + if (!repository) { + fail("GITHUB_REPOSITORY is required, for example sameer2191/repolens-mcp."); + } + if (!token) { + fail("GITHUB_TOKEN or GH_TOKEN is required."); + } + if (!["text", "json"].includes(format)) { + fail("--format must be text or json."); + } + + const context = { apiUrl: String(apiUrl).replace(/\/+$/, ""), repository, token, fetchImpl }; + const [codeScanning, dependabot, secretScanning] = await Promise.all([ + listAlerts("/code-scanning/alerts", "code scanning", context), + listAlerts("/dependabot/alerts", "Dependabot", context), + listAlerts("/secret-scanning/alerts", "secret scanning", context) + ]); + + const codeqlAlerts = codeScanning.items.filter((alert) => alert.tool?.name === "CodeQL"); + const scorecardAlerts = codeScanning.items.filter((alert) => alert.tool?.name === "Scorecard"); + const otherCodeScanningAlerts = codeScanning.items.filter( + (alert) => alert.tool?.name !== "CodeQL" && alert.tool?.name !== "Scorecard" + ); + + const summary = { + repository, + generatedAt: new Date().toISOString(), + actionableOpenAlerts: codeqlAlerts.length + dependabot.items.length + secretScanning.items.length, + codeqlOpen: codeqlAlerts.length, + dependabotOpen: dependabot.items.length, + secretScanningOpen: secretScanning.items.length, + scorecardOpen: scorecardAlerts.length, + otherCodeScanningOpen: otherCodeScanningAlerts.length, + unavailable: [codeScanning, dependabot, secretScanning] + .filter((result) => result.unavailable) + .map((result) => result.unavailable), + scorecardRules: summarizeRules(scorecardAlerts), + otherCodeScanningRules: summarizeRules(otherCodeScanningAlerts), + actionableAlerts: { + codeql: summarizeAlerts(codeqlAlerts), + dependabot: summarizeAlerts(dependabot.items), + secretScanning: summarizeAlerts(secretScanning.items) + } + }; + + if (format === "json") { + output.log(JSON.stringify(summary, null, 2)); + } else { + printTextSummary(summary, output); + } -const [codeScanning, dependabot, secretScanning] = await Promise.all([ - listAlerts("/code-scanning/alerts", "code scanning"), - listAlerts("/dependabot/alerts", "Dependabot"), - listAlerts("/secret-scanning/alerts", "secret scanning") -]); - -const codeqlAlerts = codeScanning.items.filter((alert) => alert.tool?.name === "CodeQL"); -const scorecardAlerts = codeScanning.items.filter((alert) => alert.tool?.name === "Scorecard"); -const otherCodeScanningAlerts = codeScanning.items.filter( - (alert) => alert.tool?.name !== "CodeQL" && alert.tool?.name !== "Scorecard" -); - -const summary = { - repository, - generatedAt: new Date().toISOString(), - actionableOpenAlerts: codeqlAlerts.length + dependabot.items.length + secretScanning.items.length, - codeqlOpen: codeqlAlerts.length, - dependabotOpen: dependabot.items.length, - secretScanningOpen: secretScanning.items.length, - scorecardOpen: scorecardAlerts.length, - otherCodeScanningOpen: otherCodeScanningAlerts.length, - unavailable: [codeScanning, dependabot, secretScanning] - .filter((result) => result.unavailable) - .map((result) => result.unavailable), - scorecardRules: summarizeRules(scorecardAlerts), - otherCodeScanningRules: summarizeRules(otherCodeScanningAlerts), - actionableAlerts: { - codeql: summarizeAlerts(codeqlAlerts), - dependabot: summarizeAlerts(dependabot.items), - secretScanning: summarizeAlerts(secretScanning.items) - } -}; - -if (format === "json") { - console.log(JSON.stringify(summary, null, 2)); -} else { - printTextSummary(summary); + if (!failOnActionable) { + return 0; + } + + let shouldFail = false; + if (summary.actionableOpenAlerts > 0) { + shouldFail = true; + } + if (summary.unavailable.length > 0) { + shouldFail = true; + output.error("GitHub security gate could not verify every actionable alert endpoint:"); + for (const item of summary.unavailable) { + output.error(`- ${item.label}: HTTP ${item.status}`); + } + } + return shouldFail ? 1 : 0; } -if (failOnActionable && summary.actionableOpenAlerts > 0) { - process.exitCode = 1; +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.exitCode = await runSecuritySummary(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } function getArgValue(name) { @@ -67,17 +96,17 @@ function getArgValue(name) { return process.argv[index + 1]; } -async function listAlerts(path, label) { +async function listAlerts(path, label, context) { const all = []; - let url = new URL(`${apiUrl}/repos/${repository}${path}`); + let url = new URL(`${context.apiUrl}/repos/${context.repository}${path}`); url.searchParams.set("state", "open"); url.searchParams.set("per_page", "100"); for (;;) { url.searchParams.set("state", "open"); - const response = await fetch(url, { + const response = await context.fetchImpl(url, { headers: { accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, + authorization: `Bearer ${context.token}`, "x-github-api-version": "2022-11-28" } }); @@ -142,36 +171,36 @@ function summarizeAlerts(alerts) { })); } -function printTextSummary(summary) { - console.log(`GitHub security summary for ${summary.repository}`); - console.log(`Generated: ${summary.generatedAt}`); - console.log(""); - console.log(`Actionable open alerts: ${summary.actionableOpenAlerts}`); - console.log(`- CodeQL: ${summary.codeqlOpen}`); - console.log(`- Dependabot: ${summary.dependabotOpen}`); - console.log(`- Secret scanning: ${summary.secretScanningOpen}`); - console.log(`Process/code-scanning signals: ${summary.scorecardOpen + summary.otherCodeScanningOpen}`); - console.log(`- OpenSSF Scorecard: ${summary.scorecardOpen}`); - console.log(`- Other code scanning: ${summary.otherCodeScanningOpen}`); +function printTextSummary(summary, output) { + output.log(`GitHub security summary for ${summary.repository}`); + output.log(`Generated: ${summary.generatedAt}`); + output.log(""); + output.log(`Actionable open alerts: ${summary.actionableOpenAlerts}`); + output.log(`- CodeQL: ${summary.codeqlOpen}`); + output.log(`- Dependabot: ${summary.dependabotOpen}`); + output.log(`- Secret scanning: ${summary.secretScanningOpen}`); + output.log(`Process/code-scanning signals: ${summary.scorecardOpen + summary.otherCodeScanningOpen}`); + output.log(`- OpenSSF Scorecard: ${summary.scorecardOpen}`); + output.log(`- Other code scanning: ${summary.otherCodeScanningOpen}`); if (summary.scorecardRules.length > 0) { - console.log(""); - console.log("Scorecard rules:"); + output.log(""); + output.log("Scorecard rules:"); for (const rule of summary.scorecardRules) { - console.log(`- ${rule.rule}: ${rule.count}`); + output.log(`- ${rule.rule}: ${rule.count}`); } } if (summary.otherCodeScanningRules.length > 0) { - console.log(""); - console.log("Other code-scanning rules:"); + output.log(""); + output.log("Other code-scanning rules:"); for (const rule of summary.otherCodeScanningRules) { - console.log(`- ${rule.rule}: ${rule.count}`); + output.log(`- ${rule.rule}: ${rule.count}`); } } if (summary.unavailable.length > 0) { - console.log(""); - console.log("Unavailable alert endpoints:"); + output.log(""); + output.log("Unavailable alert endpoints:"); for (const item of summary.unavailable) { - console.log(`- ${item.label}: HTTP ${item.status}`); + output.log(`- ${item.label}: HTTP ${item.status}`); } } } @@ -181,6 +210,5 @@ function trimBody(body) { } function fail(message) { - console.error(message); - process.exit(1); + throw new Error(message); } diff --git a/tests/security-fixes.test.ts b/tests/security-fixes.test.ts index e054270..c371fb7 100644 --- a/tests/security-fixes.test.ts +++ b/tests/security-fixes.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { pathToFileURL } from "node:url"; import fc from "fast-check"; import { dashboardErrorBody } from "../src/dashboard/server.js"; import { buildResolvedImportEdges } from "../src/core/import-resolver.js"; @@ -232,6 +233,49 @@ test("graph search name patterns are bounded wildcards, not raw regexes", async } }); +test("GitHub security release gate fails closed when alert endpoints are unavailable", async () => { + type SecuritySummaryModule = { + runSecuritySummary: (options: Record) => Promise; + }; + const { runSecuritySummary } = (await import( + pathToFileURL(path.join(process.cwd(), "scripts/github-security-summary.mjs")).href + )) as SecuritySummaryModule; + const stdout: string[] = []; + const stderr: string[] = []; + + const result = await runSecuritySummary({ + apiUrl: "https://api.example.test", + repository: "sameer/repolens-mcp", + token: "test-token", + failOnActionable: true, + fetchImpl: async (input: URL | string) => { + const pathname = new URL(String(input)).pathname; + if (pathname.endsWith("/code-scanning/alerts") || pathname.endsWith("/secret-scanning/alerts")) { + return new Response("[]", { headers: { "content-type": "application/json" } }); + } + if (pathname.endsWith("/dependabot/alerts")) { + return new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { + status: 403, + headers: { "content-type": "application/json" } + }); + } + return new Response(JSON.stringify({ message: "not found" }), { + status: 404, + headers: { "content-type": "application/json" } + }); + }, + output: { + log: (message = "") => stdout.push(String(message)), + error: (message = "") => stderr.push(String(message)) + } + }); + + assert.equal(result, 1); + assert.match(stdout.join("\n"), /Unavailable alert endpoints/); + assert.match(stdout.join("\n"), /Dependabot: HTTP 403/); + assert.match(stderr.join("\n"), /could not verify every actionable alert endpoint/); +}); + function fileSymbol(filePath: string): SymbolNode { return { filePath,