git-agentic/pkg-registry

GitHub: git-agentic/pkg-registry

一个部署在 npm registry 前方的透明审计代理,在安装前对包内容进行静态安全评分并执行默认拒绝策略。

Stars: 0 | Forks: 0

# Sentinel [![ci](https://static.pigsec.cn/wp-content/uploads/repos/cas/99/993938d8ce5e902ccfb9d6747725c320d855dea3235ed9a304cedf0d94c9321f.svg)](https://github.com/git-agentic/pkg-registry/actions/workflows/ci.yml) [![codeql](https://static.pigsec.cn/wp-content/uploads/repos/cas/96/96c3f93be8faa44dde0863a2e4eb8e78c8cedb435b0715c444c5050f54915e5e.svg)](https://github.com/git-agentic/pkg-registry/actions/workflows/codeql.yml) [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) **An agent-auditable security layer for the npm ecosystem.** Phase 1 is a transparent **auditing proxy** that sits in front of `registry.npmjs.org`: it resolves and serves real packages unchanged, but intercepts every tarball, scores its contents with a deterministic audit engine, and attaches a verdict — so an AI agent or a human can see the risk *before install-time code runs*. See **[ARCHITECTURE.md](./ARCHITECTURE.md)** for the full design (proxy, sync-vs-async audit placement, data model, npm hooks, stack justification). ## Status **Pre-1.0.** The auditing proxy, policy gate, deny-by-default install sandbox (macOS Seatbelt / Linux bubblewrap), CLI, MCP server, and GitHub Action work end-to-end and are covered by the full test suite (Linux CI on Node 22 and 24; macOS Seatbelt enforcement is exercised on maintainers' machines) — but this has not yet been hardened by production use, and APIs may change without notice. The complete phase-by-phase build log lives in [docs/adr/](./docs/adr/) (one ADR per phase). **No npm packages are published yet**: build from source (Quickstart below). Threat model: [sentinel-threat-model.md](./sentinel-threat-model.md) · Homepage: [git-agentic.com/sentinel](https://git-agentic.com/sentinel) ## Quickstart npm install # install workspace deps npm run build # compile all packages (tsc --build) npm run fixtures # pack the test fixtures into real .tgz tarballs npm test # engine + end-to-end proxy — see CLAUDE.md for the current count and skip breakdown npm run demo # self-contained malware-detection demo (no network) `npm run demo` boots the proxy in-process against local fixtures and prints the pre-install verdict panels for a benign package and a trojaned patch release, ending with the `403` an installer would receive: ══ 3. Trojaned patch release — color-stream@1.4.1 (diff vs 1.4.0) ═ color-stream@1.4.1 ──────────────────────────────────────────────────────── install ⚠ runs lifecycle scripts score ░░░░░░░░░░ 0/100 verdict BLOCK findings (7) critical [install-scripts] `postinstall` … reads environment variables, decodes an encoded blob. critical [secret-exfil] Reads sensitive material (…~/.npmrc, AWS credentials…) and contains a network egress sink… high [network-egress] connects to a hardcoded IP address. high [obfuscation] uses eval(). … ══ 4. What `npm install` sees when it fetches the bad tarball ════ HTTP 403 x-sentinel-verdict: block x-sentinel-score: 0 ## Run the proxy against real npm npm run build node packages/proxy/dist/index.js # observe policy, npm upstream, :4873 # open the dashboard: open http://localhost:4873/ Audit a real package before installing (no install, no execution): node packages/cli/dist/index.js audit is-odd 3.0.1 # is-odd@3.0.1 → score 100/100 ALLOW (signed, no install scripts) Route a real install through the proxy (every tarball in the tree is audited): node packages/cli/dist/index.js install lodash # == npm install --registry http://localhost:4873 lodash ### Environment | Var | Default | Meaning | |---|---|---| | `SENTINEL_UPSTREAM` | `npm` | `npm` (real registry) or `fixtures[:dir]` (local, hermetic) | | `SENTINEL_POLICY` | `observe` | `observe` = audit + serve + headers; `block` = `403` on a block verdict | | `SENTINEL_PORT` | `4873` | proxy port (verdaccio's conventional local-registry port) | | `SENTINEL_REGISTRY` | `https://registry.npmjs.org` | upstream registry when in npm mode | | `SENTINEL_STORE` | _(memory only)_ | path to a JSON file to persist the audit log | | `SENTINEL_VIOLATIONS` | _(memory only)_ | path to a JSON file to persist runtime-violation records (quarantine state) | | `SENTINEL_AUTO_QUARANTINE` | _(unset ⇒ record-only)_ | set to exactly `1` (any other value is treated as off) to let a confirmed runtime-violation report quarantine its integrity (force `block` at serve time); requires `SENTINEL_AUTH_PUBKEY` to also be set — a fatal error at startup otherwise, so auto-quarantine is only ever attributable to an authenticated caller (ADR-0040) | | `SENTINEL_APPROVAL_REQUESTS` | _(memory only)_ | path to a JSON file to persist pending approval requests (MCP `sentinel_request_approval` and any other caller) | | `SENTINEL_TRUSTED_ROOT` | _(bundled root)_ | path to a Sigstore `trusted_root.json` for provenance verification (fatal error on a bad path) | | `SENTINEL_NPM_ATTESTATION_KEYS` | _(bundled keys)_ | path to an npm publish-attestation keys JSON, used alongside `SENTINEL_TRUSTED_ROOT` | | `SENTINEL_AUTH_PUBKEY` | _(unset ⇒ open)_ | path to an Ed25519 public key PEM; when set, gates control-plane mutations behind signed role tokens (see below) | | `SENTINEL_AUTH_TOKEN` | _(unset)_ | signed role token attached as `Authorization: Bearer` by the MCP client and `sentinel-script-shell` on their POST calls | | `SENTINEL_HISTORY_DB` | _(unset ⇒ disabled)_ | path to a SQLite file (`node:sqlite`, built-in); enables durable audit/violation history, `/-/metrics`, `/-/history`, `/-/violations/timeline`, `sentinel stats`/`history`, and the dashboard's Observability section. Node 24 runs it unflagged; Node 22 needs `--experimental-sqlite` | | `SENTINEL_ADVISORIES` | _(unset ⇒ bundled corpus only)_ | path to a JSON `Advisory[]` file, loaded once at startup (fatal error on an unreadable path); merged with the bundled known-malicious corpus and checked on the public install audit path | | `SENTINEL_VULNERABILITIES` | _(unset ⇒ bundled corpus only)_ | path to a JSON `VulnAdvisory[]` file, loaded once at startup (fatal error on an unreadable path, or on a corrupt non-JSON/non-array file); merged with the bundled known-vulnerable-range corpus and checked on the public install audit path | | `SENTINEL_TARBALL_ORIGINS` | _(registry origin only)_ | comma-separated allowlist of extra bare http(s) origins tarball fetches may target, beyond `SENTINEL_REGISTRY`'s own origin; validated once at startup (fatal error on a malformed entry), and a disallowed origin is never fetched (502) | | `SENTINEL_PUBLIC_BASE_URL` | _(unset ⇒ loopback-derived)_ | base URL used to rewrite `dist.tarball` links; when unset, only a loopback request Host (`localhost`, `127.0.0.0/8`, `[::1]`) may derive it — any other Host is refused with 421 | | `SENTINEL_MAX_TARBALL_BYTES` | `256 MB` | byte cap on a single tarball fetch (streamed, content-length + mid-stream enforced); over-cap ⇒ 502 | | `SENTINEL_MAX_PACKUMENT_BYTES` | `128 MB` | byte cap on a single packument or attestation fetch; over-cap ⇒ 502 (attestations ⇒ null, fail-open) | | `SENTINEL_MAX_TREE_PACKAGES` | `5000` | cap on distinct `name@version` coordinates in a single `/-/audit-tree` request; over-cap ⇒ 413, no silent truncation | | `SENTINEL_RATE_LIMIT_RPM` | _(unset ⇒ disabled)_ | requests-per-minute token-bucket cap, keyed by socket remote address, applied to `POST /-/audit-tree`, `GET /-/explain/*`, and `POST /-/policy/preview`; over-limit ⇒ 429 + `Retry-After`. Install-gate paths are never limited | | `SENTINEL_MAX_UNPACKED_BYTES` | `1 GiB` | cap on total decompressed bytes when extracting a tarball's contents; over-cap aborts extraction mid-stream and the current tarball's audit gets a critical `resource-abuse` finding (BLOCK) | | `SENTINEL_MAX_FILE_COUNT` | `100000` | cap on the number of files unpacked from a tarball; over-cap aborts extraction mid-stream the same way as the byte cap | ## CLI sentinel audit [version] pre-install verdict panel (exit 0 allow / 1 warn / 2 block) sentinel explain per-finding remediation, a suggested last-known-good version, and a ready waiver sentinel scan audit a local tarball offline (no proxy) sentinel audit-tree [lockfile] audit an entire resolved tree (npm/yarn/pnpm); exit non-zero if gated --sbom write a CycloneDX 1.6 SBOM of the audited tree --fail-on-error also gate when any package fails to audit (default off) --omit omit a dependency group (only 'dev' is supported) sentinel install [args…] npm install routed through the proxy sentinel npx [args…] npx routed through the proxy sentinel violations list runtime violations recorded by the proxy (quarantined builds) sentinel stats durable audit/violation metrics (requires SENTINEL_HISTORY_DB on the proxy) sentinel history [--verdict --name --limit] list recorded audits (requires SENTINEL_HISTORY_DB) sentinel policy init --out scaffold a policy file from the built-in default sentinel policy validate parse + lint a policy (non-zero exit iff errors) sentinel policy preview [-p proxy] replay audit history under a candidate policy (dry run) sentinel policy keygen [--out ] generate an Ed25519 keypair for signing policies sentinel policy sign --key write a detached signature over a policy file sentinel policy verify --pubkey verify a policy's signature and print its summary sentinel token keygen --out generate an Ed25519 keypair for control-plane auth sentinel token mint --role --sub --ttl --key mint a signed role token (prints to stdout) sentinel token verify --pubkey verify a token, print role/sub/exp or the rejection reason sentinel attest-keygen --out generate an Ed25519 keypair for attestation signing sentinel attest [lockfile] --key --out [--sbom] audit the tree, write an SBOM, sign a DSSE attestation over it sentinel verify-attestation --key [--sbom --policy-hash --require] offline-verify an attestation (deploy gate) -p, --proxy proxy base URL (default http://localhost:4873) --json raw JSON report The exit codes make `sentinel audit` usable as an agent tool or a CI gate. ### Whole-tree audit (Phase 7, ADR-0020; ecosystem breadth + SBOM, Phase 14, ADR-0027) `sentinel audit-tree [lockfile]` audits every resolved package in a lockfile in one pass and exits non-zero if the aggregate verdict trips the policy's `treeGate` (default `block`). It auto-detects the format — `package-lock.json`/ `npm-shrinkwrap.json` (npm v2/v3), `yarn.lock` (v1 text or berry YAML), and `pnpm-lock.yaml` (v5/v6/v9) — by filename first, falling back to a content sniff. - `--sbom ` writes the audited tree as a CycloneDX 1.6 JSON BOM: one `library` component per package (`purl: pkg:npm/@`) carrying Sentinel's verdict/score/top-finding as `sentinel:*` properties. Written even when the tree is gated — it's informational output, not the gate itself. - The proxy cross-checks each lockfile-pinned integrity against the hash it actually recomputed from the served bytes (Phase 9); a mismatch force-blocks that row, surfaces a `lockfile-integrity-mismatch` finding, and is counted in the aggregate — but only when both sides are present and disagree (an absent integrity, e.g. yarn-berry's non-SRI checksum, never false-flags). - `--fail-on-error` opts the tree into gating on unresolvable-package rows too (default: `error` rows are surfaced but never gate, per ADR-0020's fail-open stance). - `--omit dev` skips dev dependencies where the lockfile format records them. ### Explain & remediation (Phase 18, ADR-0031) `sentinel explain ` answers "how do I get green?" for a `warn`/`block` verdict: $ sentinel explain color-stream 1.0.0 color-stream@1.0.0 — BLOCK 0/100 block — 2 finding(s); see the actions below or waive with the recorded rationale. • secret-exfil (critical) — Reads credentials/tokens and may exfiltrate them. Do not install until reviewed. If this is a false positive, waive with a recorded rationale; otherwise remove the dependency. • network-egress (high) — Makes network connections. Confirm the egress is expected for this package's purpose; if not, remove it or pin to a version without it. ✓ suggested: pin to color-stream@0.9.0 — the most recent clean release (96/100). To waive after review: sentinel approve color-stream 1.0.0 --reason "" It calls the proxy's `GET /-/explain/:pkg/:version`, which audits the version, runs the pure `remediate()` guidance mapping over the report, and walks back a bounded window (newest of ≤10 prior versions) for the last one that itself audits `allow` — the "suggested safe version" line. Prior versions come from the private store for a claimed namespace and from public npm otherwise (same `isClaimed` split as every other route — a claimed name never round-trips to public npm). The route is off the inline install-gate path, since it's expected to be slower than a plain audit — up to ~11 audits per call (integrity-cached, so repeats are cheap), so rate-limit or authenticate it if the proxy is reachable beyond a trusted network. Remediation surfaces in two more places without a separate `explain` call: - The `audit-tree` PR comment gets a **how to fix** column next to each flagged package (`remediationHint(ruleId)`), plus a footer pointing at `sentinel explain` for the full detail. - The MCP server's `sentinel_explain` tool (see below) returns the same `{ report, remediation, lastKnownGood }` shape for an agent host. **Advisory only** — nothing here rewrites a lockfile, `package.json`, or auto-selects a version. `sentinel explain` and the PR-comment hint only ever suggest; a human (or an agent through the existing approval-request path) still decides. See [ADR-0031](./docs/adr/0031-actionable-remediation.md). ### Signed audit attestations (Phase 19, ADR-0032) `sentinel audit-tree` gates a CI job; nothing survives past that job as a portable artifact a *later*, independent step can check offline. Phase 19 adds a signed, SLSA-VSA-flavored attestation over an audited tree, for a deploy-time gate: # once, offline: generate a signing keypair (keep sentinel-attest.key.pem secret) sentinel attest-keygen --out sentinel-attest # in CI, after the tree passes audit-tree: produce an SBOM + a signed attestation over it sentinel attest package-lock.json --key sentinel-attest.key.pem --out audit.att.json --sbom sbom.json # later, offline, in a deploy pipeline — pin the *public* key, never the private one sentinel verify-attestation audit.att.json --key sentinel-attest.pub.pem \ --sbom sbom.json --require allow # ✓ valid · verdict allow · policy · 2026-07-08T... # (or) ✗ attestation rejected: verdict-block → exits non-zero The attestation is a DSSE envelope around an in-toto `Statement` v1: its `subject` is the SHA-256 digest of the CycloneDX SBOM written alongside it (`--sbom`, ADR-0027), and its predicate (`https://sentinel.dev/attestation/audit-summary/v1`) carries the verdict, gate decision, per-verdict counts, the scoring-time policy hash, and a timestamp — enough to gate a deploy without re-running the audit or re-fetching the full per-package report. Signing is Ed25519, done entirely in the CLI on whatever machine runs `sentinel attest` (typically CI); the proxy holds no signing key and gains no new mutating route — its only change is exposing the `policyHash` it already computed on the `/-/audit-tree` response, so `--policy-hash` can pin an attestation to the policy that produced it. `verify-attestation` is pure, offline, and fail-closed: a tampered envelope, a wrong SBOM, a policy-hash mismatch, or a verdict below `--require` all reject with a distinct reason and a non-zero exit — never a silent pass. This is a **VSA-style** artifact (a DSSE/in-toto envelope, in the spirit of SLSA's Verification Summary Attestation) rather than a spec SLSA VSA: the predicate type is Sentinel-owned, so a generic DSSE/in-toto tool can check the signature, but a SLSA-aware verifier expecting the standard predicate shape won't recognize it without adaptation. Note the command is `sentinel attest-keygen`, not `sentinel attest keygen` — they're sibling top-level commands (a commander-15 quirk with nested `requiredOption`s made a true subcommand impractical). See [ADR-0032](./docs/adr/0032-signed-audit-attestations.md). ### Sandbox — default-deny (Phases 3–5, 25, 28, 29, Phase 2/Landlock; macOS Seatbelt / Linux bubblewrap) `sentinel run-scripts [--approve network:host …]` runs the package's lifecycle scripts under a kernel sandbox generated from its approved capabilities — `createSandbox()` selects **Seatbelt** on macOS and **bubblewrap** on Linux, same capability model and fail-closed contract. As of Phase 25 the sandbox is **deny-by-default** (ADR-0038): - **Writes** are denied outside a fixed floor (the install dir, the OS temp dir, `/dev`, and the node build caches `~/.node-gyp` / `~/.cache/node-gyp` / `~/.npm/_logs`) plus operator-approved `filesystem:` grants — killing the persistence/tamper class, not an enumerated list. - **`$HOME` reads** are denied by default, re-allowing only what a lifecycle script needs: system paths, the node install prefix (so a node-under-`$HOME` nvm/fnm/volta runtime still loads its stdlib), the project root (so `require()` resolves), and the build caches — closing credential theft as a whole class. `/etc/passwd`/`/etc/shadow` stay denied via the `SENSITIVE_PATHS` carve-out. - **Network egress** is denied unless a `network` capability is approved. - **Environment secrets** are fail-closed **scrubbed** (Phase 4): a credential-looking env var (`NPM_TOKEN`, `AWS_SECRET_ACCESS_KEY`, …) never reaches the script unless approved with an `env:NAME` capability. The `secret-exfil` audit rule additionally flags env reads at scoring time. - **Exec** (macOS, Phase 28) is denied outside a fixed floor — system dirs, the node prefix, the project tree, Apple/Homebrew toolchains — plus approved `process:` grants (`process:curl` lifts one tool's carve-out; `process:/path` opens a path; `process:*` lifts the carve-out only), and exfil-capable tools (`curl`, `wget`, `nc`, …) are re-denied inside the floor unless granted. A dropped binary in `/tmp` or a cache is kernel-denied; a binary the package writes into its *own* project tree can still exec there (the floor includes the project root), mitigated by the `unscanned-content` finding and `process` capability scoring, not kernel denial. - **Exec** (Linux, Phase 29 + Phase 2/Landlock) always exec-denies the exfil-capable tools (`curl`, `wget`, `nc`, …) by masking each with `--ro-bind /dev/null ` unless a `process:` Grant lifts it (bwrap itself cannot path-gate exec — no `noexec` mount option). As of Phase 2 the exec **floor** is also enforced where a from-source Landlock helper is available: `landlock-exec`, compiled by `npm run build` (Linux + `cc` only, a no-op elsewhere) and invoked inside bwrap, applies `LANDLOCK_ACCESS_FS_EXECUTE` over the floor (`execAllowFloor` plus the library/linker dirs `/lib`, `/lib64`, `/usr/lib`, `/usr/lib64`) before exec'ing the script — a dropped binary outside that floor is kernel-denied, matching the macOS behavior. Detection is fail-open and pre-checked (helper present AND a `--check` ABI probe passes, cached); anything else falls back to the Phase 29 advisory floor with a one-time notice — no availability regression on hosts without Landlock or a `cc` toolchain, which stay filesystem+network confined as before. The `/dev/null` exfil-tool carve-out is unchanged either way (ADR-0043, ADR-0044). A denied credential read surfaces as a confirmed runtime violation on Seatbelt (EPERM); on bubblewrap the read is *contained* (a `--tmpfs` mask yields `ENOENT`) but not classified — an accepted telemetry asymmetry (ADR-0023). Both backends contain; only the telemetry differs. ### Runtime violation telemetry (Phase 10) The sandbox has always **contained** a denied capability; it now also reports it. When an enforced lifecycle script's denied read or network egress *surfaces as a process failure*, `sentinel-script-shell` best-effort reports the detected violation to the proxy, which quarantines that exact tarball (by integrity) fleet-wide — every future serve of the same bytes comes back `block`, on top of the deterministic score, without ever mutating the cached audit. This is best-effort: a denial the package's own code silently swallows (process exits `0`) leaves no signal for telemetry, but it is still denied at the kernel level exactly as before — containment is unaffected either way. - `sentinel violations [--json]` — list recorded violations and their quarantine state. - `POST /-/violations` — report a violation `{ name, version, integrity, kind, target, confidence, deniedResource, evidence }`; `confirmed` quarantines and revokes any standing approval, `suspected` is record-only. Requires the integrity to already have an audited report. - `GET /-/violations` — the 50 most recent violation records. - `DELETE /-/violations/:integrity` — clear a quarantine. - `x-sentinel-violations` response header on every served tarball (`1`/`0`). ## Durable history + observability (Phase 15) Set `SENTINEL_HISTORY_DB=` on the proxy to turn on a durable, queryable store of every audit and violation, over the built-in `node:sqlite` — no new dependency. It's **opt-in**: leave the var unset and nothing changes (no `node:sqlite` import, same in-memory-only behavior as before). The store write-throughs beside the existing in-memory cache; it's best-effort, so a history-store failure never breaks a record or the audit gate. SENTINEL_HISTORY_DB=./sentinel-history.db node packages/proxy/dist/index.js - `GET /-/metrics` — `{ summary, trends, topFlagged }` (verdict/signature/ provenance/violation/quarantine counts, a daily allow/warn/block trend, and the most-flagged package names). - `GET /-/history?verdict=&name=&limit=&offset=` — paginated, filterable audit rows. - `GET /-/violations/timeline` — the recorded violation stream, most recent first, with quarantine status. - All three return `501 { enabled: false }` when `SENTINEL_HISTORY_DB` is unset, never a silent empty response. - `sentinel stats` and `sentinel history [--verdict --name --limit]` render the same data on the CLI; both print "history not enabled — set SENTINEL_HISTORY_DB on the proxy" if the endpoint 501s. - The dashboard's **Observability** section renders a verdict-trend chart, a top-flagged list, and a violation timeline, and degrades to a note when history isn't enabled. **Node version note:** `node:sqlite` runs unflagged on Node 24. On Node 22 you need `--experimental-sqlite` if you set `SENTINEL_HISTORY_DB`; leaving it unset keeps Node 22 fully supported with no flag. See [ADR-0028](./docs/adr/0028-durable-history-observability.md). ## Policy authoring + impact preview (Phase 20) Authoring an `EnterprisePolicy` is hand-edited JSON — Phase 20 adds a lint and a dry-run impact preview so an operator can catch a broken value and see what a candidate change *does* before signing it: # 1. scaffold a starting point from the built-in default sentinel policy init --out policy.json # 2. edit weights/thresholds/namespaces, then lint it — CI-gate: non-zero exit iff there are errors sentinel policy validate policy.json # 3. see what the candidate would change against real audit history (requires # SENTINEL_HISTORY_DB on the proxy — see "Durable history" above) sentinel policy preview policy.json -p http://localhost:4873 # 4. once it looks right, sign it as before sentinel policy sign policy.json --key sentinel-policy.key.pem - `sentinel policy validate ` parses and runs `lintPolicy`: **errors** (a policy an operator should not sign — an inverted threshold, an invalid severity, a package in both `allow` and `deny`, …) fail the command; **warnings** (legal but suspicious — non-monotonic weights, an aggressively low `hardBlockSeverity`, …) print but exit `0`, so `validate` is a clean CI gate that doesn't block on advisory noise. - `sentinel policy preview [-p proxy]` POSTs the candidate to `POST /-/policy/preview`, which re-scores every audit in `HistoryDb` under the candidate through the same deterministic `score()` the live gate uses, and prints the verdict-transition counts (e.g. "3 allow→block, 1 warn→allow") plus the worst-affected packages. It's a **dry run** — the candidate is never applied to the live proxy, stored, or signed by this command. No `SENTINEL_HISTORY_DB` on the proxy ⇒ prints "history not enabled" instead of an error. - Preview is a read: it needs a running proxy but no auth token, same as every other read route. See [ADR-0033](./docs/adr/0033-policy-authoring-impact-preview.md). ## Control-plane authentication (Phase 12) The control plane's mutating endpoints — approvals, violation reports and clears, and publish — are **unauthenticated by default (open mode)**. Setting `SENTINEL_AUTH_PUBKEY` on the proxy turns on signed-role-token auth for those routes; everything else (every `GET`, tarball fetches, packument resolution, and `POST /-/audit-tree`) stays open in either mode. Tokens are stateless, offline-verifiable Ed25519 signatures over `{ role, sub, iat, exp }` — no server-side session store, no token database. Generate a keypair, mint a token, and verify it: sentinel token keygen --out ./auth # writes auth.pub.pem, auth.key.pem (0600) sentinel token mint --role operator --sub alice --ttl 3600 --key ./auth.key.pem # eyJyb2xlIjoib3BlcmF0b3IiLCJzdWIiOiJhbGljZSIsImlhdCI6... . c2ln... sentinel token verify --pubkey ./auth.pub.pem # valid role=operator sub=alice exp=2026-07-07T15:00:00.000Z Run the proxy with auth enabled: SENTINEL_AUTH_PUBKEY=./auth.pub.pem node packages/proxy/dist/index.js **Role → endpoint map** (enforced only when `SENTINEL_AUTH_PUBKEY` is set): | Route | Required role | |---|---| | `POST /-/approvals` | `operator` | | `DELETE /-/approvals/:integrity` | `operator` | | `DELETE /-/violations/:integrity` | `operator` | | `POST /-/approval-requests` | `agent` | | `POST /-/violations` | `agent` | | `PUT /:pkg` (publish) | `publisher` | A missing/malformed/expired/badly-signed token is `401`; a well-formed token with the wrong role for the route is `403`. Reads are never gated. **Clients:** `sentinel-mcp` (`ProxyClient`) and `sentinel-script-shell` both read `SENTINEL_AUTH_TOKEN` from the environment and attach it as `Authorization: Bearer ` on their POST calls (agent role) — reads stay unauthenticated even with a token set. The dashboard has an "operator token" field (persisted to `localStorage`) that attaches the same header to its Approve/Deny/Revoke actions, so a human operator can drive the gate when auth is enabled. This is what makes ADR-0024's "the agent can request, only a human can grant" boundary a hard guarantee rather than an absent tool: with auth on, an `agent`-role token presented to `POST /-/approvals` now gets a `403`, no matter which client sends the request. See [ADR-0025](./docs/adr/0025-control-plane-auth.md). ## MCP server (Phase 11) `sentinel-mcp` is a stdio [MCP](https://modelcontextprotocol.io/) server for agent hosts that speak MCP directly instead of shelling out to the CLI. It is a **thin client** to the running proxy — it audits nothing itself and does zero scoring; every tool call is a fetch to the proxy's `/-/*` endpoints, so the verdict an agent sees is byte-identical to what a real install would see. If the proxy is unreachable, a tool call fails explicitly — it never fabricates a verdict. Point an agent host at it (e.g. in an MCP client config): { "mcpServers": { "sentinel": { "command": "node", "args": ["packages/mcp/dist/index.js"], "env": { "SENTINEL_PROXY": "http://localhost:4873" } } } } Tools: - `sentinel_audit` — verdict/score/findings/capabilities/signature/provenance for a package version, plus whether it's quarantined by a runtime violation. - `sentinel_audit_tree` — audits every package in a `package-lock.json` and returns the aggregate verdict and gate state. - `sentinel_capabilities` — the capability manifest, delta vs. the prior version, and approval state. - `sentinel_check_provenance` — provenance status and, when verified, the attested build identity (repo/workflow/builder/commit). - `sentinel_list_violations` — recorded runtime violations and which builds are quarantined. - `sentinel_explain` (Phase 18) — per-finding remediation actions, a suggested last-known-good version, and a ready approval-request payload for a package version. Same `{ report, remediation, lastKnownGood }` shape as `sentinel explain`/`GET /-/explain`; advisory only. - `sentinel_request_approval` — records a **pending** approval request; it never grants approval. Only a human can approve, via the dashboard's "Pending approval requests" panel or `POST /-/approvals`. The privilege boundary is deliberate: the agent can request, never grant. There is no auto-approve or clear-quarantine tool, and none is planned — see [ADR-0024](./docs/adr/0024-agent-native-mcp-surface.md). - `POST /-/approval-requests` — record a pending request `{ name, version, integrity, reason, requestedBy? }`. Requires the integrity to already have an audited report. - `GET /-/approval-requests` — the 50 most recent pending requests. - A `POST /-/approvals` decision for an integrity auto-clears its pending request. ## GitHub Action (Phase 17) `@sentinel/action` (bin `sentinel-ci`) is a self-contained on-ramp into pull requests — it needs no separately-running proxy. `runCi` self-boots the proxy in-process against real npm, audits your lockfile through the same `/-/audit-tree` route the CLI uses, writes a CycloneDX SBOM, and posts the verdict to the PR. # .github/workflows/sentinel.yml name: Sentinel on: { pull_request: {} } permissions: { contents: read, pull-requests: write } jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./ # this action is not yet published to the Marketplace with: fail-on: block **Inputs:** | Input | Default | Description | |---|---|---| | `lockfile` | auto-detect | Path to `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` | | `policy` | built-in `DEFAULT_POLICY` | Path to a signed enterprise policy file | | `policy-pubkey` | — | Path to the policy signer's public key PEM (required with `policy`) | | `policy-sig` | `.sig` | Path to the policy signature file | | `omit-dev` | `false` | Omit dev dependencies from the audit | | `sbom-path` | `sentinel-sbom.json` | Where to write the CycloneDX SBOM | | `fail-on` | `block` | `block` \| `warn` \| `none` — the verdict level that fails the check | | `comment` | `true` | Post/update a PR comment with the verdict | | `working-directory` | `.` | Directory to audit | **Outputs:** `verdict`, `gated`, `blocked`, `warned`, `errored`, `sbom-path`. Every run uploads the SBOM as a build artifact (`if: always()`, so it's attached even on a blocked run) and, on a `pull_request` event, posts or updates a single PR comment — found by a hidden `` marker so re-runs edit the same comment instead of piling up new ones — with the verdict, per-package findings table, and provenance summary. **Onboarding path:** start with `fail-on: none` — the audit runs, the SBOM uploads, and the PR comment appears, but nothing blocks a merge — then move to `fail-on: warn` and finally the `fail-on: block` default once the team is ready to enforce. See [ADR-0030](./docs/adr/0030-ci-native-github-action.md). Under `fail-on: block`/`warn`, a package that fails to resolve/audit (e.g. a transient npm outage) becomes an error row that gates the tree (fail-closed) — so a transient registry outage can fail the check; use `fail-on: none` (observe) to avoid this during onboarding. ## How the malware demo works (and why it's synthetic) The malicious fixture, `fixtures/malicious/color-stream`, reproduces the **event-stream / ua-parser-js** pattern: a previously-clean package (`1.4.0`) ships a patch (`1.4.1`) that adds a `postinstall` hook which harvests environment secrets and `~/.npmrc`, decodes an obfuscated base64 blob, `eval`s it, and exfiltrates over HTTPS to a hardcoded IP. It is **inert test data** — never executed by the suite (the engine only reads it as text), the egress IP is in the RFC 5737 documentation range, and it carries a `SYNTHETIC FIXTURE` header. We use a synthetic payload on purpose: the real historical malware (`flatmap-stream@0.1.1`) was **unpublished from npm** after the incident (only a `0.0.1-security` placeholder remains), so it can't be fetched — which is itself one of the problems Sentinel exists to address. The engine was also validated against the **live npm registry**: | Package (real npm) | Result | |---|---| | `is-odd@3.0.1` | `100/100` **ALLOW** — signed, no install scripts | | `esbuild@0.19.0` | **BLOCK** — flags its network-touching `postinstall` (a real, legitimate-but-reviewable install hook) | | `flatmap-stream@0.1.1` | unresolvable — unpublished after the event-stream incident | `esbuild` is a deliberate example of a *true positive that needs policy*: its postinstall is legitimate, and Phase 2's per-enterprise allowlisting is how you'd clear it without weakening detection. ## Project layout packages/ core/ @sentinel/core audit engine — rules, scoring, data model, LLM adapter (no I/O, fully unit-tested) proxy/ @sentinel/proxy Express registry proxy, pluggable upstream, audit store, dashboard cli/ @sentinel/cli pre-install verdicts + registry-redirected npm/npx mcp/ @sentinel/mcp sentinel-mcp: stdio MCP server, thin client to the proxy (Phase 11) action/ @sentinel/action sentinel-ci: self-boots the proxy for GitHub Actions (Phase 17) fixtures/ benign + synthetic-malicious packages; make-fixtures.ts packs real .tgz tarballs scripts/ make-fixtures.ts, demo.ts ARCHITECTURE.md full design · CLAUDE.md working agreement for this repo ## Scoring Deterministic: start at 100, subtract weighted penalties per finding, clamp `[0,100]`. `≥80 allow · 50–79 warn · <50 block`; any `critical` finding forces `block`. Files changed in a release are weighted `1.6×` (diff-audit). The score is produced entirely by the heuristic rules so it is reproducible in CI; the LLM adapter only adds human-readable context in the async-enrich phase, never the score. Weights live in one policy object (`packages/core/src/score.ts`). ## Supply-chain identity signals (Phase 13) Two name-only checks catch the older, non-code attack class — a malicious package published under a name close enough to trick a human or an automated install: - **Typosquat detection** — a pure rule flags a package name that's an edit-distance/homoglyph near-match of a name in a bundled, static popular-package corpus (e.g. `expres` vs `express`). `medium` severity. - **Dependency-confusion detection** — a score-time check flags a *public* package name that's a look-alike of one of your claimed `privateNamespaces` (the same field that gates private-store serving) — the signal only Sentinel can produce, since it's the only layer here that holds your namespace claims. `high` severity. Never flags the legitimate claimed package itself. Both are weighted findings that raise the score, not automatic blocks — see [ADR-0026](./docs/adr/0026-supply-chain-identity-heuristics.md). ## Maintainer & release-anomaly signals (Phase 16) Every rule through Phase 13 scores a release in isolation. Phase 16 adds cross-version context (a `ReleaseContext` derived from the packument's own `time`/`maintainers` history — no new network call) and four weighted `metadata` signals that compound with everything else: - **Maintainer change** — none of the previous version's maintainers remain ⇒ `high` (possible account/ownership takeover); the set changed but at least one previous maintainer remains ⇒ `low`. - **Dormancy resurrection** — the package was silent ≥365 days before this release ⇒ `low`. - **New-package risk** — a first-ever published version that already runs install scripts ⇒ `medium`. - **Capability novelty** — this release adds a `network`/`process` capability the immediately-previous version didn't have ⇒ `medium`. All four are inert without the underlying packument history (e.g. a private-store package) and none is a standalone hard block — see [ADR-0029](./docs/adr/0029-release-anomaly-signals.md). ## Known-advisory (known-malicious) detection (Phase 21) Every rule through Phase 16 infers risk from behavior, identity, or release history. Phase 21 adds the one signal none of them provide: a check against **already-confirmed-malicious** package versions. - **Bundled corpus** — `packages/core/src/advisory-corpus.ts` ships a static, offline snapshot of real, publicly-documented compromised npm releases (e.g. `event-stream@3.3.6`, `ua-parser-js@0.7.29`) with their GHSA advisory ids. Metadata only, never fetched at audit time. - **`known-advisory` rule** — an exact `(name, version)` match against the bundled corpus (or an operator-supplied advisory) emits a `critical` `metadata` finding by default, naming the advisory id — this **hard-blocks** under the default policy. - **Bring your own advisory list** — set `SENTINEL_ADVISORIES` to a path to a JSON `Advisory[]` file (`{ name, version, id, severity?, reference? }[]`) on the proxy; it's read once at startup and merged with the bundled corpus, so an unreadable file fails the process closed rather than silently running without your entries. - **Refreshing the bundled corpus** — the bundled snapshot goes stale as new malicious releases are discovered. `npm run advisories -- --in ` (`scripts/make-advisories.ts`) transforms a local OSV/GHSA "malicious-packages" export into a ready-to-paste `KNOWN_ADVISORIES` array; it does not fetch anything itself — see the script's header comment for the expected input shape and source pointers. Exact-match only (no semver ranges yet); the one finding type Sentinel's own remediation guidance tells you not to waive — see [ADR-0034](./docs/adr/0034-known-advisory-detection.md). ## Known-vulnerability (SCA) detection (Phase 22) `known-advisory` (above) catches confirmed-malicious releases by exact version. Phase 22 adds the far more common case: a legitimate package with a publicly-disclosed **CVE affecting a range of versions** — full software composition analysis (SCA), not just malware detection. - **Bundled corpus** — `packages/core/src/vuln-corpus.ts` ships a static, offline snapshot of real npm CVEs (`lodash`, `minimist`, `axios`, `node-fetch`, `ws`) with their affected semver ranges, CVSS-derived severity, advisory id, and fixed version(s). Metadata only, never fetched at audit time. - **`known-vulnerability` rule** — matches the audited version against any range in the bundled corpus (or an operator-supplied vulnerability) via `semver.satisfies`. Each match emits a finding at the advisory's own **faithful** severity — a `critical` CVE **hard-blocks** under the default policy exactly like any other critical finding, tunable via the same `hardBlockSeverity`/`allow`/rule-disable/`treeGate` levers as everything else (no new policy field). - **Bring your own vuln feed** — set `SENTINEL_VULNERABILITIES` to a path to a JSON `VulnAdvisory[]` file (`{ name, ranges, severity, id, fixedIn?, reference? }[]`) on the proxy; it's read once at startup and merged with the bundled corpus — an unreadable *or* corrupt file fails the process closed rather than silently running without your entries. - **Refreshing the bundled corpus** — the bundled snapshot goes stale as new CVEs are disclosed. `npm run vulns -- --in ` (`scripts/make-vulns.ts`) transforms a local OSV/GHSA export into a ready-to-paste `KNOWN_VULNERABILITIES` array; it does not fetch anything itself — see the script's header comment for the expected input shape and source pointers. - `audit-tree` gains a `vulnerabilities` count of packages carrying a known-vulnerability finding, alongside its existing verdict/provenance/ integrity-mismatch counts. See [ADR-0035](./docs/adr/0035-known-vulnerability-sca.md). ## Phase log The complete phase-by-phase log lives in [CLAUDE.md](./CLAUDE.md) and [docs/adr/](./docs/adr/); highlights: Phase 1 is the transparent auditing proxy. Phase 2 adds the install-time permission manifest + approval gate, signed per-enterprise policy, and the private-namespace registry. Phases 3–6 add cross-platform sandbox enforcement (macOS Seatbelt, Linux bubblewrap) up through `sentinel install --enforce`, which sandboxes every lifecycle script in the tree. Phase 7 adds `sentinel audit-tree`, a whole-tree lockfile gate: it audits every package in a `package-lock.json` through the proxy and exits non-zero if the aggregate verdict trips the policy's `treeGate`. Phase 8 verifies the npm registry signature offline (ECDSA P-256/SHA-256/DER against a configured key set) and surfaces `signature`/`provenance` status on every audit; a policy can require a verified signature or present provenance for matching package names. Phase 9 deep-verifies build provenance: real Sigstore attestation bundles are checked offline against pinned trust material, `provenance` becomes `verified|invalid|absent|unknown` with subject-digest binding to the actual served bytes, and a policy can require a verified attestation from a specific repository, workflow, or builder for matching package names. Phase 10 turns the enforcing sandbox into a sensor: a denied capability that surfaces as a process failure is classified and reported to the proxy, which quarantines that exact tarball fleet-wide (`sentinel violations`, `/-/violations`) as a serve-time overlay — the cached, deterministic score is never touched, and a denial the package silently swallows is still contained, just not visible to telemetry. Phase 11 adds `sentinel-mcp`, a stdio MCP server that is a thin client to the running proxy: five read tools plus `sentinel_request_approval`, which only ever records a pending request (`/-/approval-requests`) for a human to approve — the agent requests, never grants. Phase 14 broadens `audit-tree` beyond npm: `parseAnyLockfile` also reads `yarn.lock` (v1 and berry) and `pnpm-lock.yaml`, `--sbom ` writes a CycloneDX 1.6 BOM of the audited tree, a lockfile-vs-served integrity cross-check force-blocks a coordinate whose pinned integrity disagrees with what's actually served, and `--fail-on-error` opts the tree into gating on unresolvable packages. Phase 18 adds actionable remediation: `sentinel explain ` (and the MCP `sentinel_explain` tool, and a "how to fix" column on the PR comment) turns a `warn`/`block` verdict into per-finding guidance, a suggested last-known-good version, and a ready waiver — advisory only, never auto-fixing a lockfile. Phase 19 adds signed audit attestations: `sentinel attest` signs a DSSE/ in-toto envelope (Ed25519, VSA-style) over an audited tree's SBOM digest, and `sentinel verify-attestation` checks it offline against a pinned key — a portable, deploy-time gate that survives past the CI job that produced it. Signing is operator-side in the CLI; the proxy only exposes the scoring-time policy hash the attestation binds to. Phase 20 adds policy authoring + impact preview: a pure `lintPolicy` catches broken (errors) and suspicious (warnings) policy values before signing, and `sentinel policy preview` replays the proxy's audit history under a candidate policy through the same deterministic scorer to show the verdict deltas — a dry run, never applied or signed by the preview itself. Phase 21 adds known-advisory detection: a bundled, static corpus of publicly-documented known-malicious npm releases hard-blocks an exact version match by default, with an operator-supplied `SENTINEL_ADVISORIES` file merged in at proxy startup. Phase 22 adds known-vulnerability (semver-range CVE) detection over a bundled offline corpus. Phase 23 hardens the network trust boundary: outbound tarball fetches are pinned to allowlisted origins and packument tarball rewrites use a configured public base URL instead of trusting the Host header. Phase 24 adds resource robustness — fetch byte caps, audit-tree dedupe + a package cap, request coalescing, and an opt-in rate limiter. Phase 25 flips the sandbox to deny-by-default: writes and `$HOME` reads are denied unless a fixed floor or an approved capability grant re-opens them. See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full design and [docs/adr/](./docs/adr/) for the decision log. ## License Apache-2.0.
标签:LLM防护, MITM代理, Next.js, NPM生态, 代理网关, 文档安全, 沙箱环境, 自动化攻击, 跌倒检测, 软件物料清单