Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/research-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
188 changes: 108 additions & 80 deletions scripts/github-security-summary.mjs
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a fully authorized token before failing closed

When this release gate runs in .github/workflows/release.yml, it is still passed ${{ github.token }} with only security-events: read; GitHub's workflow permission docs say security-events covers code-scanning alerts, Dependabot requires vulnerability-alerts: read, and secret-scanning alerts require a GitHub App or PAT. Because this new branch now fails on any 403/404 unavailable endpoint, tag releases will exit 1 on the Dependabot or secret-scanning checks even when there are no actionable alerts, blocking publishing rather than verifying the alert state.

Useful? React with 👍 / 👎.

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) {
Expand All @@ -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"
}
});
Expand Down Expand Up @@ -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}`);
}
}
}
Expand All @@ -181,6 +210,5 @@ function trimBody(body) {
}

function fail(message) {
console.error(message);
process.exit(1);
throw new Error(message);
}
44 changes: 44 additions & 0 deletions tests/security-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>) => Promise<number>;
};
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,
Expand Down
Loading