From 92de4e058d6a1e30cdc7abd43c681e8f20b7a5f7 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Thu, 6 Aug 2026 18:55:58 +0300 Subject: [PATCH] fix: calibrate obfuscation and bind audit cache coordinates --- ARCHITECTURE.md | 25 ++-- CLAUDE.md | 13 +- .../0054-obfuscation-execution-correlation.md | 118 ++++++++++++++++++ docs/adr/0055-coordinate-bound-audit-cache.md | 97 ++++++++++++++ docs/adr/README.md | 4 +- package-lock.json | 40 +++--- packages/core/src/remediation.ts | 2 +- packages/core/src/rules/obfuscation.ts | 56 ++++++--- packages/core/src/types.ts | 2 +- packages/core/test/rules-obfuscation.test.ts | 89 +++++++++++++ packages/proxy/src/server.ts | 30 +++-- packages/proxy/src/store.ts | 51 ++++++-- packages/proxy/test/explain-e2e.test.ts | 74 +++++++++++ packages/proxy/test/retraction-e2e.test.ts | 8 +- packages/proxy/test/store.test.ts | 54 +++++++- 15 files changed, 582 insertions(+), 81 deletions(-) create mode 100644 docs/adr/0054-obfuscation-execution-correlation.md create mode 100644 docs/adr/0055-coordinate-bound-audit-cache.md create mode 100644 packages/core/test/rules-obfuscation.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ccb1c11..e19b640 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -110,7 +110,7 @@ This is the key latency decision. We split it: | Mode | What runs | When | Blocking? | |---|---|---|---| -| **Sync gate** | Heuristic rules engine (static analysis only — no LLM, no network beyond the tarball we already fetched) | On the tarball request, before bytes are served | Yes — but only on a **cold** package@version. Result is cached by integrity hash, so steady-state is a cache hit (sub-ms). | +| **Sync gate** | Heuristic rules engine (static analysis only — no LLM, no network beyond the tarball we already fetched) | On the tarball request, before bytes are served | Yes — but only on a **cold** package@version. Result is cached by `(name, version, actual integrity)`, so steady-state is a cache hit (sub-ms). | | **Async enrich** | LLM adapter pass + cross-version diff trend + provenance lookups | Queued after the response is served | No — enrichment updates the stored report and dashboard; never on the request path | Rationale: the heuristic engine is fast (regex/AST over a tarball that's already in @@ -120,9 +120,10 @@ hit, and the verdict is reproducible in CI. Anything slow or non-deterministic hiccup can never stall an install. The score a client sees inline never depends on a live LLM. -Caching key = `sha512` integrity from the packument `dist.integrity`. Audits are -immutable per `(package, version, integrity)` — npm can't mutate a published -tarball without changing the hash, so a cached verdict is always valid. +Caching key = `(package name, version, actual sha512 integrity recomputed from +the served bytes)` (ADR-0055). npm can't mutate a published tarball without +changing the hash, while the coordinate dimensions prevent byte-identical +packages from sharing metadata-dependent findings. ### 3.2 Diff-audit @@ -832,7 +833,7 @@ audit output. the target version, runs `remediate`, and walks back a **bounded window** (newest ≤10 prior versions, via the packument and `cmpSemver`) for the first version whose own audit is `allow` — short-circuiting on the first - hit and reusing the same cached, integrity-keyed `auditVersion` path every + hit and reusing the same cached, coordinate-and-integrity-keyed `auditVersion` path every other route uses. A packument fetch failure or a per-version audit failure is treated as "no last-known-good found," not an error — this is best-effort advisory output, not a gate. The route is deliberately off the @@ -1117,7 +1118,8 @@ throttle protects the expensive read endpoints. Phase 24 closes all four: `name@version` map lets concurrent uncached public audits for the same coordinate share one fetch/extract/score pipeline; the entry clears on settle (success or failure) so a failed run isn't cached. The - integrity-keyed `store` stays the durable cache (invariant #4) — the map + `(name, version, actual integrity)`-keyed `store` stays the durable cache + (invariant #4, ADR-0055) — the map is transient concurrency dedupe only, scoped to the process. - **Opt-in rate limiting (`packages/proxy/src/rate-limit.ts`).** A pure token-bucket `RateLimiter` (`createRateLimiter({ rpm, now })`, injectable @@ -1178,7 +1180,7 @@ throttle protects the expensive read endpoints. Phase 24 closes all four: `limits.ts` and `rate-limit.ts` are pure — no env access, clock injected — so the caps and the limiter are unit-tested without wall-clock or network -I/O. Scoring, the integrity cache key, and the packument passthrough are +I/O. Scoring, the coordinate-and-integrity cache key, and the packument passthrough are untouched (invariants #1–#6) — see [ADR-0037](./docs/adr/0037-resource-robustness.md), [ADR-0039](./docs/adr/0039-bounded-tarball-extraction.md), and @@ -1366,9 +1368,12 @@ Each rule is a pure function `(files, ctx) => Finding[]`. Phase 1 ships four: 3. **`network-egress`** — `http`/`https`/`net`/`dns`, `fetch`, websockets, `child_process` invoking `curl`/`wget`, hardcoded IPs, suspicious TLDs, and base64-encoded URLs. -4. **`obfuscation`** — `eval`, `Function(...)` constructor, `atob`/`unescape`, - long base64/hex blobs, `\xNN` string arrays, `charCodeAt` decode loops, - dynamic `require` of decoded strings. +4. **`obfuscation`** — concealed dynamic execution: direct or explicitly-global + JavaScript `eval` (not an arbitrary object method named `eval`), decoded source passed to the `Function` + constructor, and dynamic `require` of decoded/computed strings. Minification, + encoded data assets, data transcoding, character-code operations, and readable + `Function` runtime glue are not findings without a code-execution sink + (ADR-0054). Phase 8 adds a fifth rule (§3.9, ADR-0021): diff --git a/CLAUDE.md b/CLAUDE.md index 5e4c9a3..c4b9d90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ at audit time. Also here: multi-format lockfile parsing (npm/yarn/pnpm) + Cyclon signed audit attestations (ADR-0032), `lintPolicy` (ADR-0033). **Proxy (`@git-agentic/sentinel-proxy`)** — sync inline gate over bytes in memory, cached by -`dist.integrity`; transparent packument passthrough rewriting only `dist.tarball`. +`(name, version, actual integrity)`; transparent packument passthrough rewriting only `dist.tarball`. Private-namespace packages are served only from the private store, fail-closed (ADR-0010/0015). `POST /-/audit-tree` whole-lockfile gate with dedupe + 413 cap (ADR-0020/0037); `GET /-/explain` walk-back (ADR-0031); `POST /-/policy/preview` @@ -167,10 +167,13 @@ enforcement is tested with benign probe packages. `NoopLlmAdapter`; the engine is fully offline. 3. **The inline gate is sync + cheap; everything slow is async.** The proxy audits on the tarball request (static analysis over bytes already in memory) and caches - by `dist.integrity`. Never put a network call or an LLM call on the request path. -4. **Cache key = integrity hash.** A published tarball is immutable, so - `(name, version, integrity)` is a safe immutable key. Don't key caches on - version alone. + by `(name, version, actual integrity)`. Never put a network call or an LLM call + on the request path. +4. **Cache key = coordinate + integrity hash.** Complete reports contain + coordinate-dependent findings, so `(name, version, actual integrity)` is the + safe immutable key. Integrity remains mandatory so changed bytes always miss; + name/version prevent byte-identical coordinates from sharing findings + (ADR-0055). Don't key caches on version or integrity alone. 5. **The proxy is transparent.** For packuments we pass the upstream document through and rewrite *only* `dist.tarball`. Don't synthesize or strip fields on the npm path — it breaks resolution (dependencies, peer deps, etc.). diff --git a/docs/adr/0054-obfuscation-execution-correlation.md b/docs/adr/0054-obfuscation-execution-correlation.md new file mode 100644 index 0000000..32982a1 --- /dev/null +++ b/docs/adr/0054-obfuscation-execution-correlation.md @@ -0,0 +1,118 @@ +# ADR-0054: Obfuscation findings require a dynamic code-execution sink + +**Status:** Accepted +**Date:** 2026-08-06 +**Amends:** the Phase 1 `obfuscation` rule semantics in `ARCHITECTURE.md` §4.1 + +## Context + +The original `obfuscation` rule treated each of these as independent evidence: +`eval`, the `Function` constructor, base64 decoding, `unescape`, long encoded +strings, `\xNN` runs, and character-code operations. That conflated three +different facts: + +1. data is encoded or transcoded; +2. code is generated dynamically; and +3. concealed code is executed. + +Only the third is evidence of the behavior this rule is named for. npm packages +legitimately ship encoded WASM/data assets, minified bundles, compatibility +transcoders, and runtime glue. Those shapes are not evidence of obfuscation on +their own. + +The overly broad matcher also used `\beval\s*\(`, which matched any property +method named `eval`. In `@emnapi/core@1.9.2`, `g.eval(v8Script.value)` implements +a WebAssembly/N-API runtime operation; it is not JavaScript's direct evaluator. +The same package contains readable `new Function(...)` glue for constructing a +named wrapper. Sentinel emitted repeated high findings for both constructs +across the package's generated bundle formats. A package that bundled emnapi, +such as `@tailwindcss/oxide-wasm32-wasi`, inherited those findings for its +vendored copies. + +ADR-0053 bounded repeated penalties but intentionally did not change detection +semantics. A cold calibration at its `a65ab42` baseline still had 70 production- +tree blocks involving `obfuscation`. Further arithmetic suppression would hide +the attribution error rather than correct it. + +## Decision + +The `obfuscation` rule reports evidence only when the source contains a dynamic +code-loading or execution sink: + +- **JavaScript `eval(...)`** remains `high`, both as a direct identifier call and + through an explicit standard global object (`globalThis`, Node's `global`, or + browser `window`/`self`). An arbitrary method/property named `eval` (for + example `runtime.eval(...)`) is excluded because its semantics are defined by + that object, not the language evaluator. +- **A computed `require(...)` target** produced through the existing decoded or + opaque-call shapes remains `high`. +- **The `Function` constructor** is `high` only when its source is decoded + inline or comes from a locally assigned `atob(...)` / base64 + `Buffer.from(...)` result. A readable constructor body or runtime wrapper is + dynamic code generation, but not concealed code. + +These patterns are not `obfuscation` findings without such a sink: + +- minification or generated bundles; +- correctly declared WASM/native/binary assets; +- a long base64/hex string; +- base64 decoding, `atob`, `unescape`, or character-code conversion used as + data transcoding; +- `\xNN` data runs; or +- readable `Function`-constructor runtime glue. + +This is a rule-semantics change, not a scoring-policy change. The rule remains a +pure deterministic `(AuditInput) => Finding[]` function and still constructs +every finding through `mkFinding()`. `DEFAULT_POLICY`, thresholds, severity +weights, the per-rule cap, and the pre-registered calibration criteria are +unchanged. + +## Why this does not trade attack coverage for a target number + +The removed primitives establish encoding or code generation, not concealed +code execution. Sentinel continues to flag the execution paths: direct or +explicitly-global eval, decoded `Function` source, and computed decoded +`require`. The committed +synthetic malicious release decodes a base64 stage and passes it to direct +`eval`; it therefore retains its high obfuscation finding and remains blocked at +score 0. Native packaged-payload materialization and execution also remains +covered independently by the dataflow-correlated `native-payload-loader` rule +(ADR-0049), including correctly declared payloads and content mismatches. + +If future evidence shows a dangerous shape that does not reach one of these +sinks, add a correlation that describes that behavior. Do not restore a +standalone "encoded/minified data is obfuscation" penalty. + +## Consequences + +- Cold direct audits after this change produce no `obfuscation` findings for + `@emnapi/core@1.9.2` (score 84, `allow`) or + `@tailwindcss/oxide-wasm32-wasi@4.1.16` (score 80, `allow`). Other independent + rule findings account for the remaining deductions. +- Finding messages and remediation now describe concealed dynamic execution, + not "minified beyond normal" source. +- The local decoded-variable correlation is intentionally narrow and + deterministic. It recognizes direct assignments from `atob` and base64 + `Buffer.from`; it is not represented as full JavaScript dataflow analysis. +- The complete four-tree calibration must be rerun cold in the consuming repo; + these package probes validate the named false-positive class but do not + substitute for that registered measurement. + +## Rejected alternatives + +### Lower weights or move thresholds + +Rejected. Those are policy choices and would make the number smaller without +fixing the false attribution. + +### Lower the per-rule cap again + +Rejected. ADR-0053 already bounds repetition while retaining breadth of +evidence. Tightening it for this case would suppress every rule instance rather +than distinguish data assets from concealed code execution. + +### Package or path allowlists + +Rejected. The decision is based on behavior and applies equally to first-party, +vendored, scoped, and unscoped packages. No package name, publisher, filename, +or known hash changes the result. diff --git a/docs/adr/0055-coordinate-bound-audit-cache.md b/docs/adr/0055-coordinate-bound-audit-cache.md new file mode 100644 index 0000000..615c23f --- /dev/null +++ b/docs/adr/0055-coordinate-bound-audit-cache.md @@ -0,0 +1,97 @@ +# ADR-0055: Bind cached audit reports to package coordinate and integrity + +**Status:** Accepted +**Date:** 2026-08-06 +**Extends:** ADR-0004 (integrity-hash cache key), ADR-0012 (policy-bound verdicts) + +## Context + +ADR-0004 correctly requires the actual tarball integrity in every cache key so +changed bytes can never reuse a stale verdict. It also states that the logical +identity is `(name, version, integrity)`. The implementation did not match that +logical identity: `AuditStore` stored and looked up reports in a map keyed only +by `integrity`. + +An `AuditReport` is not a pure function of tarball bytes. Several deterministic +rules also consume the requested coordinate or its packument context: + +- `typosquat` consumes the package name; +- known advisories and vulnerabilities consume name and version; +- release anomaly consumes version history and release metadata; +- report metadata, remediation, and last-known-good output repeat the requested + name and version. + +Two package coordinates may legitimately serve byte-identical tarballs. With an +integrity-only report map, whichever coordinate was audited first owned the +cache entry. A later `GET /-/explain/:name/:version` returned that first +coordinate's metadata and finding set. This was reproduced hermetically with +`express@1.0.0` and the lookalike `expres@1.0.0` sharing one fixture tarball: +the second response identified itself as `express` and omitted its `typosquat` +finding. + +## Decision + +`AuditStore` keys every cached report by the exact tuple: + +``` +(normalized package name, version, actual tarball integrity) +``` + +The implementation uses an unambiguous NUL-delimited internal key. Persistent +schema-3 rows are re-indexed from their stored `name`, `version`, and report +integrity on load. Every report-serving cache lookup supplies all three +dimensions; there is no integrity-only serving fallback. + +The actual bytes hash remains mandatory and load-bearing. A re-publish or +tampered mirror that changes bytes changes the integrity dimension and therefore +misses the cache exactly as ADR-0004 requires. Adding coordinate dimensions does +not weaken content addressing; it prevents byte-identical coordinates from +sharing coordinate-dependent findings. + +The proxy runs one active enterprise policy per `AuditStore`, and persisted +reports whose `policy.hash` differs from that active policy are rejected during +load (ADR-0012). This ADR does not add the policy hash to the in-memory tuple +because the active store already provides that isolation. + +Approval, approval-request, and violation writes continue to record their +serve-time overlay by integrity. Approval requests and violations already submit +name/version and use the exact tuple. For backward compatibility, the older +approval endpoint may omit name/version; that integrity-only lookup succeeds only +when exactly one cached coordinate has those bytes. Shared-byte ambiguity fails +closed and requires the caller to submit name/version, preventing an arbitrary +coordinate's report from being borrowed. + +## Consequences + +- `/-/explain`, `/-/audit`, `/-/manifest`, tarball serving, and `/-/audit-tree` + all receive the report for the requested coordinate even when another package + uses identical bytes. +- `AuditStore.stats().total` and `recent()` count coordinate-bound audit records, + not unique tarball blobs. This matches their report/history semantics. +- Cache hits remain sub-millisecond map lookups and still require the actual + integrity computed from fetched bytes. +- Existing persisted schema-3 rows remain readable; they are indexed under the + corrected tuple at startup. Schema-1/2 rejection is unchanged. +- Engine/scorer-version invalidation is separate from coordinate identity. A + calibration after detection changes must still use a cold store until an + explicit engine-version cache dimension or migration policy is designed. + +## Rejected alternatives + +### Keep integrity-only reports and rewrite `meta` on response + +Rejected. Name/version are inputs to findings, not presentation-only fields. +Rewriting metadata would still reuse incorrect typosquat, advisory, +vulnerability, and release-anomaly results. + +### Cache byte-only extraction and rerun coordinate rules on every hit + +Potentially valid as a deeper cache split, but rejected for this fix. The current +public cache stores complete `AuditReport` objects, and separating byte-derived +observations from coordinate-derived rules would require a new persisted schema. +The tuple key fixes correctness without changing the audit pipeline. + +### Key only by `(name, version)` + +Rejected for the original ADR-0004 reason: changed or tampered bytes under the +same version label must never reuse a verdict. diff --git a/docs/adr/README.md b/docs/adr/README.md index b1ac722..f55d8bc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,7 +22,7 @@ not yet built · `Superseded` / `Deprecated` — replaced; see the linked succes | [0001](./0001-auditing-proxy-wedge.md) | Auditing proxy, not an npm replacement | Insert a transparent proxy; attach signal, don't own packages | | [0002](./0002-deterministic-scoring-llm-enrichment.md) | Deterministic scoring; LLM enrichment only | Rules set the score; the LLM may only add context, never the verdict | | [0003](./0003-sync-gate-async-enrich.md) | Sync gate / async enrich split | Cheap deterministic gate inline; everything slow or networked runs async | -| [0004](./0004-integrity-hash-cache-key.md) | Integrity-hash cache key | Key verdicts on the tarball SRI hash — content-addressed, never stale | +| [0004](./0004-integrity-hash-cache-key.md) | Integrity-hash cache key | Key verdicts on the tarball SRI hash — content-addressed, never stale; logical coordinate binding implemented by ADR-0055 | | [0005](./0005-transparent-packument-passthrough.md) | Transparent packument pass-through | Forward the upstream doc; rewrite only `dist.tarball` | | [0006](./0006-stack-node-typescript-workspaces.md) | Stack: Node + TS + npm workspaces | Live in the runtime we audit; one shared `AuditReport` contract | | [0007](./0007-client-integration-registry-redirection.md) | Integrate via registry redirection | Point `registry` at the proxy — covers all PMs + transitive deps | @@ -152,6 +152,8 @@ first shipped slice; Phases 31 and 32 complete claiming and retraction. | ADR | Title | Decision in one line | |-----|-------|----------------------| | [0053](./0053-per-rule-score-cap.md) | Per-rule score cap | `scoring.perRuleCapMultiplier` (policy data, default 3) bounds a rule's total penalty at that multiple of its own worst-instance weight, so N file-level findings from one rule can't alone drive the score to zero; monotonic, waiver-compatible (ADR-0014), optional for backward compat with already-signed policies | +| [0054](./0054-obfuscation-execution-correlation.md) | Obfuscation requires an execution sink | Encoded/minified data and readable runtime glue are not obfuscation by themselves; retain high findings for direct `eval`, decoded `Function` source, and computed decoded `require` | +| [0055](./0055-coordinate-bound-audit-cache.md) | Coordinate-bound audit cache | Bind complete reports to `(name, version, actual integrity)` so byte-identical package coordinates cannot share metadata-dependent findings | ## Conventions diff --git a/package-lock.json b/package-lock.json index 8c2c2d5..43feda7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -498,12 +498,12 @@ "link": true }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -522,12 +522,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1175,9 +1175,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1327,9 +1327,9 @@ } }, "node_modules/hono": { - "version": "4.12.28", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", - "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1378,9 +1378,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -1847,9 +1847,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/packages/core/src/remediation.ts b/packages/core/src/remediation.ts index 381b26c..7f47c3f 100644 --- a/packages/core/src/remediation.ts +++ b/packages/core/src/remediation.ts @@ -17,7 +17,7 @@ const REMEDIATIONS: Record = { "install-scripts": { summary: "Runs install-time lifecycle scripts.", action: "Review the scripts; approve the capability manifest (`sentinel approve …`) if they're required, otherwise prefer a script-free alternative." }, "secret-exfil": { summary: "Reads credentials/tokens and may exfiltrate them.", action: "Do not install until reviewed. If this is a false positive, waive with a recorded rationale; otherwise remove the dependency." }, "network-egress": { summary: "Makes network connections.", action: "Confirm the egress is expected for this package's purpose; if not, remove it or pin to a version without it." }, - "obfuscation": { summary: "Contains obfuscated/minified-beyond-normal code.", action: "Inspect the source; obfuscation in a dependency is a red flag — prefer a readable, well-known alternative." }, + "obfuscation": { summary: "Executes concealed or decoded dynamic code.", action: "Inspect the cited execution path and decoded source; if its behavior cannot be verified, prefer a readable, well-known alternative." }, "provenance": { summary: "Missing or unverifiable build provenance.", action: "Request an exception, or choose a package that publishes SLSA build provenance (`dist.attestations`)." }, "provenance-identity": { summary: "Provenance identity does not match the required repo/workflow/builder.", action: "Verify the release's build identity; if the mismatch is unexpected, do not install and report it." }, "typosquat": { summary: "Name resembles a popular package.", action: "Confirm you meant this exact package name — check for a one-character typo against the intended dependency." }, diff --git a/packages/core/src/rules/obfuscation.ts b/packages/core/src/rules/obfuscation.ts index e05ff3a..4e29e96 100644 --- a/packages/core/src/rules/obfuscation.ts +++ b/packages/core/src/rules/obfuscation.ts @@ -1,18 +1,44 @@ -import type { AuditInput, Evidence, Finding, Rule } from "../types.js"; -import { codeFiles, mkFinding, scanLines, truncate } from "./util.js"; +import type { AuditInput, Evidence, Finding, PackageFile, Rule } from "../types.js"; +import { codeFiles, mkFinding, scanLines } from "./util.js"; const PATTERNS = [ - { re: /\beval\s*\(/, sev: "high" as const, why: "uses eval()" }, - { re: /new\s+Function\s*\(/, sev: "high" as const, why: "uses the Function constructor" }, - { re: /\batob\s*\(|Buffer\.from\([^)]*['"]base64['"]\)/, sev: "medium" as const, why: "base64-decodes at runtime" }, - { re: /\bunescape\s*\(|decodeURIComponent\(escape/, sev: "medium" as const, why: "uses unescape-style decoding" }, - { re: /(\\x[0-9a-f]{2}){6,}/i, sev: "medium" as const, why: "contains \\xNN-encoded string runs" }, - { re: /String\.fromCharCode\(|charCodeAt\(/, sev: "low" as const, why: "char-code string assembly" }, + // A property called `eval` is an ordinary method, not JavaScript's direct + // evaluator. Matching `.eval(` misattributes domain/runtime APIs as code + // execution (for example WebAssembly N-API shims). Explicit global-object + // access still reaches the language evaluator and must remain covered. + { + re: /(?:(?= 2) return evidence; + + DECODED_ASSIGNMENT.lastIndex = 0; + for (const match of file.content.matchAll(DECODED_ASSIGNMENT)) { + const variable = match[1]; + if (!variable) continue; + const usesDecodedSource = new RegExp(`new\\s+Function\\s*\\(\\s*${escapeRegExp(variable)}\\b`); + evidence.push(...scanLines(file, usesDecodedSource, 2 - evidence.length)); + if (evidence.length >= 2) break; + } + return evidence; +} export const obfuscationRule: Rule = { id: "obfuscation", @@ -35,15 +61,15 @@ export const obfuscationRule: Rule = { ); } - const blob = BLOB.exec(file.content); - if (blob) { + const dynamicFunction = decodedFunctionEvidence(file); + if (dynamicFunction.length > 0) { findings.push( mkFinding({ ruleId: this.id, category: this.category, - severity: "medium", - message: "Contains a large encoded blob (≥120 chars) consistent with packed/obfuscated payloads.", - evidence: [{ file: file.path, snippet: truncate(blob[0], 80) }], + severity: "high", + message: "Obfuscation: executes decoded source with the Function constructor.", + evidence: dynamicFunction, files: input.files, }), ); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1fcb93a..4cfaa7e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -117,7 +117,7 @@ export interface PackageMeta { fileCount: number; } -/** Policy-independent audit: what is cached by integrity. */ +/** Policy-independent audit: cached by package coordinate plus actual integrity. */ export interface Audit { schema: 3; meta: PackageMeta; diff --git a/packages/core/test/rules-obfuscation.test.ts b/packages/core/test/rules-obfuscation.test.ts new file mode 100644 index 0000000..c39e493 --- /dev/null +++ b/packages/core/test/rules-obfuscation.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { runRules, type AuditInput, type PackageFile } from "../src/index.js"; + +function inputFor(content: string): AuditInput { + const file: PackageFile = { + path: "package/index.js", + content, + size: Buffer.byteLength(content), + changed: false, + }; + return { + meta: { + name: "obfuscation-probe", + version: "1.0.0", + author: null, + maintainers: [], + license: null, + hasInstallScripts: false, + signature: "verified", + provenance: "verified", + integrity: "sha512-probe", + unpackedSize: file.size, + fileCount: 1, + }, + files: [file], + mode: "full", + }; +} + +function obfuscationFindings(content: string) { + return runRules(inputFor(content)).filter((finding) => finding.ruleId === "obfuscation"); +} + +describe("obfuscation rule", () => { + test("retains a high finding for direct dynamic code evaluation", () => { + const findings = obfuscationFindings("const stage = decode(payload); eval(stage);"); + assert.equal(findings.length, 1); + assert.equal(findings[0]?.severity, "high"); + assert.match(findings[0]?.message ?? "", /JavaScript eval/i); + }); + + test("retains a high finding for eval reached through an explicit global object", () => { + const findings = obfuscationFindings("globalThis.eval(atob(payload));"); + assert.equal(findings.length, 1); + assert.equal(findings[0]?.severity, "high"); + }); + + test("does not misclassify an object method named eval as direct code evaluation", () => { + assert.deepEqual(obfuscationFindings("const result = runtime.eval(script.value);"), []); + }); + + test("does not treat readable Function-constructor runtime glue as obfuscation", () => { + const source = ` + const factory = new Function("_", "return function " + functionName + "(){ return _; }"); + export default factory(value); + `; + assert.deepEqual(obfuscationFindings(source), []); + }); + + test("flags decoded source passed to the Function constructor", () => { + const source = ` + const packed = "${"Y29uc29sZS5sb2coMSk7".repeat(7)}"; + const stage = Buffer.from(packed, "base64").toString("utf8"); + new Function(stage)(); + `; + const findings = obfuscationFindings(source); + assert.ok( + findings.some((finding) => finding.severity === "high" && finding.message.includes("Function constructor")), + "decoded dynamic source must remain a high-severity obfuscation signal", + ); + }); + + test("does not treat an encoded data asset as obfuscation without a code-execution sink", () => { + const embeddedAsset = `export const wasmBytes = "${"AGFzbQEAAA".repeat(13)}";`; + assert.deepEqual(obfuscationFindings(embeddedAsset), []); + }); + + test("does not treat data transcoding as obfuscation without a code-execution sink", () => { + const source = String.raw` + export const bytes = Buffer.from(asset, "base64"); + export const decoded = atob(asset); + export const legacyText = unescape(input); + export const byte = text.charCodeAt(0); + export const escapedData = "\x41\x42\x43\x44\x45\x46"; + `; + assert.deepEqual(obfuscationFindings(source), []); + }); +}); diff --git a/packages/proxy/src/server.ts b/packages/proxy/src/server.ts index f51f016..bdf42cc 100644 --- a/packages/proxy/src/server.ts +++ b/packages/proxy/src/server.ts @@ -329,11 +329,12 @@ export function createServer(opts: ServerOptions) { }); // Transient concurrency dedupe: concurrent uncached public audits for the same - // name@version share one pipeline. The integrity-keyed `store` stays the durable - // cache (invariant #4); this map lives only within the overlapping-request window. + // name@version share one pipeline. The coordinate-and-integrity-keyed `store` + // stays the durable cache (invariant #4); this map lives only within the + // overlapping-request window. const inFlight = new Map>(); - /** Audit a specific version, using the verdict cache (integrity-keyed). */ + /** Audit a specific version, using the coordinate-and-integrity-keyed verdict cache. */ async function auditVersion( pkg: string, version: string, @@ -375,7 +376,7 @@ export function createServer(opts: ServerOptions) { // runAudit for the tamper check (ADR-0022). const actualIntegrity = integrityOf(tarball); - const cached = store.get(actualIntegrity); + const cached = store.get(pkg, version, actualIntegrity); if (cached) return { report: withClaimCorpus(cached.report), tarball }; const prev = previousVersion(Object.keys(pm.versions), version); @@ -777,12 +778,21 @@ export function createServer(opts: ServerOptions) { const recorded: Approval[] = []; try { for (const d of body) { - if (!d?.integrity || (d.decision !== "approved" && d.decision !== "denied")) { - return res.status(400).json({ error: "each approval needs integrity and decision(approved|denied)" }); + if (!d || typeof d.integrity !== "string" || + (d.decision !== "approved" && d.decision !== "denied") || + ((d.name === undefined) !== (d.version === undefined)) || + (d.name !== undefined && (typeof d.name !== "string" || typeof d.version !== "string"))) { + return res.status(400).json({ error: "each approval needs integrity, decision(approved|denied), and optional name + version" }); } - const audited = store.get(d.integrity); + const audited = typeof d.name === "string" && typeof d.version === "string" + ? store.get(d.name, d.version, d.integrity) + : store.getUniqueByIntegrity(d.integrity); if (!audited) { - return res.status(400).json({ error: `audit ${d.name}@${d.version} first (no report for that integrity)` }); + return res.status(400).json({ + error: typeof d.name === "string" + ? `audit ${d.name}@${d.version} first (no report for that coordinate and integrity)` + : "no unique audited coordinate for that integrity; include name and version", + }); } recorded.push(approvals.put({ name: audited.report.meta.name, version: audited.report.meta.version, @@ -814,7 +824,7 @@ export function createServer(opts: ServerOptions) { if (typeof b?.name !== "string" || typeof b.version !== "string" || typeof b.integrity !== "string" || typeof b.reason !== "string") { return res.status(400).json({ error: "need name, version, integrity, reason" }); } - const audited = store.get(b.integrity); + const audited = store.get(b.name, b.version, b.integrity); if (!audited) return res.status(400).json({ error: `audit ${b.name}@${b.version} first (no report for that integrity)` }); const reqByType = b.requestedBy?.type; const requestedBy: { type: "human" | "agent"; id: string } = @@ -839,7 +849,7 @@ export function createServer(opts: ServerOptions) { (v.kind !== "filesystem" && v.kind !== "network" && v.kind !== "process")) { return res.status(400).json({ error: "invalid violation: need name, version, integrity, kind, confidence" }); } - if (!store.get(v.integrity)) { + if (!store.get(v.name, v.version, v.integrity)) { return res.status(400).json({ error: `no audited report for integrity ${v.integrity} — audit before reporting` }); } const rec = violations.record( diff --git a/packages/proxy/src/store.ts b/packages/proxy/src/store.ts index f32ec47..febaa99 100644 --- a/packages/proxy/src/store.ts +++ b/packages/proxy/src/store.ts @@ -16,8 +16,8 @@ export interface StoredAudit { * dashboard survives a restart. Maps 1:1 onto a future Postgres `audits` table. */ export class AuditStore { - private byIntegrity = new Map(); - private order: string[] = []; // integrity keys, most-recent last + private byCoordinateIntegrity = new Map(); + private order: string[] = []; // coordinate + integrity keys, most-recent last constructor( private readonly file?: string, @@ -30,7 +30,9 @@ export class AuditStore { for (const r of rows) { if (r.report?.schema !== 3) continue; // re-audit anything older if (this.activePolicyHash && r.report.policy?.hash !== this.activePolicyHash) continue; // scored under a different policy - this.index(r.report.meta.integrity ?? r.key, r); + const integrity = r.report.meta.integrity; + if (!integrity) continue; // actual integrity is a mandatory cache-key dimension + this.index(this.cacheKey(r.name, r.version, integrity), r); } } catch { /* start empty on a corrupt log */ @@ -38,19 +40,38 @@ export class AuditStore { } } - /** Cache lookup by immutable integrity hash. */ - get(integrity: string | null | undefined): StoredAudit | undefined { - return integrity ? this.byIntegrity.get(integrity) : undefined; + /** Cache lookup by package coordinate plus immutable integrity hash. */ + get(name: string, version: string, integrity: string | null | undefined): StoredAudit | undefined { + return integrity ? this.byCoordinateIntegrity.get(this.cacheKey(name, version, integrity)) : undefined; + } + + /** + * Backward-compatible control-plane lookup for integrity-only approval payloads. + * Shared bytes are ambiguous and therefore fail closed instead of borrowing an + * arbitrary coordinate's report. + */ + getUniqueByIntegrity(integrity: string | null | undefined): StoredAudit | undefined { + if (!integrity) return undefined; + let found: StoredAudit | undefined; + for (const stored of this.byCoordinateIntegrity.values()) { + if (stored.report.meta.integrity !== integrity) continue; + if (found) return undefined; + found = stored; + } + return found; } put(report: AuditReport): StoredAudit { + if (!report.meta.integrity) { + throw new Error("cannot cache an audit report without actual integrity"); + } const stored: StoredAudit = { key: `${report.meta.name}@${report.meta.version}`, name: report.meta.name, version: report.meta.version, report, }; - this.index(report.meta.integrity ?? stored.key, stored); + this.index(this.cacheKey(stored.name, stored.version, report.meta.integrity), stored); this.persist(); try { this.history?.recordAudit(report, new Date().toISOString()); @@ -65,7 +86,7 @@ export class AuditStore { return this.order .slice(-limit) .reverse() - .map((k) => this.byIntegrity.get(k)) + .map((k) => this.byCoordinateIntegrity.get(k)) .filter((x): x is StoredAudit => Boolean(x)); } @@ -73,23 +94,27 @@ export class AuditStore { let allow = 0, warn = 0, block = 0; - for (const s of this.byIntegrity.values()) { + for (const s of this.byCoordinateIntegrity.values()) { if (s.report.verdict === "allow") allow++; else if (s.report.verdict === "warn") warn++; else block++; } - return { total: this.byIntegrity.size, allow, warn, block }; + return { total: this.byCoordinateIntegrity.size, allow, warn, block }; } private index(key: string, stored: StoredAudit): void { - if (!this.byIntegrity.has(key)) this.order.push(key); - this.byIntegrity.set(key, stored); + if (!this.byCoordinateIntegrity.has(key)) this.order.push(key); + this.byCoordinateIntegrity.set(key, stored); + } + + private cacheKey(name: string, version: string, integrity: string): string { + return `${name}\u0000${version}\u0000${integrity}`; } private persist(): void { if (!this.file) return; try { - writeFileSync(this.file, JSON.stringify([...this.byIntegrity.values()], null, 2)); + writeFileSync(this.file, JSON.stringify([...this.byCoordinateIntegrity.values()], null, 2)); } catch { /* best-effort */ } diff --git a/packages/proxy/test/explain-e2e.test.ts b/packages/proxy/test/explain-e2e.test.ts index 3ccce92..e73b51f 100644 --- a/packages/proxy/test/explain-e2e.test.ts +++ b/packages/proxy/test/explain-e2e.test.ts @@ -58,6 +58,80 @@ describe("GET /-/explain (e2e)", () => { }); }); +class SameBytesUpstream implements Upstream { + readonly name = "same-bytes"; + + constructor(private readonly tarball: Buffer) {} + + async getPackument(pkg: string): Promise { + const version = "1.0.0"; + return { + doc: { + name: pkg, + versions: { + [version]: { name: pkg, version, dist: { tarball: `https://registry.example/${pkg}.tgz`, integrity: integrityOf(this.tarball) } }, + }, + }, + versions: { + [version]: { + version, + author: null, + maintainers: [], + license: null, + signatures: null, + hasProvenance: false, + integrity: integrityOf(this.tarball), + hasInstallScripts: false, + }, + }, + }; + } + + async getTarball(_pkg: string, _version: string): Promise { + return this.tarball; + } + + async getAttestations(_pkg: string, _version: string): Promise { + return null; + } +} + +describe("GET /-/explain — coordinate-bound cache identity", () => { + let server: Server; let base: string; + + before(async () => { + ensureFixtures(); + const sharedTarball = readFileSync(join(FIXTURES, ".tarballs", "leftpad-lite-1.0.0.tgz")); + const app = createServer({ + upstream: new SameBytesUpstream(sharedTarball), + store: new AuditStore(), approvals: new ApprovalStore(), + enterprisePolicy: DEFAULT_POLICY, privateStore: new PrivatePackageStore(), + violations: new ViolationStore(), approvalRequests: new ApprovalRequestStore(), + }); + await new Promise((resolve) => { + server = app.listen(0, () => { + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + resolve(); + }); + }); + }); + after(() => server?.close()); + + test("returns findings for the requested coordinate when two packages share identical bytes", async () => { + const clean = await (await fetch(`${base}/-/explain/express/1.0.0`)).json() as { + report: { meta: { name: string }; findings: { ruleId: string }[] }; + }; + const lookalike = await (await fetch(`${base}/-/explain/expres/1.0.0`)).json() as { + report: { meta: { name: string }; findings: { ruleId: string }[] }; + }; + + assert.equal(clean.report.meta.name, "express"); + assert.equal(lookalike.report.meta.name, "expres"); + assert.equal(clean.report.findings.some((finding) => finding.ruleId === "typosquat"), false); + assert.equal(lookalike.report.findings.some((finding) => finding.ruleId === "typosquat"), true); + }); +}); + // A stub upstream that THROWS on any public call — proves findLastKnownGood never // reaches it for a claimed private namespace (invariant #7: claimed names are // authoritative private, never consulted on public npm). diff --git a/packages/proxy/test/retraction-e2e.test.ts b/packages/proxy/test/retraction-e2e.test.ts index 8facd7e..c5a793d 100644 --- a/packages/proxy/test/retraction-e2e.test.ts +++ b/packages/proxy/test/retraction-e2e.test.ts @@ -76,7 +76,7 @@ describe("Phase 32 time-locked retraction", () => { const ctx = await boot(71, 999); const before = await fetch(`${ctx.base}/-/audit/${encodeURIComponent("@acme/widget")}/2.0.0`); assert.equal(before.status, 200); - const storedBefore = JSON.stringify(ctx.store.get(integrityOf(Buffer.from("tarball-2.0.0")))?.report); + const storedBefore = JSON.stringify(ctx.store.get("@acme/widget", "2.0.0", integrityOf(Buffer.from("tarball-2.0.0")))?.report); const response = await ctx.retract("security"); assert.equal(response.status, 201, await response.clone().text()); @@ -98,7 +98,7 @@ describe("Phase 32 time-locked retraction", () => { error: "package version retracted", package: "@acme/widget@2.0.0", ...created.tombstone, }); assert.equal((await fetch(`${ctx.base}/@acme%2Fwidget/-/widget-1.0.0.tgz`)).status, 200); - assert.equal(JSON.stringify(ctx.store.get(integrityOf(Buffer.from("tarball-2.0.0")))?.report), storedBefore, + assert.equal(JSON.stringify(ctx.store.get("@acme/widget", "2.0.0", integrityOf(Buffer.from("tarball-2.0.0")))?.report), storedBefore, "serve-time retraction overlay must not rewrite the cached AuditReport"); }); @@ -136,7 +136,7 @@ describe("Phase 32 time-locked retraction", () => { const ctx = await boot(2, 0); await fetch(`${ctx.base}/@acme%2Fwidget/-/widget-2.0.0.tgz`); const integrity = integrityOf(Buffer.from("tarball-2.0.0")); - const storedBefore = JSON.stringify(ctx.store.get(integrity)?.report); + const storedBefore = JSON.stringify(ctx.store.get("@acme/widget", "2.0.0", integrity)?.report); assert.equal((await ctx.retract("security")).status, 201); const tree = await (await fetch(`${ctx.base}/-/audit-tree`, { @@ -146,7 +146,7 @@ describe("Phase 32 time-locked retraction", () => { assert.equal(tree.packages[0].status, "block"); assert.equal(tree.packages[0].topFindingRuleId, "known-advisory"); assert.match(tree.packages[0].topFinding, /retracted/i); - assert.equal(JSON.stringify(ctx.store.get(integrity)?.report), storedBefore); + assert.equal(JSON.stringify(ctx.store.get("@acme/widget", "2.0.0", integrity)?.report), storedBefore); }); test("explain never recommends a retracted prior version", async () => { diff --git a/packages/proxy/test/store.test.ts b/packages/proxy/test/store.test.ts index 1d4ce49..1f3b42e 100644 --- a/packages/proxy/test/store.test.ts +++ b/packages/proxy/test/store.test.ts @@ -3,8 +3,20 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, test } from "node:test"; +import type { AuditReport } from "@git-agentic/sentinel-core"; import { AuditStore } from "../src/store.js"; +function report(name: string, integrity: string): AuditReport { + return { + schema: 3, + meta: { name, version: "1.0.0", integrity }, + verdict: "allow", + score: 100, + findings: [], + policy: { version: "test", hash: "sha256-test" }, + } as unknown as AuditReport; +} + describe("AuditStore schema handling", () => { test("drops persisted schema-1 audits on load", () => { const dir = mkdtempSync(join(tmpdir(), "sentinel-store-")); @@ -15,6 +27,46 @@ describe("AuditStore schema handling", () => { }]; writeFileSync(file, JSON.stringify(legacy)); const store = new AuditStore(file); - assert.equal(store.get("sha512-legacy"), undefined, "schema-1 entry is not served from cache"); + assert.equal(store.get("old", "1.0.0", "sha512-legacy"), undefined, "schema-1 entry is not served from cache"); + }); + + test("drops persisted schema-3 audits that lack actual integrity", () => { + const dir = mkdtempSync(join(tmpdir(), "sentinel-store-")); + const file = join(dir, "audits.json"); + const missingIntegrity = report("old", "sha512-placeholder"); + missingIntegrity.meta.integrity = null; + writeFileSync(file, JSON.stringify([{ + key: "old@1.0.0", name: "old", version: "1.0.0", report: missingIntegrity, + }])); + + assert.equal(new AuditStore(file).stats().total, 0); + }); +}); + +describe("AuditStore cache identity", () => { + test("stores byte-identical package coordinates independently", () => { + const store = new AuditStore(); + store.put(report("express", "sha512-shared")); + store.put(report("expres", "sha512-shared")); + + assert.equal(store.get("express", "1.0.0", "sha512-shared")?.name, "express"); + assert.equal(store.get("expres", "1.0.0", "sha512-shared")?.name, "expres"); + assert.equal(store.stats().total, 2); + }); + + test("integrity-only control-plane lookup fails closed when bytes are shared", () => { + const store = new AuditStore(); + store.put(report("express", "sha512-shared")); + assert.equal(store.getUniqueByIntegrity("sha512-shared")?.name, "express"); + + store.put(report("expres", "sha512-shared")); + assert.equal(store.getUniqueByIntegrity("sha512-shared"), undefined); + }); + + test("rejects a new report without actual integrity", () => { + const store = new AuditStore(); + const missingIntegrity = report("express", "sha512-placeholder"); + missingIntegrity.meta.integrity = null; + assert.throws(() => store.put(missingIntegrity), /without actual integrity/); }); });