diff --git a/CHANGELOG.md b/CHANGELOG.md index 0896ac0..94900af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. ### Added +- **`mcpm audit --sarif` (D3)** — emit a SARIF 2.1.0 report for GitHub + code-scanning (`github/codeql-action/upload-sarif`). One rule per real + `Finding.type`; each finding becomes a result anchored **file-level** to + `mcpm.yaml` (audit scans installed servers, which have no source line — a + fabricated line number would be a lie), with severity mapped to SARIF + `error`/`warning`/`note` and a stable fingerprint so GitHub tracks the same alert + across runs. Report-only (never fixes); exit code matches `audit` (`1` when risky). - **`mcpm verify` + an official GitHub Action (D2)** — a repo-only, **client-free** integrity gate for CI. `mcpm verify` loads `mcpm-lock.yaml` and runs the same fail-closed integrity pass as `mcpm up --frozen` — BLOCK (exit 1) on integrity diff --git a/CLAUDE.md b/CLAUDE.md index 62dfb33..df75b8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -500,6 +500,7 @@ the same entry shape). | 2026-07-03 | v0.17.0 released (Wave-0 adoption credibility floor) + started Wave 1 with the Claude Code adapter (D1) | Following the new **docs/ROADMAP-ADOPTION.md**: v0.17.0 shipped the four Wave-0 items (E3 supply-chain evidence pack, B1a macOS CI leg, E11 `docs/CONTRACTS.md`, E9a registry-delisting gate). Then D1 (#117) added **Claude Code** as a 5th first-class client (`~/.claude.json`, user-global `mcpServers`) — a rootKey-only `BaseAdapter` subclass; **user-global scope only**, per-project `projects[].mcpServers` deliberately deferred. D1 is on `main`, ships next tag. | | 2026-07-03 | Publish workflow: attach the release SBOM at `gh release create` time, never via a follow-up `gh release upload` | The repo has GitHub **immutable releases** enabled: uploading an asset to an already-created release returns HTTP 422. v0.17.0's publish succeeded on npm but failed the release-asset step (SBOM), so its GitHub release is asset-less and can't be amended. Fix (#116): pass `mcpm.cdx.json` as a positional asset to `gh release create` so it's sealed atomically. Correct from v0.18.0 on. GOTCHA for any future release-pipeline work. | | 2026-07-03 | Wave-1 D4a: Gemini CLI as a 6th first-class client (`~/.gemini/settings.json`, user-global `mcpServers`) | Structural clone of D1 — another rootKey-only `BaseAdapter` subclass; verified the format against Google's gemini-cli docs (top-level `mcpServers`, home-relative on all platforms). Detector auto-enumerates it, so every client-iterating command works day one. **User-global scope only**, per-project `.gemini/settings.json` deliberately out of scope (same as D1). Caveat: Gemini reads `url`=SSE / `httpUrl`=HTTP; mcpm writes `url`, the existing non-Cursor URL-transport caveat. Merged to `main` (#120), ships next tag. | +| 2026-07-03 | Wave-1 D3: `mcpm audit --sarif` — SARIF 2.1.0 for GitHub code-scanning | Pure `src/output/sarif.ts` mapper beside the `--json` branch. One rule per real `Finding.type` (the union is **8** now, not the roadmap's "7"; the rule catalog is a TS-exhaustive `Record` so a new type fails the build). Each finding → a SARIF result anchored **file-level** to `mcpm.yaml` (audit scans INSTALLED servers → no source line; a fake line number would be a lie), + a `logicalLocation` naming the server, severity→`error`/`warning`/`note`, and a stable `partialFingerprints` (`server:type:sha256(msg)[:12]`) so GitHub tracks the same alert across runs. Report-only (never fixes, even with `--fix`); exit matches `audit` (risky→1) — README documents the `upload-sarif` + `if: always()` pattern. `__PKG_VERSION__` (tsup define) guarded with a `typeof` fallback since it's undefined under vitest. On `main`, ships next tag. | | 2026-07-03 | Wave-1 D2: `mcpm verify` (repo-only, client-free CI gate) + a composite GitHub Action; `classifyIntegrity`/`frozenVerdict` extracted to `src/stack/frozen-verify.ts` | The critique premise held: NO existing command runs on a hosted runner (`up` hard-fails at Step 3 on zero detected clients; `sync --check`/`audit` are vacuously green). So `mcpm verify` runs the SAME fail-closed integrity pass as `up --frozen` — extracted `classifyIntegrity` + a NEW pure `frozenVerdict` (structured pass/block decision) into a shared module both consume — but client-free (no detection, no `~/.mcpm`, no writes). Same BLOCK semantics + exit codes (1 on drift/unverifiable/format-mismatch/mixed-missing-baseline; benign refuse on lock-wide no-baseline; pypi/oci/url = unenforceable notice; exit 1 on no-lock, and verify NEVER auto-locks). `up`'s output/block-matrix is byte-identical (13 frozen tests unchanged — the refactor's safety net). `--json` emits a VerifyModel (`schemaVersion:1`). Composite Action `.github/actions/mcpm-verify` (step summary from `--json`, static shields badge, pre-commit snippet). **ONE verb**: B3 later EXTENDS `mcpm verify` with Sigstore provenance, never forks it. v1 = npm `dist.integrity` only; stack-vs-lock staleness deferred. On `main`, ships next tag. | | 2026-07-03 | Wave-1 D7: `doctor --json` / `--report` via a pure `DoctorModel` builder + redacted report; fixes the `mcpm_doctor` `issues: []` bug | `doctorHandler` split into `buildDoctorModel` (pure, structured — `schemaVersion:1`, clients w/ server+guarded counts, runtimes, advisory drift, typed issues, `ok`) → renderers. `--json` emits the model; the MCP `handleDoctor` now REUSES the model (was returning hardcoded `issues: []` + detected-clients-only). `--report` = a redacted paste-for-bug-reports snapshot: OS/arch, mcpm+node versions, per-client server **counts**, runtime availability, confine + secret-store backend, issue **counts** — deliberately **no server names/args** (issue messages embed names → reduced to counts; the tested security invariant). New `.github/ISSUE_TEMPLATE/bug.yml` requires a pasted report (telemetry-free friction channel). D7 is the FIRST of the four structured-output mappers (D3/D7/E2/E6) to land: the shared "one model" is the `schemaVersion` + JSON convention, NOT a monolithic type — doctor issues stayed a doctor-specific typed list, deliberately NOT forced into the audit `Finding` shape (different domain). doctor `--json`/`--report` shapes UNSTABLE (added to CONTRACTS). On `main`, ships next tag. | diff --git a/README.md b/README.md index 93683f1..5098bf4 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Without an external scanner installed, the maximum possible score is 80/100. The | `mcpm info ` | Show full details for an MCP server | | `mcpm list` | List all installed MCP servers across detected AI clients | | `mcpm remove ` | Remove an MCP server from client config(s) | -| `mcpm audit` | Scan all installed servers and produce a trust report | +| `mcpm audit` | Scan all installed servers and produce a trust report (`--json`, `--sarif` for GitHub code-scanning) | | `mcpm update` | Check for newer versions and update installed servers | | `mcpm outdated` | Show version drift and trust regression for installed servers | | `mcpm secrets` | Manage MCP server credentials (AES-GCM encrypted at rest; key held in the OS keychain — macOS Keychain / libsecret / Windows DPAPI — so a copied store can't be decrypted off-machine, with a machine-derived-key fallback where no keychain is available). `mcpm secrets migrate` upgrades older entries | @@ -231,6 +231,19 @@ jobs: The Action writes a job step summary from `--json`; the same verb works as a pre-commit hook. See [`.github/actions/mcpm-verify`](.github/actions/mcpm-verify). +**Code scanning:** `mcpm audit --sarif` emits a SARIF 2.1.0 report (one rule per +finding type, findings anchored file-level to `mcpm.yaml`) that GitHub ingests as +code-scanning alerts: + +```yaml + - run: npx @getmcpm/cli audit --sarif > mcpm.sarif + continue-on-error: true # audit exits 1 when a server is risky + - uses: github/codeql-action/upload-sarif@v3 + if: always() # upload even if the audit flagged a risk + with: + sarif_file: mcpm.sarif +``` + > Honesty boundary: a failure means npm's *published record* diverged from your > lock — not that mcpm caught malicious bytes; npx/uvx fetch the artifact > independently at server launch. diff --git a/docs/ROADMAP-ADOPTION.md b/docs/ROADMAP-ADOPTION.md index 7e38ffc..34daa83 100644 --- a/docs/ROADMAP-ADOPTION.md +++ b/docs/ROADMAP-ADOPTION.md @@ -195,11 +195,13 @@ The completeness critic's verdict: the candidate set over-indexed enterprise for `mcpm verify` (SHA-pinned, step summary from `--json`, static badge). Pre-commit rides the same verb. **Naming is deliberate:** B3 later *extends* `mcpm verify` with provenance — one verb, integrity now, provenance later. -- **D3 · `mcpm audit --sarif`.** Pure `src/output/sarif.ts` mapper beside the existing - `--json` branch; rules from the 7 real `Finding.type`s; artifactLocation = - `mcpm.yaml` (file-level, no fake line numbers). GitHub code-scanning upload - documented. **Cut:** guard-events SARIF (no repo artifact to anchor; their export - story is E2). +- **D3 · `mcpm audit --sarif`. ✅ SHIPPED** (on `main`, ships next tag; see CHANGELOG + `[Unreleased]`). Pure `src/output/sarif.ts` mapper beside the `--json` branch; + rules from the real `Finding.type`s (**8**, not 7 — the union grew; the rule catalog + is a TS-exhaustive `Record` so a new type forces an update); artifactLocation = + `mcpm.yaml` (file-level, no fake line numbers) + a `logicalLocation` for the server + name + a stable `partialFingerprints`. Report-only, exit matches `audit` (risky→1). + GitHub code-scanning upload documented in the README. **Cut:** guard-events SARIF. - **D6 · Distribution (trimmed).** mise registry PR (near-zero — npm backend alias) + `getmcpm/homebrew-mcpm` tap + README install matrix. Framed honestly as convenience, not enterprise unblock. **Cut:** devcontainer feature until a diff --git a/src/__tests__/commands/audit.test.ts b/src/__tests__/commands/audit.test.ts index 9255aaf..d961dc5 100644 --- a/src/__tests__/commands/audit.test.ts +++ b/src/__tests__/commands/audit.test.ts @@ -348,6 +348,46 @@ describe("handleAudit — safe/caution/risky mix", () => { }); }); +// --------------------------------------------------------------------------- +// --sarif (D3) +// --------------------------------------------------------------------------- + +describe("handleAudit — --sarif", () => { + it("emits a SARIF 2.1.0 log mapping findings, exit 1 when risky", async () => { + const lines: string[] = []; + const deps = { + ...makeDeps({ + scanTier1: vi.fn().mockReturnValue([ + { type: "secrets", severity: "critical", message: "leaked key", location: "srv" }, + ] as Finding[]), + computeTrustScore: vi.fn().mockReturnValue(makeTrustScore("risky", 20)), + }), + output: (t: string) => lines.push(t), + }; + const code = await handleAudit({ sarif: true }, deps); + expect(code).toBe(1); // risky → 1, same as --json contract + + const sarif = JSON.parse(lines.join("\n")); + expect(sarif.version).toBe("2.1.0"); + const result = sarif.runs[0].results[0]; + expect(result.ruleId).toBe("mcpm/secrets"); + expect(result.level).toBe("error"); + expect(result.locations[0].physicalLocation.artifactLocation.uri).toBe("mcpm.yaml"); + // The report-only branch never touches the store even if --fix is also passed. + expect(deps.removeFromStore).not.toHaveBeenCalled(); + }); + + it("emits valid SARIF with no results + exit 0 when everything is clean", async () => { + const lines: string[] = []; + const deps = { ...makeDeps(), output: (t: string) => lines.push(t) }; // safe, no findings + const code = await handleAudit({ sarif: true }, deps); + expect(code).toBe(0); + const sarif = JSON.parse(lines.join("\n")); + expect(sarif.runs[0].results).toHaveLength(0); + expect(sarif.runs[0].tool.driver.rules.length).toBe(8); // rule catalog always present + }); +}); + // --------------------------------------------------------------------------- // Summary line // --------------------------------------------------------------------------- diff --git a/src/__tests__/output/sarif.test.ts b/src/__tests__/output/sarif.test.ts new file mode 100644 index 0000000..51c49aa --- /dev/null +++ b/src/__tests__/output/sarif.test.ts @@ -0,0 +1,108 @@ +/** + * D3 tests: the SARIF 2.1.0 mapper (src/output/sarif.ts). + * + * Pure function — asserts the shape GitHub code-scanning ingests: one rule per + * Finding.type, one result per finding, severity→level mapping, file-level + * anchoring to mcpm.yaml (NO fabricated line numbers), and stable fingerprints. + */ + +import { describe, it, expect } from "vitest"; +import { buildSarif, type SarifServer } from "../../output/sarif.js"; +import type { Finding } from "../../scanner/tier1.js"; + +function finding(type: Finding["type"], severity: Finding["severity"], message = "msg"): Finding { + return { type, severity, message, location: "server" }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function run(log: ReturnType): any { + return (log.runs as any[])[0]; +} + +describe("buildSarif", () => { + it("emits a valid SARIF 2.1.0 envelope with one run and the mcpm driver", () => { + const log = buildSarif([], "0.19.0"); + expect(log.version).toBe("2.1.0"); + expect(log.$schema).toMatch(/sarif-2\.1\.0/); + expect(log.runs).toHaveLength(1); + expect(run(log).tool.driver.name).toBe("mcpm"); + expect(run(log).tool.driver.version).toBe("0.19.0"); + }); + + it("declares a rule per Finding.type even when there are no findings", () => { + const ids = run(buildSarif([], "0.19.0")).tool.driver.rules.map((r: { id: string }) => r.id); + // The 8 real finding types, all namespaced mcpm/. + expect(ids).toEqual( + expect.arrayContaining([ + "mcpm/secrets", + "mcpm/prompt-injection", + "mcpm/typosquatting", + "mcpm/exfil-args", + "mcpm/scanner-error", + "mcpm/release-cooldown", + "mcpm/install-script", + "mcpm/registry-status", + ]) + ); + expect(ids).toHaveLength(8); + }); + + it("maps each finding to a result: ruleId, level, server in message, file-level location", () => { + const servers: SarifServer[] = [ + { name: "srv-a", findings: [finding("secrets", "critical", "leaked key")] }, + ]; + const results = run(buildSarif(servers, "0.19.0")).results; + expect(results).toHaveLength(1); + const r = results[0]; + expect(r.ruleId).toBe("mcpm/secrets"); + expect(r.level).toBe("error"); // critical → error + expect(r.message.text).toBe("leaked key (server: srv-a)"); + // File-level anchor to mcpm.yaml — NO region / line number (would be fabricated). + expect(r.locations[0].physicalLocation.artifactLocation.uri).toBe("mcpm.yaml"); + expect(r.locations[0].physicalLocation.region).toBeUndefined(); + expect(r.locations[0].logicalLocations[0].name).toBe("srv-a"); + }); + + it("maps severity → SARIF level (critical/high=error, medium=warning, low=note)", () => { + const servers: SarifServer[] = [ + { + name: "s", + findings: [ + finding("secrets", "critical"), + finding("prompt-injection", "high"), + finding("release-cooldown", "medium"), + finding("install-script", "low"), + ], + }, + ]; + const levels = run(buildSarif(servers, "0.19.0")).results.map((r: { level: string }) => r.level); + expect(levels).toEqual(["error", "error", "warning", "note"]); + }); + + it("gives each result a stable, distinct fingerprint (server:type:hash of message)", () => { + const servers: SarifServer[] = [ + { name: "s", findings: [finding("secrets", "high", "one"), finding("secrets", "high", "two")] }, + ]; + const results = run(buildSarif(servers, "0.19.0")).results; + const fps = results.map((r: { partialFingerprints: { mcpmFinding: string } }) => r.partialFingerprints.mcpmFinding); + expect(fps[0]).toMatch(/^s:secrets:[0-9a-f]{12}$/); + expect(fps[0]).not.toBe(fps[1]); // different messages → different fingerprints + // Deterministic across builds. + expect(buildSarif(servers, "0.19.0")).toEqual(buildSarif(servers, "0.19.0")); + }); + + it("flattens findings across multiple servers", () => { + const servers: SarifServer[] = [ + { name: "a", findings: [finding("secrets", "high")] }, + { name: "b", findings: [] }, + { name: "c", findings: [finding("typosquatting", "medium"), finding("exfil-args", "critical")] }, + ]; + const results = run(buildSarif(servers, "0.19.0")).results; + expect(results).toHaveLength(3); + expect(results.map((r: { ruleId: string }) => r.ruleId)).toEqual([ + "mcpm/secrets", + "mcpm/typosquatting", + "mcpm/exfil-args", + ]); + }); +}); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 833097d..ef34590 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -20,6 +20,7 @@ import ora from "ora"; import type { InstalledServer } from "../store/servers.js"; import type { ServerEntry } from "../registry/types.js"; import type { Finding } from "../scanner/tier1.js"; +import { buildSarif } from "../output/sarif.js"; import type { TrustScore, TrustScoreInput } from "../scanner/trust-score.js"; import { levelColor, scoreBar, extractRegistryMeta } from "../utils/format-trust.js"; import { stdoutOutput } from "../utils/output.js"; @@ -39,6 +40,7 @@ const DEFAULT_FIX_THRESHOLD = 50; export interface AuditOptions { json?: boolean; + sarif?: boolean; fix?: boolean; minTrust?: number; yes?: boolean; @@ -233,6 +235,25 @@ export async function handleAudit( spinner.stop(); + // --sarif mode: a read-only SARIF 2.1.0 report for GitHub code-scanning. Report + // only — never fixes. Exit code matches audit's contract (risky → 1); upload with + // `if: always()` so the artifact survives a non-zero audit. + if (options.sarif === true) { + // __PKG_VERSION__ is a tsup build-time define; guard for non-bundled (test) runs. + const version = typeof __PKG_VERSION__ === "string" ? __PKG_VERSION__ : "0.0.0"; + output( + JSON.stringify( + buildSarif( + results.map((r) => ({ name: r.name, findings: r.findings })), + version + ), + null, + 2 + ) + ); + return results.some((r) => r.score.level === "risky") ? 1 : 0; + } + // --json mode (without --fix: bare array; with --fix: wrapped object) if (options.json === true) { const serversJson = results.map((r) => ({ @@ -339,10 +360,11 @@ export function registerAuditCommand(program: Command): void { .command("audit") .description("Scan all installed servers and produce a trust report") .option("--json", "Output raw JSON instead of a formatted table") + .option("--sarif", "Output a SARIF 2.1.0 report for GitHub code-scanning (report only)") .option("--fix", "remove servers whose trust score is below the threshold") .option("--min-trust ", "threshold used by --fix (default 50)", parseMinTrust) .option("-y, --yes", "skip confirmation prompts") - .action(async (opts: { json?: boolean; fix?: boolean; minTrust?: number; yes?: boolean }) => { + .action(async (opts: { json?: boolean; sarif?: boolean; fix?: boolean; minTrust?: number; yes?: boolean }) => { const { getInstalledServers } = await import("../store/servers.js"); const { RegistryClient } = await import("../registry/client.js"); const { scanTier1 } = await import("../scanner/tier1.js"); @@ -370,7 +392,7 @@ export function registerAuditCommand(program: Command): void { }; const exitCode = await handleAudit( - { json: opts.json, fix: opts.fix, minTrust: opts.minTrust, yes: opts.yes }, + { json: opts.json, sarif: opts.sarif, fix: opts.fix, minTrust: opts.minTrust, yes: opts.yes }, deps ).catch((err: Error) => { console.error(chalk.red(err.message)); diff --git a/src/output/sarif.ts b/src/output/sarif.ts new file mode 100644 index 0000000..596fc2c --- /dev/null +++ b/src/output/sarif.ts @@ -0,0 +1,131 @@ +/** + * SARIF 2.1.0 mapper for `mcpm audit --sarif` (D3). + * + * Pure: turns audit findings into a SARIF log that GitHub code-scanning ingests + * (`github/codeql-action/upload-sarif`). Findings anchor file-level to `mcpm.yaml` + * — audit scans the INSTALLED server set, which has no source line to point at, so + * a fabricated line number would be a lie. One SARIF rule per real `Finding.type`. + */ + +import crypto from "crypto"; +import type { Finding } from "../scanner/tier1.js"; + +/** The repo artifact findings are anchored to (file-level, no line numbers). */ +const ARTIFACT_URI = "mcpm.yaml"; +const INFO_URI = "https://github.com/getmcpm/cli"; + +export interface SarifServer { + name: string; + findings: Finding[]; +} + +/** Rule metadata per finding type — TS-exhaustive, so a new type must be added here. */ +const RULES: Record = { + secrets: { + name: "Hardcoded secret", + description: "A credential/secret pattern was detected in the server's metadata.", + }, + "prompt-injection": { + name: "Prompt injection", + description: "Prompt-injection patterns were found in the server's description or metadata.", + }, + typosquatting: { + name: "Typosquatting", + description: "The server name closely resembles a known popular server (possible typosquat).", + }, + "exfil-args": { + name: "Exfil-shaped argument", + description: "An argument schema is shaped to exfiltrate model context (e.g. underscore-wrapped sigils).", + }, + "scanner-error": { + name: "Scanner error", + description: "An external scanner failed to run against this server.", + }, + "release-cooldown": { + name: "Release cooldown", + description: "The package was published very recently — inside the rug-pull cooldown window.", + }, + "install-script": { + name: "Install script", + description: "The package declares an install/postinstall script shape that runs on install.", + }, + "registry-status": { + name: "Registry status", + description: "The registry marks this server as deprecated or deleted.", + }, +}; + +/** SARIF result level. critical/high → error, medium → warning, low → note. */ +function sarifLevel(severity: Finding["severity"]): "error" | "warning" | "note" { + if (severity === "critical" || severity === "high") return "error"; + if (severity === "medium") return "warning"; + return "note"; +} + +function ruleId(type: Finding["type"]): string { + return `mcpm/${type}`; +} + +export interface SarifLog { + $schema: string; + version: "2.1.0"; + runs: unknown[]; +} + +/** + * Build a SARIF 2.1.0 log from audit findings. + * @param servers audited servers with their findings + * @param version mcpm version string for the tool driver + */ +export function buildSarif(servers: SarifServer[], version: string): SarifLog { + const rules = (Object.keys(RULES) as Finding["type"][]).map((type) => ({ + id: ruleId(type), + name: RULES[type].name, + shortDescription: { text: RULES[type].name }, + fullDescription: { text: RULES[type].description }, + helpUri: INFO_URI, + defaultConfiguration: { level: "warning" }, + })); + + const results = servers.flatMap((server) => + server.findings.map((f, i) => ({ + ruleId: ruleId(f.type), + level: sarifLevel(f.severity), + message: { text: `${f.message} (server: ${server.name})` }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: ARTIFACT_URI }, + }, + logicalLocations: [{ name: server.name, kind: "namespace" }], + }, + ], + // Stable across runs so GitHub tracks the same alert: server + type + message. + partialFingerprints: { + mcpmFinding: `${server.name}:${f.type}:${fingerprint(f.message)}`, + }, + })) + ); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "mcpm", + informationUri: INFO_URI, + version, + rules, + }, + }, + results, + }, + ], + }; +} + +function fingerprint(message: string): string { + return crypto.createHash("sha256").update(message).digest("hex").slice(0, 12); +}