Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<path>].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<Finding["type"],...>` 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. |

Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Without an external scanner installed, the maximum possible score is 80/100. The
| `mcpm info <name>` | Show full details for an MCP server |
| `mcpm list` | List all installed MCP servers across detected AI clients |
| `mcpm remove <name>` | 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 |
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions docs/ROADMAP-ADOPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/commands/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
108 changes: 108 additions & 0 deletions src/__tests__/output/sarif.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildSarif>): 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/<type>.
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",
]);
});
});
26 changes: 24 additions & 2 deletions src/commands/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,6 +40,7 @@ const DEFAULT_FIX_THRESHOLD = 50;

export interface AuditOptions {
json?: boolean;
sarif?: boolean;
fix?: boolean;
minTrust?: number;
yes?: boolean;
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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 <n>", "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");
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading