diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4444dc3..99a07fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,14 @@ name: ci # Base CI for the catalog repo (story 055.W3.1) + D24's full invariant suite (story 055.W3.3): -# - D20(1) blocking secret scanning + D20(4) capability analysis: still story 055.W4.1 / 055.W4.2, -# not built here. +# - D20(4) capability analysis landed in story 055.W4.2 (see its step below). +# - D20(1) BLOCKING secret scanning landed in story 055.W4.1: the gate itself lives inside +# publisher/publish.mjs (lib/secret-scanner.mjs + the vendored gitleaks corpus in +# lib/secret-rules.mjs), with a per-CLASS negative fixture through the real CLI in +# test/publish-cli.test.mjs. The steps this workflow adds for it are the two things a unit test +# cannot show: that the scanner BINARY runs end-to-end over a real artifact and REFUSES one with a +# planted credential, and that the plugin channel stays decoupled from the binary channel. +# What the scanner cannot see is documented in docs/SECRET-SCANNING.md and printed on every run. # - D24's three no-going-back invariants (immutable id via lineage_id, burned-name ledger, # license-in-package-root) + the publish-time half of D21 (tier vocabulary from the plugin's own # manifest) — ADDED this story. See docs/INVARIANTS.md for the full design reasoning. @@ -86,6 +92,78 @@ jobs: node scripts/analyze-capabilities.mjs --artifact "$tmp/demo.tar.gz" --require-allowed-tools echo "OK — analyzer ran end-to-end over a real artifact" + # story 055.W4.1 (D20(1)) — the BLOCKING scanner, exercised end-to-end over real artifacts. + # THREE artifacts on purpose: a clean one that must PASS, a planted one that must be REFUSED, + # and (fix-cycle-1, F2) an UNSCANNABLE one that must also be REFUSED. A step that only ever + # runs the clean case is the exact failure this story names — "a scanner that passes verde + # against a clean package proves nothing". The planted credential is assembled here from + # fragments so this workflow file never contains a literal credential shape (which would also + # trip the base guard further down this job). + - name: Secret scanning refuses a planted credential, refuses an unscannable member, passes a clean package (D20(1) — BLOCKING) + run: | + set -e + tmp="$(mktemp -d)" + + # 1. clean package — must exit 0 + mkdir -p "$tmp/clean/skills/demo" + printf 'MIT\n' > "$tmp/clean/LICENSE" + printf -- '---\nname: demo\ndescription: demo\nallowed-tools: Read\n---\n\nNothing secret here.\n' \ + > "$tmp/clean/skills/demo/SKILL.md" + tar -czf "$tmp/clean.tar.gz" -C "$tmp/clean" . + node scripts/scan-secrets.mjs --artifact "$tmp/clean.tar.gz" + echo "OK — clean package passes" + + # 2. same package + one planted credential — must exit non-zero + cp -R "$tmp/clean" "$tmp/dirty" + mkdir -p "$tmp/dirty/config" + printf 'GH_TOKEN=%s%s\n' 'ghp_' 'aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789' > "$tmp/dirty/config/ci.env" + tar -czf "$tmp/dirty.tar.gz" -C "$tmp/dirty" . + if node scripts/scan-secrets.mjs --artifact "$tmp/dirty.tar.gz"; then + echo "REFUSED: the scanner accepted an artifact with a planted credential — the gate is decorative" + exit 1 + fi + echo "OK — planted credential is REFUSED end-to-end" + + # 3. fix-cycle-1 (F2): the SAME credential behind a one-byte NUL prefix. Before the + # fail-closed decision this exited 0 — the member was classified binary, skipped, and + # the artifact published. Unscannable is now treated as not publishable. + cp -R "$tmp/clean" "$tmp/unscannable" + mkdir -p "$tmp/unscannable/config" + printf '\000' > "$tmp/unscannable/config/creds.env" + printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' >> "$tmp/unscannable/config/creds.env" + tar -czf "$tmp/unscannable.tar.gz" -C "$tmp/unscannable" . + if node scripts/scan-secrets.mjs --artifact "$tmp/unscannable.tar.gz"; then + echo "REFUSED: an unscannable member passed — one NUL byte defeats the gate again" + exit 1 + fi + echo "OK — unscannable member is REFUSED end-to-end (fail-closed)" + + # 4. fix-cycle-2 (F10): a SHADOWED duplicate member — the same path twice in one tar + # stream, credential first, clean second. Extraction keeps only the clean one, so a + # filesystem-based inventory saw nothing; the credential nevertheless shipped and was + # recoverable with `tar -xOzf`. The scan now enumerates the archive's MEMBER TABLE. + cp -R "$tmp/clean" "$tmp/shadow" + mkdir -p "$tmp/shadow/config" + printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' > "$tmp/shadow/config/app.env" + ( cd "$tmp/shadow" && tar -cf "$tmp/shadow.tar" . ) + mkdir -p "$tmp/shadow2/config" + printf 'APP_ENV=production\n' > "$tmp/shadow2/config/app.env" + ( cd "$tmp/shadow2" && tar -rf "$tmp/shadow.tar" ./config/app.env ) + gzip -c "$tmp/shadow.tar" > "$tmp/shadow.tar.gz" + # the fixture is only meaningful if the credential really is in the published bytes + tar -xOzf "$tmp/shadow.tar.gz" ./config/app.env | grep -q 'AKIA' \ + || { echo "fixture broken: the shadowed member does not carry the credential"; exit 1; } + if node scripts/scan-secrets.mjs --artifact "$tmp/shadow.tar.gz"; then + echo "REFUSED: a shadowed duplicate member passed — the credential ships silently" + exit 1 + fi + echo "OK — shadowed duplicate member is REFUSED end-to-end (F10)" + + # story 055.W4.1 (AC5) — the plugin channel must never read binary-channel state. + - name: Plugin channel stays decoupled from the binary channel (D19 / AC5) + run: | + node scripts/check-channel-separation.mjs + - name: No obvious secret shapes committed (base guard — NOT the D20(1) blocking scanner) run: | set -e diff --git a/README.md b/README.md index 6f2052e..7b68323 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,51 @@ catalog side of those decisions; the Cockpit-side consumer lives in the product | `docs/CATALOG-AND-MIRROR.md` | How the index, the R2 artifact mirror, and the publish pipeline fit together | — | | `docs/SCHEMA.md` | Field-by-field explanation of the index entry schema | — | | `docs/INVARIANTS.md` | The four no-going-back invariants (D24 a/b/c + D21's `AC8`), how each is verified, and the explicitly-named design boundaries | — | +| `lib/secret-rules.mjs` | The **vendored gitleaks rule corpus** (MIT, a dated snapshot of a named upstream ref) — 14 rules across 14 covered classes, plus what was deliberately left out and why | Enforced publish-time AND in CI (`055.W4.1`) | +| `lib/secret-scanner.mjs` | The engine that runs those rules over the manifest and the artifact's real bytes, with its blind spots attached to every report | Enforced publish-time AND in CI | +| `lib/pin.mjs` | Version pin resolution (`@` → digest) — a **pure function** of (index, pin), which is what makes it deterministic and channel-independent at once | — | +| `scripts/check-channel-separation.mjs` | CI proof that no executable file here reads binary-channel state (D19 / AC5) | Run on every push | +| `docs/SECRET-SCANNING.md` | What the blocking scanner catches, why the rules are vendored rather than depended on, and — the important part — **what it does not see** | — | +| `docs/PIN-AND-CHANNEL.md` | The pin, its determinism proof, **its cost**, and the plugin channel's independence from the binary channel | — | + +## Blocking secret scanning (`055.W4.1`, D20(1)) + +A package containing a recognisable credential **does not publish** — failure, not warning, with no +flag or environment variable that disables it. The scan covers the **manifest** (which becomes a +public catalog entry) and the **artifact's real bytes** (what a client downloads and runs), using a +vendored subset of gitleaks' rule corpus so the detection patterns are reused rather than reinvented. + +```bash +node scripts/scan-secrets.mjs --artifact +node scripts/scan-secrets.mjs --manifest --json +``` + +**Read `docs/SECRET-SCANNING.md` §5 before treating a clean scan as a safety verdict.** In +particular: the scan inspects the *published* manifest and artifact, **not** the target of an MCP +pointer resolved at runtime (`{command, args}`, typically `npx `), and an obfuscated or +encoded secret escapes it entirely. Those limits are printed on **every** run, including successful +ones. + +A member the scan **cannot read** — binary, oversized, a duplicate/shadowed path, or a non-regular +member — **blocks the publish** rather than being skipped: unscannable is treated as not publishable +(§5.1), and the inventory comes from the archive's own **member table**, not from what survives +extraction (§5.2). + +## Version pin + the plugin channel (`055.W4.1`, D20(2) / D19) + +`@` resolves to a **digest**; the mirror path is content-addressed, so the same +pin yields the same bytes. The plugin's update cycle is **independent** of the cockpit binary's +(`ADR-COCKPIT-UPDATE-CHANNELS`, epic 017 — reused as a concept, never reimplemented here), and CI +enforces that no file in this repo reads binary-channel state. + +```bash +node scripts/resolve-pin.mjs --index index/index.json --pin sinkra-os@1.2.0 [--verify ./downloaded.tar.gz] +``` + +**The pin is not pure gain.** It freezes an install — which also means an already-installed artifact +**cannot be repaired** by a later corrected build. Index freshness (`055.W5.1`, D20(5)) is what gives +that capability back. The cost is carried on every resolution and printed in every output mode; the +reasoning is in `docs/PIN-AND-CHANNEL.md` §2. ## Capability analysis + mandatory `allowed-tools` (`055.W4.2`) diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index e0e4dbf..076efc6 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -24,7 +24,7 @@ Measured in the product repo's SOT (`.aiox-core/skills/`), 2026-08-10: | Skills declaring `allowed-tools` before this story | **0 of 35** | | Skills that ship their own `scripts/` (`OWNS_SCRIPTS`) | **6 of 35** | | Skills whose body instructs executing a script (`INSTRUCTS_EXECUTION`) | **9 of 35** | -| Is an MCP server an inspectable artifact? | **No — a `{command, args}` pointer** (`mcp.rs:68`) | +| Is an MCP server an inspectable artifact? | **No — a `{command, args}` pointer** (`crates/aiox-core/src/mcp.rs:68`) | D17's prohibition on third-party `scripts/` remains valid, but it is verifiable **over the folder, not over the behaviour**. That is why the analyzer emits two signals and never one number. @@ -126,7 +126,7 @@ These travel **with** the capabilities, always — onto the entry, and into the coupling is deliberate: **a capability list displayed without its blind spots lies by omission.** - **An MCP server is a runtime-resolved pointer, not an artifact.** The manifest supplies - `{command, args}` (product repo `mcp.rs:68`, typically `npx `) against a registry AIOX + `{command, args}` (product repo `crates/aiox-core/src/mcp.rs:68`, typically `npx `) against a registry AIOX does not control. This analysis covers the **pointer**; it has never opened the **target**. The `npx` target is never inspected, downloaded or executed. - A signature over the index covers the pointer, not the pointed-at package. **Provenance is not diff --git a/docs/CATALOG-AND-MIRROR.md b/docs/CATALOG-AND-MIRROR.md index a9fb73b..bf67735 100644 --- a/docs/CATALOG-AND-MIRROR.md +++ b/docs/CATALOG-AND-MIRROR.md @@ -66,3 +66,8 @@ is not built here. If you are editing this repo and about to write the word "rev "remove" as something the system does **on its own** (not as an explicit, manual, reasoned operator action), stop — check whether `055.W1.3`'s `O5` reconciliation and `055.W5.1` have actually landed first. + +Story `055.W4.1` obeys this guardrail explicitly: the version pin's documented cost is that an +already-installed artifact **cannot be repaired**, and the thing that gives that capability back is +index freshness (`055.W5.1`). *Repair* is not *revocation*, and `docs/PIN-AND-CHANNEL.md` §2 says so +in the same words rather than leaving a reader to infer it. diff --git a/docs/PIN-AND-CHANNEL.md b/docs/PIN-AND-CHANNEL.md new file mode 100644 index 0000000..cac4b20 --- /dev/null +++ b/docs/PIN-AND-CHANNEL.md @@ -0,0 +1,160 @@ +# Version pin + the plugin's own channel (story 055.W4.1, D20(2) / D19) + +Decision records: `ADR-COCKPIT-ENTERPRISE-PREMIUM-PACK` **D20(2)** (*"pin de versão + canal +separado"*) and **D19** (a plugin's update cycle is **independent** of the binary's — marker by +version+tier+digest, separate from `.aiox-core-build`). + +--- + +## 1. What a pin is + +``` +@ e.g. sinkra-os@1.2.0 +``` + +Resolving a pin against an index yields the artifact's **digest**, and the digest is what a client +fetches by. There is no mutable "latest" pointer anywhere in this path. + +```bash +node scripts/resolve-pin.mjs --index index/index.json --pin sinkra-os@1.2.0 +node scripts/resolve-pin.mjs --index index/index.json --pin sinkra-os@1.2.0 --verify ./downloaded.tar.gz +``` + +**Same pin ⇒ same digest ⇒ same bytes.** The mirror path is content-addressed +(`plugins///.tar.gz` — see `docs/CATALOG-AND-MIRROR.md`), so the digest +*is* the filename: the same bytes can never be silently swapped under an existing pointer. + +### Determinism is a property of the function, not a promise in a doc + +`resolvePin` (`lib/pin.mjs`) is a **pure function of exactly two inputs**: the parsed index data and +the pin string. It reads no clock, no environment variable, no file, and no network. That is what +makes the guarantee testable rather than assertable — and it is also, in the same breath, the +mechanism behind §3: *a function that cannot observe the binary channel cannot be affected by it*. + +**Ambiguity is a refusal, never a tie-break.** If an index somehow carried two entries for the same +`plugin_id@version` with different digests, any tie-break (first / last / highest) would make the +resolved BYTES depend on entry ORDER — i.e. on how the file was edited. That is precisely the silent +artifact substitution D24(b) exists to prevent, so `resolvePin` refuses and says so. + +### Proof (AC4), executed against the live mirror + +The artifact mirrored by story `055.W3.1` was downloaded from the real public R2 endpoint and its +bytes re-hashed against what the pin resolves to: + +``` +$ node scripts/resolve-pin.mjs --index fixtures/index.json --pin sinkra-os@0.0.0-fixture +pin sinkra-os@0.0.0-fixture +digest sha256:9ec01ff45d2966fde7de79e46b31fa97a9485f28e2b625fdfe0af0aaa433561a + +$ curl -sS -o w31.tar.gz -w "HTTP_STATUS=%{http_code} bytes=%{size_download}\n" \ + "https://pub-42179e62dc3040138151ec33229dd073.r2.dev/plugins-fixtures/sinkra-os/0.0.0-fixture/9ec01ff45d2966fde7de79e46b31fa97a9485f28e2b625fdfe0af0aaa433561a.tar.gz" +HTTP_STATUS=200 bytes=1188 + +$ node scripts/resolve-pin.mjs --index fixtures/index.json --pin sinkra-os@0.0.0-fixture --verify w31.tar.gz + expected 9ec01ff45d2966fde7de79e46b31fa97a9485f28e2b625fdfe0af0aaa433561a + actual 9ec01ff45d2966fde7de79e46b31fa97a9485f28e2b625fdfe0af0aaa433561a + result MATCH — same pin, same digest, same bytes +``` + +`test/pin.test.mjs` keeps this honest **offline**: it asserts the resolver still maps that pin to +that exact digest and content-addressed URL, so a regression is caught on every push without a unit +suite depending on a bucket being reachable. + +## 2. What pinning COSTS — this is not a footnote + +Advisory-council finding **`C4`**, verified: *"of the four original controls, zero acted on an +already-installed artifact, and the pin even prevented it from being fixed."* + +| | | +|---|---| +| **Benefit** | An install is reproducible. The same pin resolves to the same digest, which fetches the same bytes, forever. A client that never re-resolves can never be silently handed different content. | +| **COST** | **The same property prevents an already-installed artifact from being repaired.** A pinned client keeps resolving that version — *including after the publisher ships a corrected build*. Pinning freezes the good and the bad alike: it does not act on what is already on a user's disk, and it actively stands in the way of anything that would. | +| **What gives that capability back** | **Index freshness — story `055.W5.1` (D20(5))**: an `expires` field plus a monotonic index version, so a client can distinguish a stale index from a current one and knows when it must re-resolve. | + +This is carried as **data on every resolution** (`pin_cost` on the result object) and printed by the +CLI in every output mode — the same posture `capabilities.limits` takes in `docs/CAPABILITIES.md`. +A benefit that can be displayed without its cost eventually *is* displayed without it, and then the +pin reads as pure gain, which is false. + +> **`VC-3` — this is not a revocation claim.** Restoring the ability to **repair** is not the same as +> revocation, and nothing in this module implements, implies or depends on revocation. Revocation is +> governed by `O5` (story `055.W1.3`), which has **not** closed; epic 055 rule R2 forbids any story +> from asserting it exists. See `docs/CATALOG-AND-MIRROR.md` § "AC8 (`055.W3.1`)" — the same +> guardrail, in the same words, applies here. + +## 3. The plugin channel is not the binary channel (AC5) + +The cockpit **binary** already has an update channel, governed by `ADR-COCKPIT-UPDATE-CHANNELS` +(epic `017`, `Done`) — per-role channels, its own feed, its own installer. **That concept is REUSED, +not reimplemented.** Nothing in this repository is a second binary channel. + +| | plugin channel (this repo) | binary channel (epic 017) | +|---|---|---| +| identified by | **version + tier + digest** — the product's plugin marker, `~/.aiox/sinkra-os-plugin.marker` (`crates/aiox-cockpit/src/plugin_channel.rs`) | `.aiox-core-build` / the binary's own update feed (`crates/aiox-cockpit/src/provision.rs`, `updater.rs`) | +| resolved from | the catalog index + the pin, and nothing else | the release feed for the user's role | +| governed by | D19 / D20(2), this repo | `ADR-COCKPIT-UPDATE-CHANNELS` | + +### Independence, proved in BOTH directions + +Both directions are the same fact stated twice — `resolvePin` is a pure function of (index, pin) — +but each is asserted separately, because "obvious from the design" is exactly the kind of claim that +stops being true after one edit. + +1. **A plugin updates without the binary.** `test/pin.test.mjs` publishes `pinme@1.0.0` then + `pinme@1.1.0` through the real CLI, resolves both pins (different digests), confirms the old pin + still resolves to the old digest, and asserts the whole cycle created **no** binary-channel + artifact (`.aiox-core-build`, `RELEASES`). +2. **The binary updates without the plugin.** The same test moves binary-channel state through three + distinct configurations — marker absent, build A, build B — and mutates the environment + (`AIOX_UPDATE_CHANNEL`, `AIOX_CORE_BUILD`), resolving the same pin at every step and asserting + **byte-identical** resolutions. A resolver that read binary state would change its answer here. + +### Enforced, not merely intended + +`scripts/check-channel-separation.mjs` runs in CI and refuses any executable file in this repository +(`lib/`, `publisher/`, `scripts/`, `schema/`, `index/`, `ledger/`) that references a binary-channel +identifier, plus it refuses a `process.env` read in the resolver itself. + +**Exactly what is exempt, stated precisely** (fix-cycle-1, F4 — the earlier wording here claimed more +than the code did): + +1. **Comment content.** Comments are blanked (offsets preserved, so line numbers stay true) and the + guard searches the code that remains. A doc-comment naming an identifier in order to *declare* the + separation is legitimate and stays exempt. This replaced a line-prefix check that the QG defeated + with one character — a real `readFileSync(".aiox-core-build")` written after a `/*` opener on the + same line used to pass. Quote tracking is included so a `//` inside a `"https://…"` string is not + mistaken for a comment opener, which would fail in the dangerous direction. +2. **The frozen `binary_channel_identifiers` list in `lib/pin.mjs`** — which *is* the declaration of + what must not be read, and is the single source the guard itself reads. This exemption is a + **text-range** check (from `binary_channel_identifiers` to the closing `]),`), not an AST one: an + expression placed inside that range would be exempt too. Stated plainly rather than described as + tighter than it is. An occurrence anywhere else in that file is refused like anywhere else. + +**Residual, corrected in fix-cycle-2 (F12).** The previous wording here claimed regex confusion could +only ever cause **over**-reporting, "never missing a coupling". **That was false**, and the QG +executed the counterexample: `const re = /[/*]/;` on the line before a real +`readFileSync(".aiox-core-build")` passed the guard, because the `/*` inside the character class +opened block-comment state and erased the coupling. (Under the older line-prefix logic that same +construction *was* caught, so it was a regression introduced by this very fix.) + +It is now **fixed**, not merely documented, by two independent measures: regex literals in +regex-start position are recognised and consumed as code, and an **unterminated** block comment is +treated as a parse failure that falls back to scanning the raw text — which over-reports, the safe +direction, and would have caught F12 on its own. + +What genuinely remains: a regex literal that the start-position heuristic misclassifies as division +**and** which contains a *balanced* `/* … */` could still blank real code. That is narrower than the +hole F12 exercised, but it is not impossible, and this document no longer says it is. + +**The guard has its own negative fixture.** `test/pin.test.mjs` plants a coupling in the scanned +surface, asserts the guard FAILS, removes it, and asserts it goes back to green — a guard only ever +observed passing is a guard nobody has seen catch anything. + +### Product-side evidence (measured, both repos) + +| Measurement | Result | +|---|---| +| binary-channel modules (`updater.rs`, `update_gate.rs`) referencing `plugin_channel` or the plugin marker | **0** | +| `plugin_channel.rs` referencing velopack / `RELEASES` / `update_gate` / `updater` | **0** | +| `.aiox-core-build` occurrences in `plugin_channel.rs` | **3** — two doc-comments stating the separation, one inside `mod tests` writing a fixture home; no runtime read | +| catalog repo executable files referencing any binary-channel identifier | **0** (21 files scanned by the CI guard) | diff --git a/docs/SECRET-SCANNING.md b/docs/SECRET-SCANNING.md new file mode 100644 index 0000000..35977f3 --- /dev/null +++ b/docs/SECRET-SCANNING.md @@ -0,0 +1,423 @@ +# Secret scanning — what it catches, and what it does not (story 055.W4.1, D20(1)) + +Decision record: `ADR-COCKPIT-ENTERPRISE-PREMIUM-PACK`, **D20(1)** — *"scanning de segredos bloqueante +no publish"*. D20 also records **why review is not the primary defence**: the 77 Evil Twin extensions +that shipped malware all passed human review. The control here is mechanical and blocking. + +**The most important section of this document is the last one.** If you only read one part, read +"What this scanner does NOT see". + +--- + +## 1. Where the gate is, and that it is unconditional + +`publisher/publish.mjs` runs the scan **before any other analysis**, over two subjects: + +| Subject | Why it is in scope | +|---|---| +| the **manifest** (`--manifest`, scanned as raw JSON text) | it becomes a **public catalog entry**; a credential pasted into a `description` is published verbatim | +| the **artifact** (`--artifact`, its real extracted bytes) | it is what a client downloads and runs | + +A finding is a **refusal** — nonzero exit, index untouched, ledger untouched. **So is a member the +scan could not read** (see §5.1: unscannable ⇒ not publishable, fail-closed). There is no flag, no +environment variable and no fixture path that disables it. That matches the posture of D24's four +invariants (`docs/INVARIANTS.md`), and it is asserted by tests rather than promised in prose: every +covered class has a planted-credential fixture that is pushed through the real CLI as a subprocess +and must be REFUSED (`test/publish-cli.test.mjs`, describe `055.W4.1`). + +The refusal also tells the publisher to **rotate** the credential. A secret that reached bytes +prepared for publication should be treated as exposed whether or not the publish went through. + +Standalone CLI, same engine (imported, never reimplemented, so CI-time and publish-time cannot +drift): + +```bash +node scripts/scan-secrets.mjs --artifact path/to/plugin.tar.gz +node scripts/scan-secrets.mjs --manifest path/to/manifest.json --json +``` + +## 2. Why the rules are vendored from gitleaks (VC-1 — REUSE before writing a scanner) + +Writing a secret detector from scratch would be both disproportionate and worse than the state of the +art. The corpus in `lib/secret-rules.mjs` is a **verbatim subset of gitleaks' +`config/gitleaks.toml`** (`master`, 222 rules, upstream latest `v8.30.1` when vendored on +2026-08-09; MIT, © 2019 Zachary Rice). Rule ids, descriptions, entropy floors and keyword prefilters +are the upstream values, so any finding is greppable against gitleaks' own documentation. + +The three options, and why this one: + +| Option | Verdict | +|---|---| +| **npm dependency** (`secretlint` + a recommended ruleset) | **Rejected.** It would introduce the first `package.json`/`node_modules` into this repo — today everything is stdlib `node:` plus system binaries, so a bare checkout runs the tests and the publisher with no install step. Putting `npm install` on the **publish path** means the blocking gate can fail for reasons unrelated to the package being published (registry outage, lockfile drift, a transitive advisory). A gate whose likeliest failure is unrelated to its subject is a gate that gets disabled. | +| **pinned gitleaks binary / GitHub Action** | **Rejected as the primary mechanism.** It works in CI, but AC1 requires the gate to block *inside* `publisher/publish.mjs`, which runs wherever the AIOX publish service runs. Shelling out to a possibly-absent binary leaves only fail-open (a gate that silently stops gating) or fail-closed-on-missing-tool (a pipeline that breaks on a machine without gitleaks). | +| **vendor the ruleset, keep the engine local** | **Chosen.** Reuse of the *detection corpus* — the part that is genuinely hard and that a hand-rolled scanner would get worse — with zero runtime dependency. The engine (`lib/secret-scanner.mjs`) is regex iteration plus Shannon entropy: small, testable, and blocking exactly where AC1 requires. | + +**The cost of this choice, named:** a vendored corpus does not update itself. `SECRET_RULES_PROVENANCE` +records the upstream ref, file, rule count and vendoring date so the staleness is *measurable*; it is +not automatic. Re-vendoring is mechanical: add the rule, add its fixture — the test suite fails if +you add one without the other. + +### The RE2 → JavaScript port + +gitleaks patterns are Go RE2. Exactly two constructs do not exist in JavaScript and were handled +explicitly, with no other character of any pattern modified: + +- `(?i)` inline flag → carried as the regex's `i` flag (identical semantics; every affected rule has + it as the leading token). +- `(?-i:…)` (a case-sensitive island inside a case-insensitive pattern) → **no JavaScript + equivalent**. It appears only in `generic-api-key`'s allowlist, which is why that rule is not + vendored (§4). + +A test asserts every vendored pattern compiles and that no RE2-only construct survived — the port is +verified, not assumed. + +## 3. Covered classes (14) + +Each row has a planted-credential fixture that is REFUSED through the real CLI, and each fixture is +invalid in **exactly one way** (asserted mechanically — a fixture that tripped two rules would let +its test pass for the wrong reason). + +| Class | Upstream rule id | Shape | +|---|---|---| +| `private-key` | `private-key` | PEM `BEGIN … PRIVATE KEY` block | +| `aws-access-key` | `aws-access-token` | `AKIA`/`ASIA`/`ABIA`/`ACCA`/`A3T…` access key id | +| `github-token` | `github-pat` | `ghp_` personal access token | +| `github-fine-grained-token` | `github-fine-grained-pat` | `github_pat_` fine-grained token | +| `cloudflare-api-token` | `cloudflare-api-key` | 40-char token in a `cloudflare…=` assignment | +| `cloudflare-global-api-key` | `cloudflare-global-api-key` | 37-hex global key in a `cloudflare…=` assignment | +| `slack-token` | `slack-bot-token` | `xoxb-` bot token | +| `slack-user-token` | `slack-user-token` | `xoxp-`/`xoxe-` user token | +| `stripe-key` | `stripe-access-token` | `sk_live_`/`rk_test_`/… | +| `openai-api-key` | `openai-api-key` | `sk-…T3BlbkFJ…` | +| `anthropic-api-key` | `anthropic-api-key` | `sk-ant-api03-…AA` | +| `gcp-api-key` | `gcp-api-key` | `AIza…` | +| `npm-token` | `npm-access-token` | `npm_` access token | +| `jwt` | `jwt` | `ey….ey….…` | + +Two upstream behaviours are kept because they are what stop the corpus from crying wolf: + +- **Entropy floors.** Shape alone over-fires: `ghp_` followed by 36 identical characters matches the + pattern and is obviously not a token. A shape-valid, low-entropy value is not reported. +- **Allowlists.** AWS's own `…EXAMPLE` documentation key and gitleaks' list of 16 well-known fake + Google keys are not findings — a package that quotes a vendor's sample in its README still + publishes. + +**Findings are redacted.** A finding carries the first 4 characters and the length, never the value. +A scanner that prints what it found turns every CI log into the leak it was preventing. + +## 4. Deliberately NOT vendored + +- **`generic-api-key`** — gitleaks' catch-all for unknown providers (any `key`/`token`/`secret`-ish + assignment above entropy 3.5). Its usability depends entirely on a large allowlist built on RE2's + `(?-i:…)`, which does not port. Running it *without* its allowlist inverts the cost of a mistake + from "a secret slips through" to "a legitimate publish is refused because a skill wrote + `api_version: 2026-08-09`" — and a blocking gate that cries wolf is a gate that gets bypassed. + **Consequence, stated plainly: a credential from an unlisted provider, or one with no recognisable + prefix, is NOT detected.** +- **The other ~208 upstream rules** — the vendored subset covers the providers this catalog is + plausibly exposed to (cloud, git forge, package registry, payments, AI vendors, this org's own + Cloudflare/R2 infra) plus the format-recognisable generics. Vendoring all 222 would multiply the + per-class fixture obligation by ~16 without adding an exposure this catalog actually has, and an + **unproven rule is exactly the "gate that passes verde using a tool blind to the defect"** failure + this lineage already shipped once. + +## 5. What this scanner does NOT see — READ THIS ONE + +This is the section AC3 exists for, and it is the easiest one to treat as bureaucracy. The finding it +answers to (**advisory-council `C2`**) is that *communicating a control as stronger than it is makes +the user calibrate trust by the label*. So these limits are not an appendix: they are a field of every +report object (`limits`), printed by `renderScanReport` on **every run — including a clean one that +succeeds**, and asserted by tests. This mirrors the posture `capabilities.limits` already takes +(`docs/CAPABILITIES.md` §6): limits travel WITH the claim and are never displayed apart from it. + +### (a) It inspects the published manifest and artifact — NOT the target of an MCP pointer + +An MCP server in a plugin is a **runtime-resolved pointer**, not an inspectable artifact. The manifest +supplies `{command, args}` (product repo, `crates/aiox-core/src/mcp.rs:68`), typically +`npx `, resolved against a registry AIOX does not control. + +This scan covers **the pointer**. It has never opened, downloaded or executed **the target**, and +what `npx` fetches tomorrow is not what was published today. **A clean scan says nothing whatsoever +about the code an MCP pointer will pull at runtime.** + +### (b) An obfuscated or encoded secret escapes + +Every rule is a regex over literal text. A credential that is base64'd, split across concatenated +strings, XOR'd, stored reversed, or assembled at runtime matches nothing and is not detected. This is +a **shape detector, not a semantic one**. + +### The rest, measured while building it + +- **Coverage is a fixed list of providers**, not "secrets" in general (§4). +- **The corpus does not update itself** — it is a dated snapshot of a named upstream ref. +- **Binary and oversized members cannot be scanned — and therefore BLOCK the publish** (§5.1). +- **Symlinks are not followed** — a symlink's target is outside the artifact; scanning it would + report on the publishing machine's filesystem, not on what ships. Since fix-cycle-2 they are also + not *dropped*: they are enumerated from the member table and refused (§5.2). +- **The member table has its own blind spots** — a tar parser differential, and nested archives + (§5.2). +- **A clean scan is not a security verdict.** It means "no known credential *shape* was found in + these bytes". It is not a statement that the package is safe, that it does no harm, or that AIOX + endorses it — the catalog signs the **index** to attest provenance, never the artifact to attest + endorsement (D20(3)). + +### 5.1 Unscannable ⇒ not publishable (fail-closed) — the decision, and what it costs + +**The defect this closes, which was executed rather than theorised.** The QG built two artifacts, each +carrying a real shape-valid AWS key, identical except for a one-line evasion: **(A)** one leading NUL +byte, so the member reads as binary; **(B)** the same credential followed by >5 MiB of padding, so the +member exceeds the scan cap. Both were skipped, both reported "Findings: none", both exited **0**. +AC1 says a package containing a recognisable credential does not publish; these did. + +**What was — and was not — wrong about that.** The blindness was *disclosed*: every run printed which +members were skipped and said "a skipped file is an UNKNOWN, not a pass". So this was never the +failure this lineage is haunted by (a gate passing green using a tool blind to the defect it was +meant to catch — that failure is about **undisclosed** blindness). The real gap was smaller and more +damning: every other trade-off in this deliverable is written down at its decision site, and this one +was not. It was presented as an unavoidable property of scanning rather than as an alternative that +had been weighed. **Disclosure is not enforcement.** + +**The decision: fail-closed.** `publisher/publish.mjs` and `scripts/scan-secrets.mjs` refuse when any +member could not be read. A member nobody could read is a member nobody can certify. + +**What it costs — named, because "it's free" would be false:** + +| Cost | Assessment | +|---|---| +| A legitimate binary asset (icon, font, `.wasm`) is refused | Does not exist today — a plugin is skills + scripts + a licence — but it is the case most likely to appear first. | +| macOS packaging junk (`.DS_Store`, AppleDouble `._*`) is refused | **The likeliest real false refusal**: those files are binary and `tar` on macOS sweeps them in by accident. Arguably the *correct* outcome (that junk has no business in a published artifact), but it will surprise a macOS publisher — so the refusal names the file and says exactly that. | +| A genuinely large **text** member (>5 MiB) is refused | Refused rather than silently unexamined. | + +**Why pay that now:** the catalog has **zero** real entries and is closed to external publishers, so +today the false-refusal cost is literally zero — which makes this the only moment when tightening the +rule is free. Same reasoning that made D24 worth ratifying before a catalog existed: these things +only cost nothing before they exist. + +**Why there is no `--allow-unscannable` override**, though the obvious design has one: an override is +precisely the disable path AC1 forbids and the bypass sweep hunts for, and a flag that lets unread +bytes through is a flag that will be passed by default in somebody's CI within a quarter. When a +legitimate binary-asset case actually appears, the answer is a **designed rule** (e.g. an explicit, +digest-pinned asset allowlist that records which member is exempt and why), decided with a real case +in hand — not a bypass built speculatively. Tracked in the product repo: +`docs/backlog/aiox-plugins-scanner-fail-closed-sem-rota-para-asset-binario-legitimo.md`. + +**Proven by execution, not by this paragraph.** Both evasions are permanent fixtures +(`buildArtifactWithNulPrefixedSecret`, `buildArtifactWithOversizedSecret`) pushed through the real CLI +in `test/publish-cli.test.mjs`, plus a control proving the refusal is about *not being able to look* +rather than about finding something (a clean binary member with no credential in it also blocks), plus +the positive control that a clean package still publishes. + +### 5.2 The inventory is the ARCHIVE, not the extraction (fix-cycle-2, F10/F11) + +Fail-closed only means something if the list of members it runs over is complete. It was not. + +**Executed by the QG, and reproduced here before the fix.** A single tar stream carrying the **same +path twice** — first member holding a shape-valid AWS key, second member clean — extracts to one +clean file. The scanner inventoried the *extracted filesystem*, saw one file, found nothing, exited +**0**. The credential shipped and stayed fully recoverable from the published bytes: + +``` +$ tar -tzf shadow.tar.gz | sort | uniq -d +./config/app.env ← the same member, twice +$ tar -xOzf shadow.tar.gz ./config/app.env +AWS_ACCESS_KEY_ID=AKIA… ← still there, in the bytes a client downloads +``` + +**Why this outranked the gap it replaced**, even at the same severity: §5.1 was survivable because +the scanner *said out loud* what it had not read. A shadowed member appeared in **nothing** — not +`files_total`, not `skipped_binary`, not `skipped_too_large`, not `unscannable`. Undisclosed +blindness is the disqualifying kind. The same root cause had a quieter symptom too (**F11**): +symlinks and other non-regular members were dropped *before* enumeration, so a 3-member archive +reported `2/2 file(s) scanned` — which reads as complete coverage of an archive that was not fully +seen, in the very report §5 makes load-bearing. + +**The fix, once, for both.** The **member table** (`tar -tzf` for names, `tar -tvzf` for types) is +the source of truth for what the archive contains; the extracted tree only supplies *bytes* for +members the table says are ordinary files. Anything the table lists that cannot be mapped to exactly +one readable regular file is **unscannable**, and therefore refused by the path §5.1 already built: + +| Member kind | Treatment | +|---|---| +| regular file, unique path | scanned | +| **directory** — *positively identified*: rendered type `d` **and** size exactly 0 | structural — carries no bytes, present in every normal artifact, **not** refused (a fix that refused these would refuse everything) | +| **directory-shaped but carrying data** (or whose size cannot be determined) | refused — §5.3 | +| **duplicate path** | refused — extraction keeps only the last, so an earlier member's bytes ship without ever existing on disk to be read | +| **non-regular** (symlink, hardlink, FIFO, socket, device) | refused — enumerated rather than dropped before counting | +| **absolute or `..`-escaping path** | refused — cannot be mapped to a file inside the package root | +| listed as a regular file but **absent after extraction** | refused — a member that was never read is not a pass | + +`files_total` now counts the archive's content members, so `N/M file(s) scanned` means what a reader +assumes it means. + +**What the member table still cannot see** — declared here and in the limits printed on every run, +because the lesson of §5.1 is that an undeclared blind spot is the disqualifying kind: + +1. **Parser differential.** The inventory is *this* `tar`'s parse. A crafted archive that another tar + implementation reads differently — extra, ignored or ambiguous headers, PAX vs ustar + disagreements — could present a consumer with members this scan never saw. Nothing here detects + that. It is an adversarial construction, and it matters most at the same moment §5.1's residual + does: when the catalog opens to external publishers. **Scope, stated precisely because it was + misread once:** this covers *disagreement between parses*. It does **not** cover a member this + parse itself misclassifies — that is a defect, not a residual, and one such defect (F14) was found + and fixed in §5.3. The remedy for both, if this area is ever worked again, is a real tar reader + instead of parsing CLI output. + > **Superseded by §5.4 (fix-cycle-4).** The remedy this paragraph names is the one that was built. + > A differential between the two parses is now *detected* and refused by name in both directions, + > and the first thing it found was not adversarial at all: macOS AppleDouble members, which `tar` + > hides from its own listing. What remains of this residual is narrower — see §5.4. +2. **Structure, not content.** The table cannot tell that an ordinary-looking member is itself a + nested archive whose contents are never opened. +3. **Unenumerable archives are refused outright.** If the archive cannot be walked cleanly, the whole + artifact is refused rather than guessed at — fail-closed, but it means such an archive cannot be + published at all. (fix-cycle-4 widened *and* sharpened this: see §5.4.) + +**Fixtures** (`test/publish-cli.test.mjs`, through the real CLI): the shadowed-duplicate artifact — +which first asserts the credential really *is* recoverable from the archive, so a later refusal +cannot pass for the wrong reason — the symlink artifact, a `files_total` honesty check (3 reported as +3), the positive control that a clean package still publishes, and a control that directories are not +refused. + +### 5.3 The classifier is an ALLOWLIST — exemption requires positive evidence (fix-cycle-3, F14) + +**Executed by the QG, and reproduced here before the fix.** A hand-forged ustar member with typeflag +`0` (**regular file**) whose **name ends in `/`**, carrying 39 bytes with a shape-valid AWS key: + +``` +$ tar -tvzf forged.tar.gz +-rw-r--r-- 0 0 0 4 Aug 10 01:48 ./LICENSE +-rw-r--r-- 0 0 0 51 Aug 10 01:48 ./SKILL.md +drw-r--r-- 0 0 0 39 Aug 10 01:48 ./config/payload/ ← rendered `d`, but 39 bytes of DATA +$ tar -xOzf forged.tar.gz ./config/payload/ +AWS_ACCESS_KEY_ID=AKIA… ← same tar, same machine +``` + +Before the fix: `2/2 file(s) scanned`, `Findings: none`, **exit 0**. The member was excluded from +classification *before* any refusal logic could run. + +**This is not the declared parser-differential residual.** That residual is about *different* tar +implementations disagreeing. Here one implementation is enough to recover the credential, so it was a +classification bug in this parse — a genuine finding, not a disclosed limit. + +**The root cause was the shape of the rule, not the missing case.** The old pre-filter asked what a +member *looks like* and exempted on resemblance: `type !== "d" && !name.endsWith("/")`. Anything +resembling a directory inherited the directory exemption **regardless of what it carried**. Over a +format as old and permissive as tar, a denylist keeps producing findings — three cycles running, each +closed the demonstrated instance and left an undemonstrated one. + +So the question is inverted: + +| | | +|---|---| +| **Old (denylist)** | "is this member excluded from classification?" → exempt on **resemblance** | +| **New (allowlist)** | "can this member be **positively identified** as one of two known-safe things?" → everything else, **including anything unrecognised**, is unscannable and refuses | + +The two positively-identified categories, and nothing else: + +- **Directory** — rendered type `d` **and** a size that is **known and exactly 0**. A real directory + carries no data. A member rendered as a directory with non-zero size is anomalous by construction. + If the size cannot be parsed at all, that is **not** a pass either: an unverifiable claim to be a + directory is unscannable, because the whole point of the inversion is that **exemption requires + positive evidence**. +- **Ordinary file** — rendered type `-`/`0`, name not ending in `/`, safe path, unique among content + members, mappable to exactly one extracted regular file. + +**Directories must stay exempt**, and that is the one thing this inversion cannot tighten: refusing +them would refuse *every* package built the normal way (`tar -czf x.tgz -C dir .`). That carve-out is +pinned by its own control test, alongside a test of the allowlist property itself (a directory whose +size is unknown refuses; a directory carrying data refuses; an ordinary file still reads). + +**Fixtures:** the forged archive is a permanent fixture that first asserts the archive is **valid** +and the credential **really is recoverable** from it — the second engine's own attempt at this probe +produced a *damaged* archive that yielded only NUL bytes, i.e. an unproven assertion dressed as a +finding, which is worth remembering before trusting a probe nobody ran. + +### 5.4 Classification reads the ustar HEADER, not `tar`'s rendered listing (fix-cycle-4, F17) + +**Executed by the QG.** The §5.3 archive with **one extra header field** — a crafted `uname` of +`0 Aug 1` written into the ustar header's 32-byte uname slot. Nothing about the member changed: same +typeflag `0`, same trailing-slash name, same 39 bytes of credential. What changed is the *rendering*: + +``` +drw-r--r-- 0 0 Aug 1 g 39 Jul 27 2021 ./config/payload/ + ^^^^^^^ injected — "digits followed by a date", the exact shape the size regex + anchored on, appearing BEFORE the real size of 39 +``` + +`parseMemberSize` returned `0`, `isRealDirectory` became true, the member was exempted, and the scan +reported `2/2 file(s) scanned`, `Findings: none`, **exit 0** — while `tar -xOzf` printed the key. + +**§5.3 was right in *form* and wrong in *source*.** Inverting the classifier so exemption requires +positive evidence was the correct move and it stands. What it did not change is **where the evidence +comes from**: both the type and the size were read from `tar -tvzf`, a *human-readable rendering* +whose column layout is a function of attacker-supplied header fields. An allowlist whose evidence is +forgeable is an allowlist in shape and a denylist in effect. §5.3's own note even says "rendered is +load-bearing" about the type — and then uses a second value from the same rendering as the +cross-check. + +**The fix is terminal for the class, not another enumeration step.** The two facts are read from the +archive's own bytes, at **fixed offsets** in each 512-byte ustar header: + +| Fact | Offset | Why it cannot be forged *around* | +|---|---|---| +| `typeflag` | **156** | no header field an attacker writes moves another field's offset | +| `size` (octal) | **124** | same — there is no "one more forged column" variant of this | + +`tar` is still consulted, **demoted from source of truth to second opinion**: its enumeration is +compared member-by-member against the header walk, and a member only one parse can see is refused by +name — `hidden-member` (in the headers, not in `tar`'s listing) or `phantom-member` (the reverse). + +**What that immediately found, and it is not hypothetical.** macOS `tar -czf` writes an AppleDouble +`._name` companion member for every file carrying an extended attribute, and **`tar -tzf` does not +list it**. Measured: + +``` +$ xattr -w com.example.cfg "AWS_ACCESS_KEY_ID=AKIA…" LICENSE && tar -czf a.tgz . +$ tar -tzf a.tgz +./ +./LICENSE ← the listing admits two members +$ # …while the credential is recoverable verbatim from the published bytes, +$ # carried by a 163-byte ./._LICENSE member the listing never mentions. +``` + +Every cycle before this one enumerated from that listing, so **all four reported complete coverage of +archives containing members they had never seen** — the same undisclosed-blindness class as +F10/F11/F14/F17. Those members are now enumerated and refused, with the operator told the actual +remedy (`COPYFILE_DISABLE=1 tar -czf …`, which is what a well-formed macOS build uses anyway). + +**Cost, named because "it's free" would be false.** This is a minimal tar reader — the thing parsing +CLI output was chosen to avoid. To avoid refusing *legitimate* packages it must handle the extensions +real archives use: the ustar `prefix` field, PAX `x`/`g` records (whose `path`/`size` overrides are +honoured, because ignoring an override `tar` obeys would recreate the very divergence this closes), +and GNU `L`/`K` long names. Anything it cannot walk cleanly — a header failing its own checksum, an +unreadable size field, data past the end-of-archive marker, an archive `tar` cannot unpack — refuses +the **whole** artifact. That last case used to throw an uncaught exception with a stack trace instead +of producing a report; it is now a named refusal. + +**What remains of the differential residual:** detection compares exactly **two** parsers, this walk +and the local `tar`. A third implementation that disagrees with *both* is still not covered. + +**Fixtures** (`test/publish-cli.test.mjs`): the forged-`uname` archive, which first asserts the +credential really is recoverable *and* that the poisoned rendering still fools §5.3's exact regex — +so the test cannot pass because the fixture failed to inject the field; both directions of the +differential through the real `classifyMembers`; a broken-header archive; the AppleDouble trigger +(real `xattr`, therefore macOS-only — CI's coverage of the refusal is the portable unit test); and +the positive controls, re-run in full: **23/23**, zero regressions and zero over-fire. + +## 6. Relationship to the base grep in CI + +`.github/workflows/ci.yml` has a small grep step ("No obvious secret shapes committed") that scans +**this repository's own committed files**. It is a different control with a different subject and was +labelled "NOT the D20(1) blocking scanner" when it landed. It stays exactly as it was; the blocking +scanner described here scans what gets **published**, which the base grep never looks at. + +The workflow additionally runs the scanner end-to-end over **two** artifacts — one clean (must pass) +and one with a planted credential (must be refused). A step that only ever ran the clean case would +prove only that the binary starts. + +## 7. AC7 — no credential in this pipeline + +Every fixture value in `test/helpers/secret-fixtures.mjs` is **fabricated** and **assembled at +runtime from fragments**, so the repository never contains a well-formed credential shape +contiguously. That is deliberate on two counts: it honours AC7 directly, and it keeps this repo's own +committed-secret grep intact — the tempting alternative (excluding `test/` from that guard) would +punch a hole in a working control to accommodate a test. diff --git a/lib/capability-analyzer.mjs b/lib/capability-analyzer.mjs index a6560ef..9acd6d2 100644 --- a/lib/capability-analyzer.mjs +++ b/lib/capability-analyzer.mjs @@ -308,7 +308,7 @@ export function deriveCapabilities(body, signals) { // Without this, the capability display lies by omission — the C2 finding in its most expensive // form. Every report carries these limits; they are not an appendix, they are part of the result. export const ANALYZER_LIMITS = Object.freeze([ - "An MCP server is a RUNTIME-RESOLVED POINTER, not an inspectable artifact: the manifest supplies `{command, args}` (see the product repo's mcp.rs:68, typically `npx `), resolved against a registry AIOX does not control. This analysis covers the POINTER; it has never opened the TARGET. The `npx` target is NEVER inspected, downloaded or executed here, so nothing below constrains what that package does once it runs.", + "An MCP server is a RUNTIME-RESOLVED POINTER, not an inspectable artifact: the manifest supplies `{command, args}` (see the product repo's crates/aiox-core/src/mcp.rs:68, typically `npx `), resolved against a registry AIOX does not control. This analysis covers the POINTER; it has never opened the TARGET. The `npx` target is NEVER inspected, downloaded or executed here, so nothing below constrains what that package does once it runs.", "A signature over the index covers the pointer, not the pointed-at package. Provenance is not behaviour.", "This is STATIC analysis of skill prose. It cannot see what an agent will actually decide to do at runtime, and a skill body is natural language — an execution instruction phrased in a form these probes do not match will be missed. Absence of a signal is NOT proof of absence of the behaviour.", "An execution instruction classified `ambient` (a bare script name with no path) resolves from PATH/cwd at runtime. Which file actually runs is decided on the user's machine and is not knowable here.", diff --git a/lib/pin.mjs b/lib/pin.mjs new file mode 100644 index 0000000..be4fe1c --- /dev/null +++ b/lib/pin.mjs @@ -0,0 +1,198 @@ +// lib/pin.mjs — story 055.W4.1, D20(2): version PIN + the plugin's own CHANNEL. +// +// A "pin" is `@`. Resolving it against an index yields the artifact's DIGEST, +// and the digest is what a client fetches by — never a mutable "latest" pointer. That is the whole +// mechanism: same pin ⇒ same digest ⇒ same bytes (AC4). +// +// ── DETERMINISM IS A PROPERTY OF THE FUNCTION, NOT A PROMISE IN A DOC ──────────────────────────── +// +// `resolvePin` is a PURE function of exactly two inputs: the parsed index data and the pin string. +// It reads no clock, no environment variable, no file, no network, and no channel state of any kind. +// That is what makes AC4 provable rather than assertable, and it is also the mechanical half of AC5: +// a function that cannot observe the binary channel cannot be affected by it. `test/pin.test.mjs` +// proves both directions by resolving the same pin with binary-channel state present and absent, and +// with the environment mutated, asserting byte-identical results. +// +// ── WHY AMBIGUITY IS A REFUSAL, NOT A "PICK THE FIRST ONE" ─────────────────────────────────────── +// +// If one index somehow carried two entries for the same `plugin_id@version`, any tie-break rule +// (first, last, highest) would make the RESOLUTION depend on entry ORDER — i.e. on how the file was +// edited — which is exactly the silent substitution D24(b) exists to prevent. Refusing is the only +// answer that keeps the pin meaningful. +// +// fix-cycle-1 (F5): this paragraph used to say that and the code only half-did it — duplicates with +// DIFFERENT digests were refused, duplicates with the SAME digest were tie-broken with `exact[0]`, +// so `tiers` and `mirror_url` were order-dependent. `tiers` is the entitlement axis, so that is not +// cosmetic. Any duplicate is now a refusal; the reasoning and the implementation say the same thing. + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +export const PIN_SYNTAX = "@"; + +// Mirrors lib/entry-schema.mjs's shapes. Imported values are not reused here on purpose: this module +// must stay resolvable against ANY index that declares itself valid, including a future schema +// version, so it validates the two fields it actually consumes rather than the whole entry. +const PIN_RE = /^([a-z0-9][a-z0-9-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +export function parsePin(pin) { + const m = PIN_RE.exec(String(pin ?? "").trim()); + if (!m) { + throw new Error( + `invalid pin "${pin}" — expected ${PIN_SYNTAX} (kebab-case plugin_id, semver version), e.g. "sinkra-os@1.2.0"`, + ); + } + return { plugin_id: m[1], version: m[2] }; +} + +// ── AC6 — the pin's COST, carried WITH every resolution ────────────────────────────────────────── +// +// Advisory-council finding C4, verified: "of the 4 original controls, zero acted on an +// already-installed artifact, and the pin even prevented it from being fixed." +// +// This is DATA on the result object, not prose in a document, for the same reason +// `capabilities.limits` is data in lib/capability-analyzer.mjs: a benefit that can be displayed +// without its cost WILL eventually be displayed without it, and then the pin reads as pure gain — +// which is false. +export const PIN_COST = Object.freeze({ + benefit: + "A pin makes an install reproducible: the same pin resolves to the same digest, which fetches the same bytes, forever. A client that never re-resolves can never be silently handed different content.", + cost: + "THE SAME PROPERTY PREVENTS AN ALREADY-INSTALLED ARTIFACT FROM BEING REPAIRED. A client pinned to a version keeps resolving that version — including after the publisher ships a corrected build. Pinning freezes the good and the bad alike: it is not a control that acts on what is already on a user's disk, and it actively stands in the way of one.", + what_gives_the_capability_back: + "Index freshness — story 055.W5.1 (D20(5)): an `expires` field plus a monotonic index version, so a client can tell a stale index from a current one and knows when it must re-resolve. That is the mechanism that restores the ability to REPAIR an installed artifact.", + not_a_revocation_claim: + "Restoring the ability to repair is NOT the same as revocation, and nothing in this module implements, implies or depends on revocation. Revocation is governed by O5 (story 055.W1.3), which has NOT closed — epic 055 rule R2 forbids any story from asserting it exists. See docs/CATALOG-AND-MIRROR.md, section 'AC8 (055.W3.1)'.", +}); + +// ── AC5 — the plugin channel is not the binary channel ─────────────────────────────────────────── +// +// REUSE, not reimplementation: the binary's update channel already exists and is governed by +// `ADR-COCKPIT-UPDATE-CHANNELS` (epic 017, Done) — per-role channels for the cockpit BINARY. This +// module does not implement a second one and must never read the first. D19 fixes that the plugin's +// cycle is independent of the binary's (marker by version+tier+digest, separate from +// `.aiox-core-build`). +export const CHANNEL = Object.freeze({ + name: "plugin", + what_identifies_an_install: + "version + tier + digest (the product's plugin marker, ~/.aiox/sinkra-os-plugin.marker — crates/aiox-cockpit/src/plugin_channel.rs)", + resolved_from: "the catalog index + the pin, and nothing else", + // The binary channel's identifiers. This module never reads any of them; + // scripts/check-channel-separation.mjs mechanically proves that for the whole repository, so the + // separation is enforced rather than merely intended. + binary_channel_identifiers: Object.freeze([ + ".aiox-core-build", // the framework/binary provisioning marker (crates/aiox-cockpit/src/provision.rs) + "ADR-COCKPIT-UPDATE-CHANNELS", // the per-role binary channel decision (epic 017) + "velopack", // the installer/updater the binary ships through + "RELEASES", // the binary update feed's manifest file + ]), + independence: + "A plugin can be re-pinned and updated with the binary untouched, and the binary can update with every plugin pin unchanged: the two are resolved from different inputs, recorded in different markers, and neither reads the other's state.", +}); + +// Resolve a pin against parsed index data. PURE — index data + pin string in, resolution out. +export function resolvePin(indexData, pin) { + const { plugin_id, version } = parsePin(pin); + const entries = Array.isArray(indexData?.entries) ? indexData.entries : null; + if (!entries) throw new Error("index has no `entries` array — cannot resolve a pin against it"); + + const forPlugin = entries.filter((e) => e.plugin_id === plugin_id); + if (forPlugin.length === 0) { + const known = [...new Set(entries.map((e) => e.plugin_id))].sort(); + throw new Error( + `pin "${pin}" — no plugin "${plugin_id}" in this index. Known plugin_ids: ${known.length ? known.join(", ") : "(index is empty)"}`, + ); + } + + const exact = forPlugin.filter((e) => e.version === version); + if (exact.length === 0) { + const versions = [...new Set(forPlugin.map((e) => e.version))].sort(); + throw new Error( + `pin "${pin}" — plugin "${plugin_id}" exists but not at version "${version}". Published versions: ${versions.join(", ")}`, + ); + } + + // fix-cycle-1 (F5). This used to refuse only when the duplicates carried DIFFERENT digests, and + // silently took `exact[0]` otherwise — which the QG executed: two same-digest entries for one pin, + // in two index orderings, resolved to different `artifact.mirror_url` and different `tiers`. + // + // Two reasons that was the wrong shape, and the second is the one that matters: + // 1. The comment at the top of this module argues that ANY tie-break "would make the resolved + // BYTES depend on entry ORDER" — stated absolutely while the code tie-broke in one case. The + // reasoning has to be as strict as the implementation or one of them is a lie. + // 2. `tiers` is the ENTITLEMENT axis (`gate_plugin(plugin, tier)`), so an order-dependent `tiers` + // is not cosmetic drift — it is which customers the resolution says may install this. + // + // So: ANY duplicate `plugin_id@version` is a refusal, whatever the digests say. The publish path + // already refuses to create one (lib/entry-schema.mjs::checkNoConflictingDuplicate), so reaching + // this requires a hand-edited index — which is exactly the case a resolver should not paper over. + if (exact.length > 1) { + const digests = [...new Set(exact.map((e) => e.digest?.value))]; + const detail = digests.length > 1 + ? `with ${digests.length} DIFFERENT digests (${digests.join(", ")})` + : `with the same digest but potentially differing metadata (tiers/mirror_url) — order would decide which one you get, and \`tiers\` is the entitlement axis`; + throw new Error( + `pin "${pin}" — REFUSED: ${exact.length} entries share this plugin_id@version ${detail}. A pin whose resolution depends on entry order is not a pin; the index is corrupt and must be fixed, not tie-broken.`, + ); + } + + const e = exact[0]; + if (!e.digest?.value || !e.artifact?.mirror_url) { + throw new Error(`pin "${pin}" — entry is missing digest.value or artifact.mirror_url; cannot resolve to bytes`); + } + // fix-cycle-1 (F6). This used to default a missing `digest.algorithm` to "sha256". Defaulting is + // the wrong posture for the field that decides how bytes are verified: the schema REQUIRES it + // (schema/index-entry.schema.json), so an entry without it is malformed, and silently assuming the + // algorithm means a future entry using a different one would be verified with the wrong function + // while reporting success. Refuse instead — fail-closed is the shape of an invariant. + if (!e.digest.algorithm) { + throw new Error( + `pin "${pin}" — REFUSED: the entry has no digest.algorithm. The field is REQUIRED by the entry schema; this resolver will not assume one, because assuming the algorithm that verifies bytes is how a mismatch becomes a silent pass.`, + ); + } + + return { + pin: `${plugin_id}@${version}`, + plugin_id, + version, + lineage_id: e.lineage_id ?? null, + tiers: e.tiers ?? [], + digest: { algorithm: e.digest.algorithm, value: e.digest.value }, + artifact: { mirror_url: e.artifact.mirror_url, r2_key: e.artifact.r2_key ?? null }, + channel: CHANNEL, + // The cost travels with the claim — see PIN_COST's comment. + pin_cost: PIN_COST, + }; +} + +export function resolvePinFromFile(indexPath, pin) { + return resolvePin(JSON.parse(readFileSync(indexPath, "utf8")), pin); +} + +// Verify downloaded/local bytes against a resolution. This is the "same digest ⇒ same bytes" half of +// AC4 — a pin that resolves to a digest nobody ever checks is a string, not a guarantee. +export function verifyBytesAgainstPin(resolved, filePath) { + if (resolved.digest.algorithm !== "sha256") { + throw new Error(`unsupported digest algorithm "${resolved.digest.algorithm}" — only sha256 is implemented`); + } + const actual = createHash("sha256").update(readFileSync(filePath)).digest("hex"); + return { ok: actual === resolved.digest.value, expected: resolved.digest.value, actual, path: filePath }; +} + +export function renderResolution(resolved) { + const out = []; + out.push(`pin ${resolved.pin}`); + out.push(`digest ${resolved.digest.algorithm}:${resolved.digest.value}`); + out.push(`artifact ${resolved.artifact.mirror_url}`); + out.push(`tiers ${resolved.tiers.join(", ") || "(none)"}`); + out.push(`channel ${resolved.channel.name} — ${resolved.channel.resolved_from}`); + out.push(` identified by: ${resolved.channel.what_identifies_an_install}`); + out.push(` independent of the binary channel (${resolved.channel.binary_channel_identifiers.join(", ")})`); + out.push(""); + out.push("WHAT PINNING COSTS (this is not a footnote):"); + out.push(` benefit: ${resolved.pin_cost.benefit}`); + out.push(` COST: ${resolved.pin_cost.cost}`); + out.push(` fix: ${resolved.pin_cost.what_gives_the_capability_back}`); + out.push(` note: ${resolved.pin_cost.not_a_revocation_claim}`); + return out.join("\n"); +} diff --git a/lib/secret-rules.mjs b/lib/secret-rules.mjs new file mode 100644 index 0000000..1e29b5a --- /dev/null +++ b/lib/secret-rules.mjs @@ -0,0 +1,258 @@ +// lib/secret-rules.mjs — the DETECTION CORPUS for story 055.W4.1 (D20(1)). +// +// ── WHERE THESE RULES COME FROM (VC-1: REUSE before writing a scanner) ──────────────────────────── +// +// Every rule below is a VERBATIM REUSE of a rule from **gitleaks** — `config/gitleaks.toml` on the +// `master` branch (222 rules; the file is auto-generated from `cmd/generate/config/`), fetched +// 2026-08-09. Latest release at the time of vendoring: **v8.30.1**; the config declares +// `minVersion = "v8.25.0"`. gitleaks is MIT-licensed (Copyright (c) 2019 Zachary Rice) — the same +// permissive terms this repository ships under, so vendoring the corpus with attribution is +// license-clean. Nothing here was invented: the `id`, `description`, `entropy` threshold, `keywords` +// prefilter and the regex body are the upstream values. +// +// WHY VENDORED AND NOT DEPENDED ON — the trade-off, recorded rather than assumed: +// +// 1. `secretlint` (or any npm scanner) would introduce the FIRST `package.json`/`node_modules` +// into this repository. Today every script here is stdlib `node:` plus shelling out to system +// binaries (`tar`, `git`) — deliberately, so `node --test test/*.test.mjs` and +// `publisher/publish.mjs` run on a bare checkout with zero install step. Adding an install step +// to the PUBLISH path means the blocking gate can fail for reasons that have nothing to do with +// the package being published (registry outage, lockfile drift, transitive advisory). A gate +// whose most likely failure mode is unrelated to its subject gets disabled by whoever is on +// call. REJECTED. +// +// 2. The `gitleaks` BINARY (or its GitHub Action) is the natural choice for CI — but AC1 requires +// the gate to BLOCK inside `publisher/publish.mjs`, which runs wherever the AIOX publish +// service runs, not only in GitHub Actions. Shelling out to a binary that may be absent leaves +// exactly two options: fail-open (a gate that silently stops gating — the precise defect this +// lineage already shipped once) or fail-closed on a missing binary (a publish pipeline that +// breaks on a machine without gitleaks installed). REJECTED as the primary mechanism. +// +// 3. VENDORING THE RULESET — reuse of the DETECTION CORPUS (the part that is actually hard and +// that a hand-rolled scanner would get worse) without a runtime dependency of any kind. The +// engine that runs them (`lib/secret-scanner.mjs`) is ~200 lines of regex + Shannon entropy, +// which is genuinely small; the 222 curated provider patterns are what took an ecosystem years. +// CHOSEN. +// +// THE COST OF (3), NAMED: a vendored corpus does not update itself. New provider formats land +// upstream and never reach this file until someone re-vendors. `SECRET_RULES_PROVENANCE` below +// records exactly which upstream snapshot this is so the staleness is measurable rather than +// invisible, and `docs/SECRET-SCANNING.md` carries it as a declared limit, not a footnote. +// +// ── RE2 → JavaScript PORTING NOTES (the only edits made to upstream text) ───────────────────────── +// +// gitleaks regexes are Go RE2. Two constructs do not exist in JavaScript: +// +// * `(?i)` inline flag → carried as `flags: "i"` on the rule instead. Semantics are identical +// (Go applies `(?i)` to the whole pattern when it is the leading token, which it is in every +// rule below that uses it). +// * `(?-i:...)` (a case-SENSITIVE island inside a case-insensitive pattern) → has NO JavaScript +// equivalent. It appears only in `generic-api-key`'s allowlist, which is why that rule is NOT +// vendored — see `DELIBERATELY_NOT_VENDORED` at the bottom of this file. +// +// No other character of any pattern was modified. A rule whose regex could not be ported faithfully +// was left out rather than approximated: an approximated pattern is a rule whose behaviour nobody +// can predict from the upstream docs, which defeats the point of reusing an established corpus. + +export const SECRET_RULES_PROVENANCE = Object.freeze({ + source: "gitleaks — https://github.com/gitleaks/gitleaks", + file: "config/gitleaks.toml (auto-generated from cmd/generate/config/)", + ref: "master", + upstream_latest_release_when_vendored: "v8.30.1", + config_min_version: "v8.25.0", + upstream_rule_count: 222, + vendored_at: "2026-08-09", + license: "MIT — Copyright (c) 2019 Zachary Rice", + vendored_by: "story 055.W4.1 (D20(1)), aiox-plugins", +}); + +// Each rule: +// id — upstream rule id, unchanged (so a finding is greppable against gitleaks' docs) +// class — the human-facing CLASS name this repo uses in docs + tests (AC2/AC3). Several +// upstream rules can map to one class (e.g. the two Cloudflare rules), which is why +// this field exists separately from `id`. +// description — upstream description, unchanged +// pattern — upstream regex, `(?i)` lifted into `flags` +// flags — "" or "i" +// entropy — upstream Shannon-entropy floor on the captured secret (undefined = no floor) +// keywords — upstream case-insensitive prefilter; a rule whose keyword is absent from the text +// is skipped entirely (this is gitleaks' own optimisation, kept for fidelity) +// allowMatch — upstream `[[rules.allowlists]] regexes` with regexTarget = match +// allowPaths — upstream `[[rules.allowlists]] paths` +export const SECRET_RULES = Object.freeze([ + { + id: "private-key", + class: "private-key", + description: + "Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.", + pattern: "-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\\s\\S-]{64,}?KEY(?: BLOCK)?-----", + flags: "i", + keywords: ["-----begin"], + }, + { + id: "aws-access-token", + class: "aws-access-key", + description: + "Identified a pattern that may indicate AWS credentials, risking unauthorized cloud resource access and data breaches on AWS platforms.", + pattern: "\\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\\b", + flags: "", + entropy: 3, + keywords: ["a3t", "akia", "asia", "abia", "acca"], + allowMatch: [".+EXAMPLE$"], + }, + { + id: "github-pat", + class: "github-token", + description: + "Uncovered a GitHub Personal Access Token, potentially leading to unauthorized repository access and sensitive content exposure.", + pattern: "ghp_[0-9a-zA-Z]{36}", + flags: "", + entropy: 3, + keywords: ["ghp_"], + allowPaths: ["(?:^|/)@octokit/auth-token/README\\.md$"], + }, + { + id: "github-fine-grained-pat", + class: "github-fine-grained-token", + description: + "Found a GitHub Fine-Grained Personal Access Token, risking unauthorized repository access and code manipulation.", + pattern: "github_pat_\\w{82}", + flags: "", + entropy: 3, + keywords: ["github_pat_"], + }, + { + id: "cloudflare-api-key", + class: "cloudflare-api-token", + description: + "Detected a Cloudflare API Key, potentially compromising cloud application deployments and operational security.", + pattern: + "[\\w.-]{0,50}?(?:cloudflare)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "i", + entropy: 2, + keywords: ["cloudflare"], + }, + { + id: "cloudflare-global-api-key", + class: "cloudflare-global-api-key", + description: + "Detected a Cloudflare Global API Key, potentially compromising cloud application deployments and operational security.", + pattern: + "[\\w.-]{0,50}?(?:cloudflare)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{37})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "i", + entropy: 2, + keywords: ["cloudflare"], + }, + { + id: "slack-bot-token", + class: "slack-token", + description: + "Identified a Slack Bot token, which may compromise bot integrations and communication channel security.", + pattern: "xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*", + flags: "", + entropy: 3, + keywords: ["xoxb"], + }, + { + id: "slack-user-token", + class: "slack-user-token", + description: + "Found a Slack User token, posing a risk of unauthorized user impersonation and data access within Slack workspaces.", + pattern: "xox[pe](?:-[0-9]{10,13}){3}-[a-zA-Z0-9-]{28,34}", + flags: "", + entropy: 2, + keywords: ["xoxp-", "xoxe-"], + }, + { + id: "stripe-access-token", + class: "stripe-key", + description: + "Found a Stripe Access Token, posing a risk to payment processing services and sensitive financial data.", + pattern: "\\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "", + entropy: 2, + keywords: ["sk_test", "sk_live", "sk_prod", "rk_test", "rk_live", "rk_prod"], + }, + { + id: "openai-api-key", + class: "openai-api-key", + description: + "Found an OpenAI API Key, posing a risk of unauthorized access to AI services and data manipulation.", + pattern: + "\\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "", + entropy: 3, + keywords: ["t3blbkfj"], + }, + { + id: "anthropic-api-key", + class: "anthropic-api-key", + description: + "Identified an Anthropic API Key, which may compromise AI assistant integrations and expose sensitive data to unauthorized access.", + pattern: "\\b(sk-ant-api03-[a-zA-Z0-9_\\-]{93}AA)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "", + keywords: ["sk-ant-api03"], + }, + { + id: "gcp-api-key", + class: "gcp-api-key", + description: + "Uncovered a GCP API key, which could lead to unauthorized access to Google Cloud services and data breaches.", + pattern: "\\b(AIza[\\w-]{35})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "", + entropy: 4, + keywords: ["aiza"], + // The upstream allowlist is a literal list of 16 well-known fake keys that circulate in docs and + // test suites. Carried verbatim (as an alternation) so a package quoting Google's own sample key + // is not refused. + allowMatch: [ + "^(?:AIzaSyabcdefghijklmnopqrstuvwxyz1234567|AIzaSyAnLA7NfeLquW1tJFpx_eQCxoX-oo6YyIs|AIzaSyCkEhVjf3pduRDt6d1yKOMitrUEke8agEM|AIzaSyDMAScliyLx7F0NPDEJi1QmyCgHIAODrlU|AIzaSyD3asb-2pEZVqMkmL6M9N6nHZRR_znhrh0|AIzayDNSXIbFmlXbIE6mCzDLQAqITYefhixbX4A|AIzaSyAdOS2zB6NCsk1pCdZ4-P6GBdi_UUPwX7c|AIzaSyASWm6HmTMdYWpgMnjRBjxcQ9CKctWmLd4|AIzaSyANUvH9H9BsUccjsu2pCmEkOPjjaXeDQgY|AIzaSyA5_iVawFQ8ABuTZNUdcwERLJv_a_p4wtM|AIzaSyA4UrcGxgwQFTfaI3no3t7Lt1sjmdnP5sQ|AIzaSyDSb51JiIcB6OJpwwMicseKRhhrOq1cS7g|AIzaSyBF2RrAIm4a0mO64EShQfqfd2AFnzAvvuU|AIzaSyBcE-OOIbhjyR83gm4r2MFCu4MJmprNXsw|AIzaSyB8qGxt4ec15vitgn44duC5ucxaOi4FmqE|AIzaSyA8vmApnrHNFE0bApF4hoZ11srVL_n0nvY)$", + ], + }, + { + id: "npm-access-token", + class: "npm-token", + description: + "Uncovered an npm access token, potentially compromising package management and code repository access.", + pattern: "\\b(npm_[a-z0-9]{36})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "i", + entropy: 2, + keywords: ["npm_"], + }, + { + id: "jwt", + class: "jwt", + description: + "Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.", + pattern: + "\\b(ey[a-zA-Z0-9]{17,}\\.ey[a-zA-Z0-9\\/\\\\_-]{17,}\\.(?:[a-zA-Z0-9\\/\\\\_-]{10,}={0,2})?)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", + flags: "", + entropy: 3, + keywords: ["ey"], + }, +]); + +// The 14 rules above collapse to this many distinct CLASSES. Exported (rather than recomputed at +// each call site) because AC2's contract is per-CLASS: every entry here must have a negative fixture +// that is REFUSED through the real CLI, and `test/publish-cli.test.mjs` iterates this exact list — +// so adding a rule without adding its fixture makes the test suite fail on the missing fixture +// rather than silently shipping an unproven class. +export const SECRET_CLASSES = Object.freeze([...new Set(SECRET_RULES.map((r) => r.class))].sort()); + +// ── DELIBERATELY NOT VENDORED, and why ─────────────────────────────────────────────────────────── +// +// This list is part of the deliverable, not an apology. AC3's whole point is that the boundary of a +// control is as load-bearing as its coverage, and an omission nobody wrote down becomes, six weeks +// later, a coverage claim nobody can check. +export const DELIBERATELY_NOT_VENDORED = Object.freeze([ + { + id: "generic-api-key", + reason: + "gitleaks' catch-all for unknown providers (any `key`/`token`/`secret`-ish assignment whose value clears entropy 3.5). Its usability depends entirely on a large allowlist that uses Go RE2's `(?-i:...)` case-sensitive island, which has no JavaScript equivalent — porting the rule WITHOUT its allowlist inverts the cost of a mistake from 'a secret slips through' to 'a legitimate publish is refused because a skill wrote `api_version: 2026-08-09`'. A blocking gate that cries wolf is a gate that gets bypassed. Consequence, stated plainly: a credential from a provider not in the vendored list, or one with no recognisable prefix, is NOT detected here.", + }, + { + id: "(the other ~208 upstream rules)", + reason: + "The vendored subset covers the providers this catalog's threat model actually touches (cloud + git forge + package registry + payment + AI + the org's own Cloudflare/R2 infra) plus the format-recognisable generics (PEM private keys, JWTs). Vendoring all 222 would multiply the per-class negative-fixture obligation of AC2 by ~16 without adding a class this catalog is plausibly exposed to — and an unproven rule is exactly the 'gate that passes verde using a tool blind to the defect' failure this lineage already shipped once. Re-vendoring more is a small, mechanical change: add the rule here, add its fixture, the test suite enforces the pairing.", + }, +]); diff --git a/lib/secret-scanner.mjs b/lib/secret-scanner.mjs new file mode 100644 index 0000000..db2eef6 --- /dev/null +++ b/lib/secret-scanner.mjs @@ -0,0 +1,765 @@ +// lib/secret-scanner.mjs — story 055.W4.1, D20(1): the BLOCKING secret scan. +// +// WHAT THIS IS: the engine that runs the vendored gitleaks corpus (lib/secret-rules.mjs) over the +// two things a publish actually makes public — the ARTIFACT'S REAL BYTES and the MANIFEST that +// becomes the catalog entry. It is wired into publisher/publish.mjs as an unconditional refusal +// (AC1): a finding is a failure, never a warning. There is no flag, environment variable or fixture +// path that turns it off — the same posture as D24's four invariants. +// +// WHY THE ARTIFACT AND NOT "THE REPO": the base grep in .github/workflows/ci.yml scans this +// repository's own committed files. That is a different control with a different subject, and it was +// explicitly labelled "NOT the D20(1) blocking scanner" when it landed. The thing a user downloads +// and runs is the tarball in R2; the thing that ends up in the public index is the manifest. Those +// are what this scans. +// +// ── WHAT IT CANNOT SEE (AC3 — read `SCANNER_LIMITS` below, it is part of the result) ───────────── +// +// The limits travel WITH every report, exactly like `capabilities.limits` does in +// lib/capability-analyzer.mjs, and for the same measured reason (advisory-council finding C2): a +// control communicated as stronger than it is makes the user calibrate trust by the label. A scan +// result that can be displayed without its blind spots WILL eventually be displayed without them, +// so the blind spots are a field of the result object, not a paragraph in a document somewhere. + +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, mkdtempSync, rmSync, statSync, existsSync } from "node:fs"; +import { join, sep } from "node:path"; +import { tmpdir } from "node:os"; +import { gunzipSync } from "node:zlib"; +import { SECRET_RULES, SECRET_RULES_PROVENANCE, SECRET_CLASSES } from "./secret-rules.mjs"; + +// A file larger than this is not READ. Chosen so a normal skill package (markdown + small scripts) +// is always fully covered while a vendored blob cannot make the publish pipeline hang. +export const MAX_SCANNED_FILE_BYTES = 5 * 1024 * 1024; + +// ── THE DECISION SITE: an UNSCANNABLE member is a REFUSAL, not a pass (fix-cycle-1, F2) ─────────── +// +// WHAT THE QG EXECUTED. Two one-line evasions each let an artifact carrying a live-shaped AWS key +// exit 0 and publish: (A) one leading NUL byte, so the member is classified `[binary]` and skipped; +// (B) the same credential followed by >5 MiB of padding, so it is classified `[too large]` and +// skipped. Both produced "Findings: none". +// +// WHY THAT WAS NOT ALREADY OBVIOUSLY WRONG — and the QG is right about this: the blindness was +// DISCLOSED. Every run printed which members were skipped and said "a skipped file is an UNKNOWN, +// not a pass". So this was never the failure this lineage is haunted by (a gate passing verde using +// a tool blind to the defect it was meant to catch — that failure is about UNDISCLOSED blindness). +// What was actually missing was smaller and more damning: every other trade-off in this deliverable +// is written down at its decision site, and THIS one was not. It was presented as an unavoidable +// property of scanning rather than as an alternative that had been weighed. +// +// THE CHOICE, MADE AND RECORDED: **fail-closed**. `publisher/publish.mjs` and +// `scripts/scan-secrets.mjs` REFUSE when any member could not be scanned. AC1's text is "um pacote +// contendo credencial reconhecível não publica — falha, não aviso"; a member nobody could read is a +// member nobody can say that about, and "unscannable therefore not publishable" is the only reading +// under which the sentence stays true. Disclosure is not enforcement. +// +// WHAT FAIL-CLOSED COSTS — named concretely, because "it's free" would be false: +// 1. A legitimate binary asset in a package (an icon, a font, a .wasm) is REFUSED. A plugin is +// skills + scripts + a licence today, so this is not a case that exists yet — but it is the +// case most likely to appear first. +// 2. macOS packaging junk is REFUSED, and this is the likeliest real false refusal: `.DS_Store` +// and AppleDouble `._*` files are binary and `tar` on macOS sweeps them in by accident. The +// refusal is arguably CORRECT here (that junk has no business in a published artifact) but it +// will surprise a macOS publisher, so the message names the file and says what to do. +// 3. A genuinely large TEXT member (>5 MiB) is REFUSED rather than silently unexamined. +// +// WHY THE COST IS WORTH PAYING NOW: the catalog has ZERO real entries and is closed to external +// publishers, so today the false-refusal cost is literally zero — and that makes this the only +// moment when tightening the rule is free. It is the same reasoning that made D24 worth ratifying +// before a catalog existed: these things only cost nothing before they exist. Waiting until a real +// publisher is broken by the change is strictly worse than paying for it while nobody is watching. +// +// WHY THERE IS NO `--allow-unscannable` OVERRIDE, though the obvious design has one: an override is +// precisely the disable path AC1 forbids and the bypass sweep hunts for. A flag that lets a publish +// through with unread bytes is a flag that will be passed by default in somebody's CI within a +// quarter. When a legitimate binary-asset case actually appears, the answer is a DESIGNED rule +// (e.g. an explicit, digest-pinned asset allowlist that says which member is exempt and why), +// decided with a real case in hand — not a bypass built speculatively for a case that does not yet +// exist. +// +// Skipped members are still counted and listed in every report, exactly as before. What changed is +// that the listing is now a REFUSAL rather than a note the operator was free to ignore. +export const UNSCANNABLE_IS_BLOCKING = true; + +// Every member the scan could not read, as one list — the thing the caller refuses on. +export function unscannableMembers(report) { + if (!report) return []; + return [ + ...report.skipped_binary.map((s) => ({ ...s, why: "binary (NUL byte in the first 8000 bytes — not readable as text)" })), + ...report.skipped_too_large.map((s) => ({ ...s, why: `larger than the ${MAX_SCANNED_FILE_BYTES}-byte scan cap` })), + // fix-cycle-2 (F10/F11): members the archive's STRUCTURE puts out of reach. Same doctrine, + // applied one step earlier — at enumeration rather than at read. + ...(report.skipped_structural ?? []), + ]; +} + +// ── fix-cycle-2 (F10/F11): enumerate the ARCHIVE, not the extracted filesystem ──────────────────── +// +// WHAT THE QG EXECUTED. A single tar stream carrying the SAME path twice — first member holding a +// shape-valid AWS key, second member clean — extracts to one clean file on disk. The old inventory +// walked that extracted tree, saw one file, found nothing, and exited 0. The credential shipped and +// was fully recoverable from the published bytes (`tar -xOzf artifact.tar.gz ./config/app.env`). +// Reproduced here before writing the fix, on this machine, with the same result. +// +// WHY THIS ONE OUTRANKED THE GAP IT REPLACED, and the reason is not severity: the fail-closed +// doctrine from fix-cycle-1 was survivable precisely because the scanner SAID OUT LOUD what it had +// not read. A shadowed member appeared in nothing — not `files_total`, not `skipped_*`, not +// `unscannable`. Undisclosed blindness is the disqualifying kind, and this story's whole thesis is +// that a control communicated as stronger than it is is worse than a weaker control described +// honestly. +// +// THE ROOT CAUSE was one architectural line, not a mistake in the previous cycle: the inventory came +// from the EXTRACTED FILESYSTEM, so anything that does not survive extraction as a distinct regular +// file was invisible BY CONSTRUCTION. Duplicates collapse. Symlinks and other non-regular members are +// dropped before they can be counted (F11 — the same cause wearing a different symptom, which is why +// one change closes both). +// +// THE FIX: the member table is the source of truth for WHAT IS IN the archive; the extracted tree is +// used only to read the BYTES of members the table says are ordinary files. Anything the table lists +// that cannot be mapped to exactly one readable regular file is UNSCANNABLE — which routes it into +// the fail-closed path built and tested in cycle 1 rather than inventing a second mechanism. +// +// ── fix-cycle-4 (F17): classify from the ARCHIVE'S BYTES, not from `tar`'s rendered listing ─────── +// +// WHAT THE QG EXECUTED. The cycle-3 archive with ONE extra header field: a crafted `uname` of +// `0 Aug 1`. uname is a 32-byte FREE-TEXT slot the archive's author fills in, and `tar -tvzf` prints +// it as a column between the link count and the size: +// +// drw-r--r-- 0 0 Aug 1 g 39 Jul 27 2021 ./config/payload/ +// ^^^^^^^ "digits followed by a date" — the exact shape the size regex anchored on, +// appearing BEFORE the real size of 39 +// +// `parseMemberSize` returned 0, `isRealDirectory` became true, the member was exempted from +// classification, and the scan reported "2/2 file(s) scanned", "Findings: none", exit 0 — while +// `tar -xOzf artifact.tar.gz ./config/payload/` printed the AWS key. Reproduced here before writing +// this, on this machine, with the same tar. +// +// WHY THIS IS A DIFFERENT KIND OF FIX FROM THE PREVIOUS THREE. Cycles 1-3 each closed the shape that +// had been demonstrated. Cycle 3 in particular was right in FORM — it inverted the classifier so that +// exemption requires positive evidence — but it left the EVIDENCE itself attacker-shapeable, because +// both the type and the size were read from `tar -tvzf`: a HUMAN-READABLE RENDERING whose column +// layout is a function of attacker-supplied header fields. An allowlist whose evidence is forgeable +// is an allowlist in shape and a denylist in effect. The previous cycle's own comment names half of +// this ("Rendered is load-bearing") and then uses a second value from the same rendering as the +// cross-check. +// +// THE FIX IS TERMINAL FOR THE CLASS, not another enumeration step: read the two facts from the ustar +// header's FIXED BINARY OFFSETS — typeflag at 156, size as octal at 124. No header field an attacker +// can write moves another field's offset, so there is no "one more forged column" variant of this. +// It stops asking what a member LOOKS LIKE and reads what the archive actually SAYS. +// +// WHAT IT COSTS, stated because "it's free" would be false: this is a minimal tar reader — the thing +// parsing the CLI was chosen to avoid. It must handle the extensions a real archive uses (ustar +// `prefix`, PAX `x`/`g` records, GNU `L`/`K` long names) or it would REFUSE legitimate packages whose +// paths exceed 100 bytes. Those are handled below. Anything it cannot walk cleanly refuses the whole +// artifact rather than guessing — the same fail-closed doctrine as every cycle before it. +// +// `tar` IS STILL CONSULTED, but demoted from source of truth to SECOND OPINION: its member count is +// compared against the header walk's, and a disagreement refuses the artifact. That is a real gain +// over cycle 3 — declared residual (i) said "nothing here detects a parser differential", and a +// count differential between two independent parses of the same bytes now does. +const TAR_LIST_MAX_BUFFER = 64 * 1024 * 1024; +const TAR_BLOCK = 512; + +// ustar header field offsets (POSIX 1003.1-1988). These are the whole point of this cycle: a field +// at a fixed offset cannot be displaced by the content of any other field. +const OFF = Object.freeze({ + name: 0, size: 124, chksum: 148, typeflag: 156, magic: 257, prefix: 345, +}); + +// An octal numeric field: ASCII digits terminated by NUL and/or spaces. Returns null when the field +// cannot be read as a number — the caller must fail closed, never assume 0. +function parseOctalField(buf, off, len) { + let s = buf.toString("ascii", off, off + len).replace(/\0/g, "").trim(); + if (s.length === 0) return null; + if (!/^[0-7]+$/.test(s)) return null; + const n = parseInt(s, 8); + return Number.isFinite(n) ? n : null; +} + +// GNU base-256 encoding for values that do not fit the octal field (large files). Signalled by the +// high bit of the first byte. Handled because refusing a >8 GiB member for the wrong reason would be +// a false refusal, not a security property. +function parseSizeField(buf) { + const first = buf[OFF.size]; + if ((first & 0x80) === 0) return parseOctalField(buf, OFF.size, 12); + let n = 0; + for (let i = OFF.size + 1; i < OFF.size + 12; i++) n = n * 256 + buf[i]; + return Number.isSafeInteger(n) ? n : null; +} + +// The header checksum, verified as both the unsigned and the signed sum (old tars differ on whether +// the bytes are signed). This is NOT an anti-forgery control — an attacker computes it as easily as +// tar does. It is a DESYNC detector: if the walk ever mistakes a data block for a header, the sum +// will not match and the whole artifact is refused instead of yielding invented members. +function headerChecksumOk(buf) { + const stored = parseOctalField(buf, OFF.chksum, 8); + if (stored === null) return false; + let unsigned = 0; + let signed = 0; + for (let i = 0; i < TAR_BLOCK; i++) { + const b = i >= OFF.chksum && i < OFF.chksum + 8 ? 0x20 : buf[i]; // the field counts as spaces + unsigned += b; + signed += b > 127 ? b - 256 : b; + } + return stored === unsigned || stored === signed; +} + +function cstr(buf, off, len) { + const end = buf.indexOf(0, off); + const stop = end === -1 || end > off + len ? off + len : end; + return buf.toString("utf8", off, stop); +} + +// A PAX extended header's payload: repeated ` =\n` records. Only `path` and `size` +// are consumed — they are the two facts this classifier depends on, and ignoring an override that +// `tar` honours would reintroduce exactly the divergence this cycle exists to remove. +function parsePaxRecords(payload) { + const out = {}; + let i = 0; + const text = payload.toString("utf8"); + while (i < text.length) { + const sp = text.indexOf(" ", i); + if (sp === -1) break; + const len = Number(text.slice(i, sp)); + if (!Number.isFinite(len) || len <= 0 || i + len > text.length) break; + const record = text.slice(sp + 1, i + len).replace(/\n$/, ""); + const eq = record.indexOf("="); + if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1); + i += len; + } + return out; +} + +// The raw ustar typeflag, mapped onto the single-letter vocabulary `classifyMembers` already speaks. +// The mapping is total: an unrecognised flag keeps its own character, so the refusal message names +// the actual byte found in the archive rather than a guess about it. +function typeFromFlag(flag) { + switch (flag) { + case "0": case "\0": case "7": return "-"; // regular (and GNU contiguous, read as regular) + case "5": return "d"; + case "1": return "h"; + case "2": return "l"; + case "3": return "c"; + case "4": return "b"; + case "6": return "p"; + default: return flag; + } +} + +function normalizeMemberPath(p) { + return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); +} + +// An entry whose path escapes the package root cannot be mapped to a file inside the extracted tree, +// so its bytes are not something this scan can honestly claim to have read. +function isUnsafeMemberPath(p) { + return p.startsWith("/") || p === ".." || p.startsWith("../") || p.includes("/../"); +} + +// Walks the inflated tar stream block by block and returns one entry per real member, with the type +// and the size read from the header's own bytes. `aligned: false` means the stream could not be +// walked cleanly (or `tar` disagrees about how many members it holds) and the artifact is refused +// whole — the `reason` travels with it so the operator is told which of the two happened. +export function tarMemberTable(tarPath) { + const raw = readFileSync(tarPath); + let buf; + try { + // gzip magic. A plugin artifact is always a .tar.gz, but accepting a plain tar costs one branch + // and avoids refusing a valid archive for a reason that has nothing to do with its contents. + buf = raw[0] === 0x1f && raw[1] === 0x8b ? gunzipSync(raw) : raw; + } catch (e) { + return { aligned: false, members: [], reason: `the archive could not be decompressed (${e.code ?? e.message}) — nothing about its contents can be certified` }; + } + + const members = []; + let next = {}; // PAX / GNU long-name overrides awaiting the member they describe + let off = 0; + + while (off + TAR_BLOCK <= buf.length) { + const head = buf.subarray(off, off + TAR_BLOCK); + if (head.every((b) => b === 0)) { + // End-of-archive. Everything after it MUST be padding: a non-zero byte here is a member some + // reader might pick up and this one would not, which is the differential this cycle refuses. + const tail = buf.subarray(off); + if (!tail.every((b) => b === 0)) { + return { aligned: false, members: [], reason: "non-zero bytes follow the end-of-archive marker — the stream carries data outside the member table, which different tar implementations treat differently" }; + } + break; + } + if (!headerChecksumOk(head)) { + return { aligned: false, members: [], reason: `a 512-byte header at offset ${off} fails its own checksum — the member table cannot be walked, so nothing about the archive can be certified` }; + } + + const size = parseSizeField(head); + if (size === null || size < 0) { + return { aligned: false, members: [], reason: `the size field of the header at offset ${off} is not a readable number — the walk cannot advance without guessing where the next member begins` }; + } + const flag = String.fromCharCode(head[OFF.typeflag]); + const dataStart = off + TAR_BLOCK; + const dataEnd = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK; + if (dataEnd > buf.length) { + return { aligned: false, members: [], reason: `the header at offset ${off} declares ${size} byte(s) of data that run past the end of the stream — the archive is truncated or its headers are inconsistent` }; + } + const payload = buf.subarray(dataStart, dataStart + size); + + // Metadata blocks: they describe the NEXT member and are not members themselves. + if (flag === "x" || flag === "g") { + const rec = parsePaxRecords(payload); + if (flag === "x") { + if (rec.path !== undefined) next.path = rec.path; + if (rec.size !== undefined) next.size = Number(rec.size); + } + off = dataEnd; + continue; + } + if (flag === "L") { next.path = payload.toString("utf8").replace(/\0+$/, ""); off = dataEnd; continue; } + if (flag === "K") { off = dataEnd; continue; } // long LINK name — describes the target, not the member + + const prefix = head.toString("ascii", OFF.magic, OFF.magic + 5) === "ustar" ? cstr(head, OFF.prefix, 155) : ""; + const base = cstr(head, OFF.name, 100); + const rawPath = next.path ?? (prefix ? `${prefix}/${base}` : base); + members.push({ + raw_path: rawPath, + path: normalizeMemberPath(rawPath), + // From the header's typeflag byte at offset 156 — the archive's own statement about what this + // member IS, not `tar`'s rendering of it. This is the whole of fix-cycle-4. + type: typeFromFlag(flag), + // From the octal size field at offset 124. A real directory carries 0; the F14/F17 member + // carries 39, and no forged uname/gname/mode can change a byte at a fixed offset. + size: next.size ?? size, + }); + next = {}; + off = dataEnd; + } + + // ── `tar` as a SECOND OPINION, not as the source of truth ─────────────────────────────────────── + // + // The header walk is now authoritative, so `tar`'s enumeration becomes a DIFFERENTIAL: any member + // one parse sees and the other does not is named individually. Declared residual (i) said "nothing + // here detects a parser differential"; this does, at member granularity rather than as a count. + // + // It immediately found one, and it is not hypothetical. `tar -czf` on macOS writes an AppleDouble + // `._name` companion member for every file carrying extended attributes — and `tar -tzf` DOES NOT + // LIST IT, because the extracting tar consumes it as metadata. MEASURED here: an xattr set with + // `xattr -w` on a LICENSE file produces a 163-byte `./._LICENSE` member absent from every listing + // this scanner has ever read, whose bytes carry the xattr's value verbatim and are recoverable from + // the published archive. Every cycle before this one enumerated from `tar`'s listing, so all four + // of them reported complete coverage of archives containing members they had never seen. That is + // the same undisclosed-blindness class as F10/F11/F14/F17, and the header walk is what makes it + // visible. Dropping such a member because tar hides it would be the disqualifying failure by name, + // so it is enumerated and refused like any other member the scan cannot certify. + const tarNames = execFileSync("tar", ["-tzf", tarPath], { encoding: "utf8", maxBuffer: TAR_LIST_MAX_BUFFER }) + .split("\n").filter((l) => l.length > 0).map(normalizeMemberPath); + + // Multiset comparison — a duplicated path is listed twice by tar and appears twice in the headers, + // and collapsing either side to a set would hide exactly the F10 construction. + const remaining = new Map(); + for (const n of tarNames) remaining.set(n, (remaining.get(n) ?? 0) + 1); + for (const m of members) { + const left = remaining.get(m.path) ?? 0; + m.listed_by_tar = left > 0; + if (left > 0) remaining.set(m.path, left - 1); + } + const tar_only = []; + for (const [name, count] of remaining) for (let i = 0; i < count; i++) tar_only.push(name); + + return { aligned: true, members, tar_only, counts: { names: tarNames.length, headers: members.length } }; +} + +// ── fix-cycle-3 (F14): the classifier is an ALLOWLIST, not a denylist with a pre-filter ────────── +// +// WHAT THE QG EXECUTED. A hand-forged ustar member with typeflag '0' (REGULAR FILE) whose NAME ends +// in '/', carrying 39 bytes containing a shape-valid AWS key. `tar -tzf` lists it; `tar -tvzf` +// renders it as type `d`, because bsdtar prints a trailing-slash name as a directory regardless of +// the header's real typeflag. The previous pre-filter excluded members that were `type === "d"` OR +// whose name ended in `/` — so BOTH halves excluded it, before any refusal logic could run. Result: +// "2/2 file(s) scanned" on a 3-member archive, "Findings: none", exit 0 — while +// `tar -xOzf artifact.tar.gz ./config/payload/` printed the credential. Reproduced here before the +// fix, on this machine, with the same tar. That last detail matters: this is NOT covered by the +// declared parser-differential residual, which is about DIFFERENT tar implementations disagreeing. +// One implementation was enough. +// +// WHY THE SHAPE OF THE BUG MATTERS MORE THAN THE INSTANCE. The old rule asked what a member LOOKS +// LIKE and exempted on appearance. Anything that merely resembles a directory inherited the +// directory exemption regardless of what it actually carried — and tar is an old, permissive format +// with more shapes than any denylist will hold. Three cycles running, a denylist closed the +// demonstrated instance and left an undemonstrated one. So the question is inverted: +// +// OLD (denylist): "is this member excluded from classification?" -> exempt on resemblance +// NEW (allowlist): "can this member be POSITIVELY identified as one of two known-safe things?" +// -> everything else, INCLUDING anything unrecognised, is unscannable and refuses +// +// The two positively-identified categories, and nothing else: +// +// DIRECTORY — rendered type `d` AND a size that is KNOWN and EXACTLY 0. A real directory carries +// no data. A member rendered as a directory with non-zero size is anomalous by construction and +// refuses. If the size cannot be parsed at all, that is not a pass either: an unverifiable claim +// to be a directory is treated as unscannable, because the entire point of this inversion is +// that exemption requires positive evidence. +// ORDINARY FILE — rendered type `-`/`0`, name NOT ending in `/`, safe path, unique among content +// members, and mappable to exactly one extracted regular file (checked in scanArtifact). +// +// Directories must stay exempt: refusing them would refuse EVERY package, which would "close" the +// finding and break the product. That carve-out is the one thing this inversion cannot tighten, so +// it is pinned by its own control test. +export function classifyMembers(table) { + if (!table.aligned) { + return { + readable: [], + structural: [{ + path: "(whole archive)", + kind: "unparseable-member-table", + why: table.reason ?? "the archive's contents cannot be enumerated reliably, so nothing about it can be certified", + }], + }; + } + + // POSITIVE identification of a real directory — the ONLY exemption from classification. + const isRealDirectory = (m) => m.type === "d" && m.size === 0; + + const content = table.members.filter((m) => !isRealDirectory(m)); + const seen = new Map(); + for (const m of content) seen.set(m.path, (seen.get(m.path) ?? 0) + 1); + + const readable = []; + const structural = []; + const reportedDuplicates = new Set(); + + // fix-cycle-4 (F17, differential half): a name `tar` enumerates that the header walk never + // produced. The two parses disagree about what is in the archive, so neither can be trusted for it. + for (const name of table.tar_only ?? []) { + structural.push({ path: name, kind: "phantom-member", why: "`tar` lists this member but no ustar header in the stream produces it — two independent parses of the same bytes disagree about what the archive contains, so nothing about this member can be certified" }); + } + + for (const m of content) { + // fix-cycle-4 (F17, differential half): a member whose header IS in the stream but which `tar` + // does not list. The macOS AppleDouble case is the one that exists in practice, so it is named: + // it ships real bytes (an xattr's value, recoverable from the archive) that no previous cycle + // ever enumerated. Reported before every other check because the operator's fix is different + // from every other refusal here — it is a rebuild, not an edit. + if (m.listed_by_tar === false) { + const appleDouble = /(^|\/)\._/.test(m.raw_path); + structural.push({ + path: m.path, + kind: "hidden-member", + why: appleDouble + ? "an AppleDouble metadata member that `tar -tzf` does not list — macOS `tar` writes one per file carrying extended attributes, and its bytes (the xattr values) ship inside the artifact while being invisible to the archive's own listing. Rebuild the package with `COPYFILE_DISABLE=1 tar -czf …` so the artifact contains only the files it declares." + : "present as a ustar header in the stream but absent from `tar`'s own enumeration — its bytes ship inside the artifact while some extractors will never materialise it, so it cannot be certified", + }); + continue; + } + if (seen.get(m.path) > 1) { + // Report a shadowed path ONCE, with its multiplicity — the point is the path is ambiguous, + // and printing it N times would bury that under repetition. + if (!reportedDuplicates.has(m.path)) { + reportedDuplicates.add(m.path); + structural.push({ + path: m.path, + kind: "duplicate", + why: `appears ${seen.get(m.path)} times in the archive — extraction keeps only the last, so an earlier member's bytes ship inside the artifact while never existing on disk to be read (this is the F10 construction: a shadowed credential)`, + }); + } + continue; + } + if (isUnsafeMemberPath(m.path)) { + structural.push({ path: m.path, kind: "unsafe-path", why: "absolute or parent-escaping member path — it cannot be mapped to a file inside the package root, so its bytes cannot be read here" }); + continue; + } + // fix-cycle-3 (F14) / fix-cycle-4 (F17) — a member that PRESENTS as a directory without being + // one, reached from either side: the header's typeflag says directory, or the name ends in `/`. + // Reported before the generic non-regular case because the operator needs the specific anomaly + // named: "it says directory but carries data" is a different problem from "it is a symlink". + if (m.type === "d" || m.raw_path.endsWith("/")) { + if (m.size === null) { + structural.push({ path: m.path, kind: "directory-with-data", why: "presents as a directory but its size could not be read from the ustar header — a claim to be a directory that cannot be verified is not a pass (F14/F17)" }); + } else if (m.size > 0) { + structural.push({ + path: m.path, + kind: "directory-with-data", + why: `presents as a directory but carries ${m.size} bytes of data — a real directory carries none, so this is a regular-file member wearing a directory's name (F14: the bytes ship and are recoverable with \`tar -xOzf\`, while nothing on disk holds them). The size is read from the ustar header at offset 124, not from tar's rendered listing (F17).`, + }); + } else { + structural.push({ path: m.path, kind: "directory-shaped-name", why: "member name ends in '/' but the ustar header's typeflag does not say directory — it cannot be mapped to exactly one extracted regular file (F14)" }); + } + continue; + } + if (m.type !== "-" && m.type !== "0") { + structural.push({ path: m.path, kind: "non-regular", why: `member type '${m.type}' is not a regular file (symlink/hardlink/FIFO/socket/device) — it carries no readable bytes of its own, so it is enumerated and refused rather than dropped before counting (F11)` }); + continue; + } + readable.push(m); + } + + return { readable, structural }; +} + +// ── entropy ────────────────────────────────────────────────────────────────────────────────────── + +// Shannon entropy in bits/character — the same measure gitleaks applies to a rule's captured secret. +// Rules carry an entropy floor precisely because shape alone over-fires: `ghp_` followed by 36 +// repetitions of one character matches the pattern and is obviously not a token. +export function shannonEntropy(str) { + if (!str) return 0; + const counts = new Map(); + for (const ch of str) counts.set(ch, (counts.get(ch) ?? 0) + 1); + let h = 0; + for (const n of counts.values()) { + const p = n / str.length; + h -= p * Math.log2(p); + } + return h; +} + +// ── redaction ──────────────────────────────────────────────────────────────────────────────────── + +// A scanner that prints what it found turns every CI log into the leak it was preventing. Findings +// carry a fingerprint, never the value: the first 4 characters (enough to recognise the provider +// prefix a human already knows) plus the length, plus a short digest-free hash-like tail marker. +export function redact(secret) { + const s = String(secret ?? ""); + if (s.length <= 4) return `${"*".repeat(s.length)} (len ${s.length})`; + return `${s.slice(0, 4)}${"*".repeat(Math.min(12, s.length - 4))} (len ${s.length})`; +} + +// ── the scan ───────────────────────────────────────────────────────────────────────────────────── + +function lineOf(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++; + return line; +} + +function compiled(rule) { + // `g` is required (we iterate all matches); the rule's own flags carry the lifted `(?i)`. + return new RegExp(rule.pattern, `g${rule.flags ?? ""}`); +} + +function pathAllowed(rule, path) { + if (!rule.allowPaths || !path) return false; + return rule.allowPaths.some((p) => new RegExp(p).test(path)); +} + +function matchAllowed(rule, matched) { + if (!rule.allowMatch) return false; + return rule.allowMatch.some((p) => new RegExp(p).test(matched)); +} + +// Scans one text blob. `path` is only used for reporting + upstream path allowlists. +export function scanText(text, path = "(inline)") { + const findings = []; + const haystack = text.toLowerCase(); + + for (const rule of SECRET_RULES) { + // gitleaks' own keyword prefilter, kept for fidelity (and speed): a rule whose keyword does not + // appear anywhere in the text cannot match, so it is skipped without running the regex. + if (rule.keywords?.length && !rule.keywords.some((k) => haystack.includes(k))) continue; + if (pathAllowed(rule, path)) continue; + + const re = compiled(rule); + let m; + while ((m = re.exec(text)) !== null) { + // The "secret" is capture group 1 when the rule has one, else the whole match — the same + // choice gitleaks makes, and it matters: the entropy floor is meant to apply to the credential, + // not to the surrounding `cloudflare_token = ` boilerplate. + const secret = m[1] ?? m[0]; + if (m[0].length === 0) { re.lastIndex++; continue; } + if (matchAllowed(rule, secret)) continue; + const entropy = shannonEntropy(secret); + if (rule.entropy !== undefined && entropy < rule.entropy) continue; + findings.push({ + rule_id: rule.id, + class: rule.class, + description: rule.description, + path, + line: lineOf(text, m.index), + redacted: redact(secret), + entropy: Number(entropy.toFixed(2)), + }); + } + } + return findings; +} + +function walk(dir, base = dir, out = []) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isSymbolicLink()) continue; // a symlink's target is outside the artifact; see SCANNER_LIMITS + if (e.isDirectory()) walk(full, base, out); + else if (e.isFile()) out.push(full.slice(base.length + 1).split(sep).join("/")); + } + return out; +} + +// A NUL byte in the head of a file is the standard, cheap "this is not text" signal (`git` uses the +// same heuristic). Binary members are skipped and counted — see SCANNER_LIMITS. +function looksBinary(buf) { + const head = buf.subarray(0, Math.min(buf.length, 8000)); + return head.includes(0); +} + +export function scanArtifact(tarPath) { + // fix-cycle-2 (F10/F11): the ARCHIVE decides what exists; the extracted tree only supplies bytes. + const table = tarMemberTable(tarPath); + const { readable, structural } = classifyMembers(table); + + const dir = mkdtempSync(join(tmpdir(), "aiox-plugins-secretscan-")); + try { + // fix-cycle-4: an archive `tar` cannot extract used to THROW out of here — an uncaught exception + // with a stack trace instead of a report. It failed closed by accident (a crashed process exits + // non-zero) rather than by design, and the operator was told nothing actionable. Found by the + // broken-header fixture below, which is the first artifact this suite ever built that tar + // refuses to unpack. It is now the same named, fail-closed refusal as every other unreadable + // member: the archive is enumerated, nothing is certified, and the reason is printed. + try { + execFileSync("tar", ["-xzf", tarPath, "-C", dir], { stdio: ["ignore", "ignore", "pipe"] }); + } catch (e) { + const detail = String(e.stderr ?? e.message ?? "").trim().split("\n")[0]; + return { + subject: "artifact", + files_total: 1, + files_scanned: 0, + bytes_scanned: 0, + skipped_binary: [], + skipped_too_large: [], + skipped_structural: [{ + path: "(whole archive)", + kind: "unextractable-archive", + why: `\`tar\` could not unpack this archive (${detail || "no detail reported"}) — no member's bytes could be read, so nothing about it can be certified`, + }], + findings: [], + rules: SECRET_RULES.length, + classes: [...SECRET_CLASSES], + provenance: SECRET_RULES_PROVENANCE, + limits: [...SCANNER_LIMITS], + }; + } + const onDisk = new Set(walk(dir)); + const findings = []; + let scanned = 0; + let bytes = 0; + const skipped_binary = []; + const skipped_too_large = []; + const skipped_structural = [...structural]; + + for (const m of readable) { + const full = join(dir, m.path); + // A member the table lists as an ordinary file that is nevertheless absent from the extracted + // tree is not a pass — it is a member we never read, and the whole point of this cycle is that + // those are enumerated instead of vanishing. + if (!onDisk.has(m.path)) { + skipped_structural.push({ path: m.path, kind: "missing-after-extraction", why: "listed in the archive's member table as a regular file but absent from the extracted tree — its bytes were never read" }); + continue; + } + const size = statSync(full).size; + if (size > MAX_SCANNED_FILE_BYTES) { skipped_too_large.push({ path: m.path, bytes: size }); continue; } + const buf = readFileSync(full); + if (looksBinary(buf)) { skipped_binary.push({ path: m.path, bytes: size }); continue; } + scanned++; + bytes += size; + findings.push(...scanText(buf.toString("utf8"), m.path)); + } + + return { + subject: "artifact", + // Counts the archive's CONTENT MEMBERS (directories excluded — they carry no bytes), so + // "N/M file(s) scanned" now means what a reader assumes it means. Before this cycle it counted + // what survived extraction, which silently understated a 3-member archive as 2 (F11). + files_total: readable.length + structural.length, + files_scanned: scanned, + bytes_scanned: bytes, + skipped_binary, + skipped_too_large, + skipped_structural, + findings, + rules: SECRET_RULES.length, + classes: [...SECRET_CLASSES], + provenance: SECRET_RULES_PROVENANCE, + limits: [...SCANNER_LIMITS], + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// The manifest is scanned as the raw JSON TEXT it is on disk, not as a parsed object: a secret can +// hide in a field this repo does not know about, and re-serialising would drop exactly those. +export function scanManifestFile(manifestPath) { + const text = readFileSync(manifestPath, "utf8"); + return { + subject: "manifest", + files_total: 1, + files_scanned: 1, + bytes_scanned: Buffer.byteLength(text), + skipped_binary: [], + skipped_too_large: [], + skipped_structural: [], + findings: scanText(text, manifestPath.split(/[\\/]/).pop()), + rules: SECRET_RULES.length, + classes: [...SECRET_CLASSES], + provenance: SECRET_RULES_PROVENANCE, + limits: [...SCANNER_LIMITS], + }; +} + +// ── AC3 — what this scanner CANNOT see ─────────────────────────────────────────────────────────── +// +// (a) and (b) are the two the story names explicitly; the rest are the ones measured while building +// it. They are frozen data, attached to every report, and rendered by `renderScanReport` under a +// heading that cannot be mistaken for an endorsement. +export const SCANNER_LIMITS = Object.freeze([ + "THE POINTER, NOT THE TARGET (limit (a), the big one). An MCP server in a plugin is a RUNTIME-RESOLVED POINTER — the manifest supplies `{command, args}` (product repo, crates/aiox-core/src/mcp.rs:68), typically `npx `. This scan reads the PUBLISHED MANIFEST and the PUBLISHED ARTIFACT. It has never opened, downloaded or executed what that pointer resolves to, and what `npx` fetches tomorrow is not what was published today. A clean scan says nothing whatsoever about the code an MCP pointer will pull at runtime.", + "AN OBFUSCATED OR ENCODED SECRET ESCAPES (limit (b)). Every rule here is a regex over literal text. A credential that is base64'd, split across concatenated strings, XOR'd, stored reversed, or assembled at runtime does not match any pattern and is not detected. This is a shape detector, not a semantic one.", + "COVERAGE IS A FIXED LIST OF PROVIDERS, NOT 'SECRETS' IN GENERAL. The vendored corpus is a curated SUBSET of gitleaks' 222 rules (see lib/secret-rules.mjs `DELIBERATELY_NOT_VENDORED`). gitleaks' catch-all `generic-api-key` rule is deliberately NOT vendored, so a credential from an unlisted provider, or one with no recognisable prefix, is NOT detected.", + "THE CORPUS DOES NOT UPDATE ITSELF. It is a snapshot (see `provenance`), vendored on a date, from a named upstream ref. New provider formats added upstream do not reach this scanner until someone re-vendors them. The snapshot is recorded so the staleness is measurable; it is not automatic.", + "BINARY AND OVERSIZED MEMBERS CANNOT BE SCANNED — AND THEREFORE BLOCK THE PUBLISH (fail-closed, fix-cycle-1/F2). Files containing a NUL byte in their head, and files above the size cap, are not readable by this scanner. They are counted, listed, and REFUSED: a member nobody could read is a member nobody can certify, so 'unscannable' is treated as 'not publishable' rather than as a pass. The residual limit is therefore not a publishing hole but a USABILITY one, and it is real: a package with a legitimate binary asset (an icon, a font, a .wasm) or with macOS packaging junk (`.DS_Store`, AppleDouble `._*`) is refused until that member is removed or shipped in a scannable form. There is deliberately no override flag — see the decision site in lib/secret-scanner.mjs.", + "SYMLINKS ARE NOT FOLLOWED — AND SINCE fix-cycle-2 THEY ARE NOT SILENTLY DROPPED EITHER. A symlink points outside the artifact, so scanning its target would report on the publishing machine's filesystem rather than on what ships. It is now enumerated from the archive's member table and REFUSED as unscannable, together with every other non-regular member (hardlink, FIFO, socket, device) and every DUPLICATE member path. Before fix-cycle-2 a symlink was dropped before it could be counted, so a 3-member archive reported '2/2 file(s) scanned'.", + "WHAT THE MEMBER TABLE ITSELF CANNOT SEE (the residual after fix-cycle-4, stated because an undeclared blind spot is the disqualifying kind). Since fix-cycle-4 the inventory is a direct walk of the archive's own 512-byte ustar headers — the typeflag at offset 156 and the size at offset 124 — so classification no longer depends on `tar`'s rendered listing, which is a human-readable projection whose columns an attacker can shape (F17: one forged `uname` re-exempted a credential-carrying member). What remains: (i) a parser differential — a member only ONE of the two parses can see — is now DETECTED and refused by name in both directions (`hidden-member` / `phantom-member`), which is how macOS AppleDouble members, whose bytes carry extended-attribute values and which `tar -tzf` does not list at all, are caught; but the comparison is between exactly TWO parsers, this walk and the local `tar`, so a third implementation that disagrees with BOTH is still not covered. (ii) The table describes STRUCTURE, not content: it cannot tell that an ordinary-looking member is itself a nested archive whose contents are never opened. (iii) Anything the walk cannot resolve cleanly — a header failing its own checksum, an unreadable size field, data past the end-of-archive marker, or an archive `tar` cannot unpack — refuses the ENTIRE artifact rather than guessing. Fail-closed, but it means such an archive cannot be published at all.", + "A CLEAN SCAN IS NOT A SECURITY VERDICT. It means 'no known credential SHAPE was found in these bytes'. It is not a statement that the package is safe, that it does no harm, or that AIOX endorses it — the catalog signs the INDEX to attest provenance, never the artifact to attest endorsement (D20(3)).", +]); + +// ── rendering ──────────────────────────────────────────────────────────────────────────────────── + +export function renderScanReport(report) { + const out = []; + out.push( + `Secret scan (${report.subject}) — ${report.files_scanned}/${report.files_total} file(s) scanned, ${report.bytes_scanned} byte(s), ${report.rules} rule(s) across ${report.classes.length} class(es)`, + ); + out.push( + `Corpus: ${report.provenance.source} @ ${report.provenance.ref} (${report.provenance.file}), vendored ${report.provenance.vendored_at}, upstream latest ${report.provenance.upstream_latest_release_when_vendored}`, + ); + const structural = report.skipped_structural ?? []; + if (report.skipped_binary.length || report.skipped_too_large.length || structural.length) { + out.push( + `NOT scanned: ${report.skipped_binary.length} binary, ${report.skipped_too_large.length} oversized, ${structural.length} structural — a skipped file is an UNKNOWN, not a pass, and an UNKNOWN is BLOCKING (fix-cycle-1, F2; fix-cycle-2, F10/F11):`, + ); + for (const s of report.skipped_binary) out.push(` - [binary] ${s.path} (${s.bytes} bytes)`); + for (const s of report.skipped_too_large) out.push(` - [too large] ${s.path} (${s.bytes} bytes)`); + for (const s of structural) out.push(` - [${s.kind}] ${s.path} — ${s.why}`); + } + if (report.findings.length === 0) { + out.push("Findings: none"); + } else { + out.push(`Findings: ${report.findings.length} — BLOCKING (D20(1)/AC1: this is a failure, not a warning)`); + for (const f of report.findings) { + out.push(` ! [${f.class}] ${f.path}:${f.line} — rule ${f.rule_id}, entropy ${f.entropy}, value ${f.redacted}`); + out.push(` ${f.description}`); + } + } + out.push(""); + out.push("WHAT THIS SCAN CANNOT SEE:"); + for (const l of report.limits) out.push(` - ${l}`); + return out.join("\n"); +} + +// The single call publisher/publish.mjs makes. Returns the two reports plus the merged finding list; +// the CALLER refuses on a non-empty list. Deliberately NOT `process.exit`-ing from in here — a +// library that kills the process cannot be tested, and an untestable gate is an unproven one. +export function scanPublishInputs({ manifestPath, artifactPath }) { + const manifest = scanManifestFile(manifestPath); + const artifact = existsSync(artifactPath) ? scanArtifact(artifactPath) : null; + const findings = [...manifest.findings, ...(artifact?.findings ?? [])]; + // fix-cycle-1 (F2): the caller refuses on EITHER list. A finding means "we found a credential"; + // an unscannable member means "we could not look" — and under fail-closed both stop the publish. + const unscannable = [...unscannableMembers(manifest), ...unscannableMembers(artifact)]; + return { manifest, artifact, findings, unscannable }; +} diff --git a/publisher/README.md b/publisher/README.md index d69f6c5..a564f3c 100644 --- a/publisher/README.md +++ b/publisher/README.md @@ -110,10 +110,32 @@ gate) so publish-time and CI-time checks can never drift apart: - **Tier vocabulary from the manifest (`055.W3.3`, check d, `AC8`, D21 publish-time half):** refuses a publish whose `--emit-tiers` includes a tier the manifest itself doesn't declare, naming both the invalid tier and the valid vocabulary. +- **Mandatory `allowed-tools` (`055.W4.2`, D17/AC1):** every publishable skill must declare + `allowed-tools`; a skill with none — or with an empty value, a wildcard, or the silently-ignored + `allowed_tools`/`allowedTools` spelling — is refused. A manifest that tries to **self-declare** + `capabilities`/`permissions`/`grants`/`sandbox`/`trust_level` is likewise refused rather than + ignored (capabilities are DERIVED on the AIOX side — `../lib/capability-analyzer.mjs`, + `../docs/CAPABILITIES.md`). +- **Secret scanning (`055.W4.1`, D20(1)):** refuses a publish when a recognisable credential is found + in the **manifest** (which becomes a public catalog entry) or in the **artifact's real bytes**, + using a vendored subset of gitleaks' rule corpus (`../lib/secret-rules.mjs` — 14 rules / 14 classes, + each with a negative fixture through this very CLI). **Also refuses when a member could not be + scanned at all** — binary, over the size cap, a **duplicate/shadowed member path**, a non-regular + member (symlink/hardlink/FIFO/socket/device), or a path escaping the package root — because + unscannable is treated as not publishable (fail-closed; see `../docs/SECRET-SCANNING.md` §5.1 for + the decision, its named cost, and why there is deliberately no override flag, and §5.2 for why the + inventory comes from the archive's **member table** rather than from the extracted tree). What the + scan can and cannot see is printed on **every** run, including a successful one. All of the above are BLOCKING, unconditionally — no flag/env var/branch disables any of them (AC4/AC6; see the story's handoff for the literal bypass-grep command + output). +> **Keeping this list true is part of the job.** This enumeration reads as complete, so a gate that +> ships without a line here is a gate readers will not know exists — the `055.W4.1` QG caught exactly +> that (finding `F3`: two shipped blocking gates missing from this list, while the paragraph below +> still announced one of them as unbuilt). If you add a blocking check to `publish.mjs`, add it here +> in the same commit. + All checks are covered by automated tests (`../test/`, `node --test test/*.test.mjs`, Node's built-in `node:test` — zero new dependency), wired into `.github/workflows/ci.yml` so they run on every push. @@ -125,5 +147,14 @@ fixed): `plugin_id` need only appear *somewhere* in the path, not at the canonic (`F-BINDING-POSITION-AGNOSTIC`); and the freely-typed `name` field can carry Unicode look-alike characters (`F-HOMOGLYPH-NAME`, belongs to the still-open `O4` curation question). Both are pinned as explicit regression tests in `../test/entry-schema.test.mjs` so the current, accepted behavior is a -deliberate choice, not silent drift. D20(1)/(2)/(4) — blocking secret scanning, capability analysis, -version pinning — remain `055.W4.1`/`055.W4.2`, not built here. +deliberate choice, not silent drift. + +D20(1) (blocking secret scanning) and D20(4) (capability analysis) **are now built** and gate this +very script — see the two entries added to the blocking list above (`055.W4.1` and `055.W4.2` +respectively). D20(2) (version pin + separate plugin channel) also landed in `055.W4.1`, but +deliberately **not** inside this script: a pin is resolved by a *consumer* against an index +(`../lib/pin.mjs`, `../scripts/resolve-pin.mjs`), not asserted by the publisher, so there is nothing +for `publish.mjs` to check. What remains genuinely unbuilt here is **D20(3)** — AIOX signing the +index — which is `055.W4.3`, and **D20(5)** — index freshness (`expires` + a monotonic version), +which is `055.W5.1` and is also what would give back the ability to repair an already-installed +artifact. diff --git a/publisher/publish.mjs b/publisher/publish.mjs index b80b094..8a9db9d 100644 --- a/publisher/publish.mjs +++ b/publisher/publish.mjs @@ -22,7 +22,10 @@ // FULL D24 invariant suite (story 055.W3.3): id-immutability (digest lineage against the // persistent ledger), burned-name rejection, license-in-package-root, and the publish-time half of // D21 (tier vocabulary from the plugin's own manifest). All FOUR are BLOCKING — no flag/env var -// disables any of them (AC4/AC6, VC-2/VC-3). +// disables any of them (AC4/AC6, VC-2/VC-3). Story 055.W4.2 added `allowed-tools` (BLOCKING) + +// DERIVED capabilities (warn-and-display). Story 055.W4.1 (D20(1)) adds SECRET SCANNING over the +// manifest and the artifact's real bytes — also BLOCKING, also undisableable; what it can and +// cannot see is printed on every run and written up in docs/SECRET-SCANNING.md. // // fix-cycle-1 (055.W3.1 QG @architect, F-AC6-ARTIFACT-BINDING): validation now lives in // lib/entry-schema.mjs, shared with scripts/validate-index.mjs, so publish-time and CI-time checks @@ -76,6 +79,7 @@ import { renderCapabilityReport, capabilityFindingsAreBlocking, } from "../lib/capability-analyzer.mjs"; +import { scanPublishInputs, renderScanReport } from "../lib/secret-scanner.mjs"; function usageAndExit(msg) { if (msg) console.error(`error: ${msg}\n`); @@ -130,6 +134,45 @@ function main() { } const digestValue = digestFromArtifact; + // ── story 055.W4.1 (D20(1)) — BLOCKING secret scan ───────────────────────────────────────────── + // + // Runs FIRST, before any other analysis, on the two things this command makes public: the + // MANIFEST (which becomes a public catalog entry) and the ARTIFACT'S REAL BYTES (which a client + // downloads and runs). A finding is a REFUSAL — AC1: failure, not warning. There is no flag, no + // environment variable and no fixture path that disables it, matching the posture of D24's four + // invariants. + // + // The report is printed on EVERY publish, clean or not, because its `WHAT THIS SCAN CANNOT SEE` + // section is the deliverable of AC3: an operator who only ever sees "OK" learns to read this gate + // as a safety verdict, which it explicitly is not. + const secretScan = scanPublishInputs({ manifestPath: args.manifest, artifactPath: args.artifact }); + console.error(renderScanReport(secretScan.manifest)); + if (secretScan.artifact) console.error(renderScanReport(secretScan.artifact)); + if (secretScan.findings.length) { + console.error(`REFUSED — secret scanning found ${secretScan.findings.length} credential(s) (D20(1)/AC1 — BLOCKING):`); + for (const f of secretScan.findings) { + console.error(` - [${f.class}] ${f.path}:${f.line} — rule ${f.rule_id}, value ${f.redacted}`); + } + console.error( + "Remove the credential from the package and ROTATE it: it existed in bytes that were prepared for publication, so treat it as exposed regardless of whether the publish went through.", + ); + process.exit(1); + } + + // fix-cycle-1 (F2) — FAIL-CLOSED on anything the scan could not read. Previously these members + // were listed and the publish proceeded, which meant one leading NUL byte (or 5 MiB of padding) + // carried a real credential straight through a gate whose whole promise is that it does not. + // The full reasoning, the named cost, and why there is deliberately no override flag live at the + // decision site in lib/secret-scanner.mjs. + if (secretScan.unscannable.length) { + console.error(`REFUSED — ${secretScan.unscannable.length} member(s) could NOT be scanned (D20(1)/AC1 — fail-closed):`); + for (const u of secretScan.unscannable) console.error(` - ${u.path} (${u.bytes} bytes) — ${u.why}`); + console.error( + "A member nobody could read is a member nobody can certify, so it is treated as not publishable. Remove it, or ship it in a scannable form. If this is macOS packaging junk (.DS_Store, AppleDouble ._*), exclude it from the tarball — it has no business in a published artifact. There is no override flag by design: a flag that lets unread bytes through is the disable path this gate exists to not have.", + ); + process.exit(1); + } + const emitTiers = args["emit-tiers"] ? args["emit-tiers"].split(",").map((t) => t.trim()).filter(Boolean) : manifest.tiers; @@ -202,6 +245,38 @@ function main() { process.exit(1); } + // ── fix-cycle-1 (F8) — the artifact must not have changed while we were examining it ─────────── + // + // The artifact is opened several times in this run by design (hashed here, opened by `tar` for the + // license check, again for the capability analysis, again for the secret scan) because each check + // needs a different view of it. That leaves a window in which the RECORDED digest could describe + // different bytes than the ones the checks actually read. + // + // The window is not CLOSED (that would mean routing every consumer through one snapshot copy, and + // those consumers embed the caller's path in their error messages — an operator debugging a + // rejected publish would be shown a temp path instead of their own file). It is DETECTED: re-hash + // immediately before anything is written, and refuse if it moved. Nothing is committed to the + // index or the ledger on a changed artifact. + // + // Residual, named rather than implied: an attacker who can write to the publish machine mid-run + // could also restore the original bytes before this check. That attacker already owns the artifact + // outright, so this defends against the accidental/racy case — a rebuild landing mid-publish — + // which is the one that actually happens. + // + // TEST BOUNDARY, stated because this story's standard is executed-not-asserted and this one check + // is not: there is no fixture for this refusal. Triggering it requires mutating the artifact + // BETWEEN two reads inside a single CLI invocation, which cannot be done deterministically from a + // subprocess test without instrumenting the CLI — and a timing-dependent test would be a flaky + // test, which is worse than an absent one. What the suite does prove is that adding this check + // refuses nothing it should not (every publish fixture still passes). + const digestAfterChecks = sha256File(args.artifact); + if (digestAfterChecks !== digestValue) { + console.error( + `REFUSED — the artifact changed while it was being verified: hashed ${digestValue} at the start, ${digestAfterChecks} now. Every check above examined bytes that are no longer the bytes on disk, so none of their results can be trusted. Re-run the publish against a stable artifact.`, + ); + process.exit(1); + } + if (!existsSync(args.target)) usageAndExit(`--target ${args.target} does not exist`); const index = JSON.parse(readFileSync(args.target, "utf8")); index.entries ??= []; diff --git a/scripts/check-channel-separation.mjs b/scripts/check-channel-separation.mjs new file mode 100644 index 0000000..ade1f29 --- /dev/null +++ b/scripts/check-channel-separation.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +// scripts/check-channel-separation.mjs — story 055.W4.1, AC5. +// +// D19 fixes that a plugin's update cycle is INDEPENDENT of the cockpit binary's: the plugin is +// identified by version+tier+digest in its own marker, separate from `.aiox-core-build`, and the +// per-role binary channel of `ADR-COCKPIT-UPDATE-CHANNELS` (epic 017, Done) is REUSED as a concept, +// never reimplemented here. +// +// "Independent" is easy to write in a doc and easy to lose in a later edit. This guard makes it +// mechanical: no executable file in this repository may reference any binary-channel identifier, so +// a future change that starts reading the binary's state fails CI instead of quietly coupling the +// two channels. +// +// THE ONE EXEMPTION, and why it is safe: `lib/pin.mjs` names the identifiers in a frozen +// `binary_channel_identifiers` list — that list IS the declaration of what must not be read, and the +// single source this guard itself reads. The exemption is not "that file is trusted": occurrences in +// it are checked to fall inside that array literal (or inside a comment), and an occurrence anywhere +// else in the file is refused exactly like anywhere else in the repo. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, dirname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CHANNEL } from "../lib/pin.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); + +// The EXECUTABLE surface — the code that actually runs at publish time and in CI. Documentation is +// deliberately out of scope: docs/PIN-AND-CHANNEL.md must be free to explain the separation, which +// requires naming both sides of it. +const SCAN_DIRS = ["lib", "publisher", "scripts", "schema", "index", "ledger"]; +const DECLARATION_FILE = "lib/pin.mjs"; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full, out); + else out.push(full); + } + return out; +} + +// The region of lib/pin.mjs where naming an identifier is the point. +function declarationRegion(text) { + const start = text.indexOf("binary_channel_identifiers"); + if (start < 0) return null; + const end = text.indexOf("]),", start); + if (end < 0) return null; + return [start, end + 3]; +} + +// fix-cycle-1 (F4). The previous exemption was LINE-PREFIX textual — "does this line start with +// `//`, `*` or `/*`?" — and the QG defeated it with one character: a real coupling written as +// `/* probe */ const s = readFileSync(".aiox-core-build", …)` passed the guard, while the identical +// read on an ordinary line was caught. A guard with a one-character bypass is precisely the +// anti-pattern this story spends its whole argument on. +// +// The exemption is now applied to COMMENT CONTENT rather than to whole lines: comments are blanked +// out (preserving offsets, so reported line numbers stay true) and the guard searches what is LEFT, +// which is code. A doc-comment that names an identifier in order to declare the separation is still +// exempt — that is legitimate and there are several — but code hiding behind a comment opener on the +// same line is not, because after blanking the comment the code is still there. +// +// Quote tracking is included because a naive stripper would treat the `//` in a `"https://…"` string +// literal as a comment opener and blank the REST OF THE LINE — which fails in the dangerous +// direction (a real coupling after a URL would vanish). +// +// fix-cycle-2 (F12) — A REGRESSION THIS FUNCTION ITSELF INTRODUCED, and the honest framing matters: +// the round-1 version of this comment claimed regex confusion "fails toward over-reporting … never +// toward missing a coupling". **That claim was false**, and the QG executed the counterexample: +// `const re = /[/*]/;` followed on the next line by a real `readFileSync(".aiox-core-build")` passed +// the guard with exit 0. The `/*` inside the character class opened block-comment state and erased +// the coupling. Under the OLD line-prefix logic that same construction WAS caught. On a story whose +// subject is inaccurate claims about a control's strength, an inaccurate claim about this control's +// strength is the specific error under review — so it is FIXED here, and what remains is described +// as it actually behaves rather than as I would like it to behave. +// +// TWO INDEPENDENT MEASURES, because one heuristic guarding a guard is not enough: +// +// 1. REGEX LITERALS ARE RECOGNISED. A `/` in regex-start position (the previous meaningful +// character is an operator, opener, or a keyword like `return` — never an identifier, `)` or +// `]`, which mean division) consumes to its unescaped closing `/`, honouring `[...]` classes. +// The `//` and `/*` checks run FIRST, so an ordinary comment after `;` is still a comment and +// not mistaken for a regex. +// 2. AN UNTERMINATED BLOCK COMMENT IS TREATED AS A PARSE FAILURE. If block state is entered and +// never closed before EOF, this function returns the RAW text, so the guard searches everything +// including comments. That over-reports (a doc-comment mention becomes a violation someone must +// look at) — which is the safe direction — and it is precisely what would have caught F12 even +// if measure 1 had missed, since `/[/*]/` opens a block that is never closed. +// +// THE RESIDUAL, stated accurately this time: a regex literal that measure 1 misclassifies as +// division AND that contains a BALANCED `/* … */` could still blank real code. That is narrower than +// the hole F12 exercised, but it is not "impossible", and this comment no longer says it is. +function blankComments(text) { + const out = text.split(""); + let i = 0; + let state = "code"; // code | line | block | single | double | template + let lastMeaningful = ""; + + // A `/` starts a regex literal when what precedes it cannot END an expression. Identifiers, + // numbers, `)` and `]` can, so a `/` after those is division. + const REGEX_START_AFTER = new Set(["", "=", "(", ",", "[", "{", ";", ":", "!", "&", "|", "?", "+", "-", "*", "%", "^", "~", "<", ">", "\n"]); + const KEYWORD_BEFORE_REGEX = /\b(?:return|typeof|instanceof|in|of|case|do|else|void|delete|yield|await)$/; + + while (i < text.length) { + const c = text[i]; + const n = text[i + 1]; + if (state === "code") { + // Comment openers are checked BEFORE the regex heuristic: `x;` followed by `// note` must stay + // a comment. This ordering is also why measure 1 is safe — at the first `/` of `/[/*]/` the + // next character is `[`, so neither comment branch fires and the regex branch gets its turn. + if (c === "/" && n === "/") { state = "line"; out[i] = " "; out[i + 1] = " "; i += 2; continue; } + if (c === "/" && n === "*") { state = "block"; out[i] = " "; out[i + 1] = " "; i += 2; continue; } + if (c === "/") { + const before = text.slice(0, i); + const canBeRegex = REGEX_START_AFTER.has(lastMeaningful) || KEYWORD_BEFORE_REGEX.test(before.trimEnd()); + if (canBeRegex) { + // Consume the literal verbatim — it is code, it stays. + let j = i + 1; + let inClass = false; + while (j < text.length) { + const rc = text[j]; + if (rc === "\\") { j += 2; continue; } + if (rc === "\n") break; // an unterminated literal is not a regex; bail out + if (rc === "[") inClass = true; + else if (rc === "]") inClass = false; + else if (rc === "/" && !inClass) { j++; break; } + j++; + } + lastMeaningful = "/"; + i = j; + continue; + } + } + if (c === "'") state = "single"; + else if (c === '"') state = "double"; + else if (c === "`") state = "template"; + if (!/\s/.test(c)) lastMeaningful = c; + i++; + continue; + } + if (state === "line") { + if (c === "\n") { state = "code"; i++; continue; } + out[i] = " "; + i++; + continue; + } + if (state === "block") { + if (c === "*" && n === "/") { state = "code"; out[i] = " "; out[i + 1] = " "; i += 2; continue; } + if (c !== "\n") out[i] = " "; + i++; + continue; + } + // inside a string/template: only the matching terminator (unescaped) ends it + if (c === "\\") { i += 2; continue; } + if ((state === "single" && c === "'") || (state === "double" && c === '"') || (state === "template" && c === "`")) { + state = "code"; + lastMeaningful = c; + } + i++; + } + + // Measure 2 — a block comment that never closes means this function's model of the file is wrong. + // Return the RAW text so the guard searches everything: over-reporting is recoverable (a human + // looks at a flagged doc-comment), under-reporting is the failure F12 was. + if (state === "block") return text; + return out.join(""); +} + +const files = SCAN_DIRS.flatMap((d) => { + try { return walk(join(root, d)); } catch { return []; } +}); + +const violations = []; +let occurrencesInDeclaration = 0; + +for (const file of files) { + const rel = relative(root, file).split("\\").join("/"); + const raw = readFileSync(file, "utf8"); + // Comments blanked, offsets preserved — so `code` and `raw` agree on every index and line number. + const code = rel.endsWith(".json") ? raw : blankComments(raw); + const region = rel === DECLARATION_FILE ? declarationRegion(raw) : null; + const lines = raw.split(/\r?\n/); + + for (const id of CHANNEL.binary_channel_identifiers) { + let idx = code.indexOf(id); + while (idx !== -1) { + const line = raw.slice(0, idx).split(/\r?\n/).length; + const inRegion = region && idx >= region[0] && idx < region[1]; + if (inRegion) occurrencesInDeclaration++; + else violations.push({ rel, line, id, text: (lines[line - 1] ?? "").trim().slice(0, 160) }); + idx = code.indexOf(id, idx + id.length); + } + } +} + +// A second, independent property: the pin resolver must not be able to observe process state at all. +// `resolvePin` being a pure function of (index, pin) is what makes AC4's determinism and AC5's +// independence the SAME fact — so the absence of `process.env` in that module is worth asserting +// mechanically rather than trusting a reviewer to notice its reintroduction. +const pinSource = readFileSync(join(root, DECLARATION_FILE), "utf8"); +if (/process\s*\.\s*env/.test(pinSource)) { + violations.push({ rel: DECLARATION_FILE, line: 0, id: "process.env", text: "the pin resolver must not read the environment — determinism (AC4) and channel independence (AC5) both depend on it being a pure function of (index, pin)" }); +} + +console.log(`channel separation — scanned ${files.length} file(s) across ${SCAN_DIRS.join(", ")}`); +console.log(`binary-channel identifiers checked: ${CHANNEL.binary_channel_identifiers.join(", ")}`); +console.log(`declaration-site occurrences (lib/pin.mjs frozen list): ${occurrencesInDeclaration}`); + +if (violations.length) { + console.error(`REFUSED — the plugin channel must not reference the binary channel (${violations.length} occurrence(s)):`); + for (const v of violations) console.error(` - ${v.rel}:${v.line} [${v.id}] ${v.text}`); + console.error("The plugin's cycle is independent of the binary's (D19): version+tier+digest in the plugin's own marker, resolved from the catalog index + the pin, and nothing else."); + process.exit(1); +} + +console.log("OK — no executable file in this repository reads binary-channel state"); diff --git a/scripts/resolve-pin.mjs b/scripts/resolve-pin.mjs new file mode 100644 index 0000000..888c4a6 --- /dev/null +++ b/scripts/resolve-pin.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// scripts/resolve-pin.mjs — story 055.W4.1 (D20(2)). Resolves `@` against an +// index to the artifact's DIGEST, and optionally verifies local bytes against it. +// +// --index the index to resolve against (required) +// --pin the pin (required) +// --verify recompute sha256 of these bytes and compare to the resolved digest +// --json machine-readable output (carries `pin_cost` — see below) +// +// Exit 0 when the pin resolves (and, with --verify, when the bytes match); non-zero otherwise. +// +// EVERY output mode carries the pin's COST (AC6): pinning freezes an install, which also means an +// already-installed artifact CANNOT BE REPAIRED by a later corrected build — the capability that +// index freshness (story 055.W5.1, D20(5)) is what gives back. Printing the benefit without the cost +// is the exact miscommunication advisory-council finding C4 measured, so the CLI cannot do it. + +import { readFileSync, existsSync } from "node:fs"; +import { resolvePin, verifyBytesAgainstPin, renderResolution } from "../lib/pin.mjs"; + +function usageAndExit(msg) { + if (msg) console.error(`error: ${msg}\n`); + console.error("usage: node scripts/resolve-pin.mjs --index --pin [--verify ] [--json]"); + process.exit(2); +} + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "--json") { args.json = true; continue; } + if (!a.startsWith("--")) continue; + args[a.slice(2)] = process.argv[++i]; +} + +if (!args.index) usageAndExit("--index is required"); +if (!args.pin) usageAndExit("--pin is required"); +if (!existsSync(args.index)) usageAndExit(`--index ${args.index} does not exist`); + +let resolved; +try { + resolved = resolvePin(JSON.parse(readFileSync(args.index, "utf8")), args.pin); +} catch (e) { + console.error(`REFUSED — ${e.message}`); + process.exit(1); +} + +let verification = null; +if (args.verify) { + if (!existsSync(args.verify)) usageAndExit(`--verify ${args.verify} does not exist`); + verification = verifyBytesAgainstPin(resolved, args.verify); +} + +if (args.json) { + console.log(JSON.stringify({ resolved, verification }, null, 2)); +} else { + console.log(renderResolution(resolved)); + if (verification) { + console.log(""); + console.log(`verify ${verification.path}`); + console.log(` expected ${verification.expected}`); + console.log(` actual ${verification.actual}`); + console.log(` result ${verification.ok ? "MATCH — same pin, same digest, same bytes" : "MISMATCH"}`); + } +} + +if (verification && !verification.ok) { + console.error("REFUSED — the bytes do not match the digest this pin resolves to"); + process.exit(1); +} diff --git a/scripts/scan-secrets.mjs b/scripts/scan-secrets.mjs new file mode 100644 index 0000000..36bc642 --- /dev/null +++ b/scripts/scan-secrets.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// scripts/scan-secrets.mjs — story 055.W4.1 (D20(1)). The standalone CLI for the same scanner +// publisher/publish.mjs runs as a blocking gate (lib/secret-scanner.mjs — imported, never +// reimplemented, so the CI-time and publish-time behaviours cannot drift apart). +// +// Two inputs, either or both: +// --artifact scan a plugin artifact's real bytes +// --manifest scan a publish manifest as raw text +// +// Exit 1 on any finding, 0 otherwise. `--json` emits the machine-readable report (which carries the +// `limits` array — the blind spots travel with the result by construction, in every output mode). + +import { existsSync } from "node:fs"; +import { scanArtifact, scanManifestFile, renderScanReport, unscannableMembers } from "../lib/secret-scanner.mjs"; + +function usageAndExit(msg) { + if (msg) console.error(`error: ${msg}\n`); + console.error("usage: node scripts/scan-secrets.mjs [--artifact ] [--manifest ] [--json]"); + process.exit(2); +} + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "--json") { args.json = true; continue; } + if (!a.startsWith("--")) continue; + args[a.slice(2)] = process.argv[++i]; +} + +if (!args.artifact && !args.manifest) usageAndExit("at least one of --artifact / --manifest is required"); +for (const k of ["artifact", "manifest"]) { + if (args[k] && !existsSync(args[k])) usageAndExit(`--${k} ${args[k]} does not exist`); +} + +const reports = []; +if (args.manifest) reports.push(scanManifestFile(args.manifest)); +if (args.artifact) reports.push(scanArtifact(args.artifact)); + +const findings = reports.flatMap((r) => r.findings); +// fix-cycle-1 (F2): the CLI and publisher/publish.mjs must agree on what refuses, or CI would pass +// an artifact the publish path rejects (and, worse, the reverse). +const unscannable = reports.flatMap((r) => unscannableMembers(r)); + +if (args.json) { + console.log(JSON.stringify({ reports, findings_total: findings.length, unscannable_total: unscannable.length }, null, 2)); +} else { + for (const r of reports) console.log(renderScanReport(r)); +} + +if (findings.length) { + console.error(`\nREFUSED — ${findings.length} credential(s) found (BLOCKING, D20(1)/AC1)`); + process.exit(1); +} +if (unscannable.length) { + console.error(`\nREFUSED — ${unscannable.length} member(s) could NOT be scanned (fail-closed, D20(1)/AC1):`); + for (const u of unscannable) console.error(` - ${u.path} (${u.bytes} bytes) — ${u.why}`); + console.error("Unscannable is treated as not publishable. See lib/secret-scanner.mjs's decision site for why there is no override."); + process.exit(1); +} +console.error("\nOK — no credential of any covered class found (read the limits above before reading this as a safety verdict)"); diff --git a/scripts/validate-index.mjs b/scripts/validate-index.mjs index 30dcea4..9cab120 100644 --- a/scripts/validate-index.mjs +++ b/scripts/validate-index.mjs @@ -9,9 +9,13 @@ // fix-cycle-1 (QG round 1, F7 — this comment was stale): the D24 invariant suite (id-immutability // history, burned-name ledger, license-in-package-root) and D21's publish-time tier check landed in // story 055.W3.3, but wired into SEPARATE scripts (scripts/check-ledger-append-only.mjs, -// scripts/check-ledger-consistency.mjs) and publisher/publish.mjs — not into this file. Secret -// scanning (D20(1)) and capability analysis (D20(4)) genuinely remain future work -// (055.W4.1/055.W4.2), unwired anywhere. +// scripts/check-ledger-consistency.mjs) and publisher/publish.mjs — not into this file. The same is +// now true of the two D20 controls that were future work when this comment was first written: +// capability analysis (D20(4), story 055.W4.2 — lib/capability-analyzer.mjs + +// scripts/analyze-capabilities.mjs) and BLOCKING secret scanning (D20(1), story 055.W4.1 — +// lib/secret-scanner.mjs + scripts/scan-secrets.mjs, gating inside publisher/publish.mjs). Both +// operate on an ARTIFACT's bytes, which this file never has: it validates an index FILE. Neither is +// wired here, and that is by construction, not omission. // // fix-cycle-1 (055.W3.1 QG @architect, F-AC6-ARTIFACT-BINDING): now also runs checkArtifactBinding // per entry (imported, not reimplemented) so a hand-edited index that skips publish.mjs can't slip @@ -43,12 +47,30 @@ export function validateIndexData(data, label) { } // D24(a)/(b) base guard, replicated here so a hand-edit can't bypass what publish.mjs enforces: - // no two entries with the same plugin_id+version but different digests. + // no two entries with the same plugin_id+version. + // + // fix-cycle-2 (F13) — this used to fire ONLY when the duplicates' digests CONFLICTED, while + // lib/pin.mjs (tightened in fix-cycle-1 for F5) refuses ANY duplicate. The QG executed the + // divergence: an index with two same-digest entries for `dup@1.0.0`, differing only in `tiers`, + // passed `validate-index` with 0 violations and was then REFUSED by `resolvePin`. Green in CI, + // unresolvable in use — which fails closed, so nothing unsafe ships, but it reads as a bug to + // whoever hits it and it means two parts of this repo disagree about whether the same file is + // valid. + // + // The two are now the same rule. Deliberately NOT done by having pin.mjs import this module: that + // module stays dependency-light on purpose (it must resolve against any index that declares itself + // valid, including a future schema version). The anti-drift device is a test that runs BOTH over + // the same fixture and asserts they agree — see test/pin.test.mjs, "F13". const seen = new Map(); for (const e of data.entries) { const key = `${e.plugin_id}@${e.version}`; - if (seen.has(key) && seen.get(key) !== e.digest?.value) { - errors.push(`${label}: duplicate ${key} with conflicting digest — D24 immutability violated`); + if (seen.has(key)) { + const sameDigest = seen.get(key) === e.digest?.value; + errors.push( + sameDigest + ? `${label}: duplicate ${key} — two entries share this plugin_id@version with the same digest but potentially differing metadata (tiers/mirror_url). Resolution would depend on entry ORDER, and \`tiers\` is the entitlement axis, so lib/pin.mjs refuses it; CI refuses it here for the same reason` + : `${label}: duplicate ${key} with conflicting digest — D24 immutability violated`, + ); } seen.set(key, e.digest?.value); } diff --git a/test/helpers/secret-fixtures.mjs b/test/helpers/secret-fixtures.mjs new file mode 100644 index 0000000..a5693d5 --- /dev/null +++ b/test/helpers/secret-fixtures.mjs @@ -0,0 +1,432 @@ +// test/helpers/secret-fixtures.mjs — story 055.W4.1, AC2. One planted credential PER COVERED CLASS. +// +// ── WHY EVERY VALUE IS ASSEMBLED AT RUNTIME FROM FRAGMENTS ─────────────────────────────────────── +// +// Not stylistic. Two hard reasons: +// +// 1. AC7 — "no secret in the pipeline itself". A test fixture that is a literal, well-formed +// credential shape sitting in a committed file is exactly the thing this story exists to keep +// out of published bytes. Concatenating fragments means the repository never contains the shape +// contiguously, while the value the scanner sees at runtime is byte-for-byte a real one. +// 2. This repository's own CI already greps every committed file for `AKIA[0-9A-Z]{16}`, a PEM +// `BEGIN ... PRIVATE KEY` header, and a `CLOUDFLARE_API_TOKEN=` assignment (.github/workflows/ +// ci.yml, "No obvious secret shapes committed"). Committing literal fixtures would make this +// story's own tests fail that guard — and the tempting fix (adding an exclusion for the test +// directory) would punch a hole in a working control to accommodate a test. Assembling at +// runtime keeps BOTH controls intact, with neither weakened. +// +// Every value below is FABRICATED — invented character sequences that match a provider's published +// FORMAT. None of them is, or was ever, a live credential for anything. +// +// ── "INVALID IN EXACTLY ONE WAY" ───────────────────────────────────────────────────────────────── +// +// Same discipline as test/helpers/tarball.mjs's license fixtures: each artifact below is a fully +// VALID publishable package (LICENSE at root, a skill with `allowed-tools`) that carries exactly one +// planted credential, of exactly one class. `test/secret-scanner.test.mjs` asserts that mechanically +// — a fixture that trips two rules would let its test pass for the wrong reason. + +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildTarball, FIXTURE_SKILL } from "./tarball.mjs"; +import { MAX_SCANNED_FILE_BYTES } from "../../lib/secret-scanner.mjs"; + +// fix-cycle-4 (F17). macOS `tar` writes an AppleDouble `._name` companion member for every file that +// carries extended attributes, and `tar -tzf` does NOT list them — so their bytes ship inside the +// artifact while being invisible to the archive's own listing. A fixture is meant to be a WELL-FORMED +// package, and the scanner now (correctly) refuses one that carries members it does not declare. +// This env var is the standard macOS fix and a no-op on Linux, where GNU tar never writes them. +const NO_APPLEDOUBLE = { ...process.env, COPYFILE_DISABLE: "1" }; + +const B = "-----BEGIN "; +const E = "-----END "; +const PK = "RSA PRIVATE KEY-----"; + +// Fabricated PEM body — long enough to clear the rule's `{64,}` minimum between the two markers. +const PEM_BODY = [ + "MIIEpAIBAAKCAQEA3vQ2mKcLdYw7RsTbNzXfQjHgVpKmEuLoAiCwZxNdRfTgYhUj", + "kLmPqRsTuVwXyZ0123456789abcdefGHIJKLMNOPqrstuvwxYZabcdEFGHijklmn", + "opQRSTuvwxYZ0123456789ABCDEFghijKLMNopqrstUVWXyz0123456789abcdef", +].join("\n"); + +// Each entry: the CLASS (matching lib/secret-rules.mjs `SECRET_CLASSES`), the file the credential is +// planted in, and a `render()` that assembles it. `where` is realistic on purpose — a credential +// leaks in a config file, a deploy script or a README, not in a file named `secret.txt`. +export const PLANTED_SECRETS = Object.freeze([ + { + class: "private-key", + where: "config/deploy-key.pem", + render: () => `${B}${PK}\n${PEM_BODY}\n${E}${PK}\n`, + }, + { + class: "aws-access-key", + where: "config/settings.env", + render: () => `AWS_ACCESS_KEY_ID=${"AKIA"}${"QRS7TUVWX234YZ56"}\n`, + }, + { + class: "github-token", + where: "scripts/release.sh", + render: () => `#!/bin/sh\nexport GH_TOKEN=${"ghp_"}${"aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789"}\n`, + }, + { + class: "github-fine-grained-token", + where: "config/ci.env", + render: () => + `FORGE_TOKEN=${"github_pat_"}${"11ABCDEFG0aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789bCdEfGhIjKlMnOpQrStUvWxYz01234567_x9"}\n`, + }, + { + class: "cloudflare-api-token", + where: "config/edge.toml", + render: () => `${"cloudflare"}_api_token = "${"vQ7hZ2mKcLdYw9RsTbNzXfQjHgVpKmEuLoAiCwZx"}"\n`, + }, + { + class: "cloudflare-global-api-key", + where: "config/edge-global.toml", + render: () => `${"cloudflare"}_global_key = "${"a1b2c3d4e5f60718293a4b5c6d7e8f90abcde"}"\n`, + }, + { + class: "slack-token", + where: "scripts/notify.sh", + render: () => `SLACK=${"xoxb-"}${"2938471029384"}-${"1029384756102"}-${"aB3dEf7hIjKlMnOpQrStUvWx"}\n`, + }, + { + class: "slack-user-token", + where: "config/slack.env", + render: () => + `SLACK_USER=${"xoxp-"}${"2938471029384"}-${"1029384756102"}-${"5647382910473"}-${"aB3dEf7hIjKlMnOpQrStUvWxYz012345"}\n`, + }, + { + class: "stripe-key", + where: "config/billing.env", + render: () => `STRIPE_SECRET="${"sk_live_"}${"4eC39HqLyjWDarjtT1zdp7dc"}"\n`, + }, + { + class: "openai-api-key", + where: "config/models.env", + render: () => `OPENAI="${"sk-"}${"aB3dEf7hIjKlM9oPqRsT"}${"T3BlbkFJ"}${"uVwXyZ0123456789abcd"}"\n`, + }, + { + class: "anthropic-api-key", + where: "config/anthropic.env", + render: () => + `ANTHROPIC="${"sk-ant-api03-"}${"aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012"}${"AA"}"\n`, + }, + { + class: "gcp-api-key", + where: "config/maps.env", + render: () => `GOOGLE_MAPS="${"AIza"}${"Sy7dQmKcLdYw9RsTbNzXfQjHgVpUeoAi2x4"}"\n`, + }, + { + class: "npm-token", + where: ".npmrc", + render: () => `//registry.npmjs.org/:_authToken=${"npm_"}${"aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789"}\n`, + }, + { + class: "jwt", + where: "config/session.json", + render: () => + `{ "token": "${"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}.${"eyJzdWIiOiJhY2N0X2ZpeHR1cmUiLCJpYXQiOjE1MTYyMzkwMjJ9"}.${"dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"}" }\n`, + }, +]); + +// A fully valid publishable artifact + exactly one planted credential. +export function buildArtifactWithPlantedSecret(planted) { + return buildTarball({ + LICENSE: "MIT License\n\nCopyright (c) AIOX\n", + "SKILL.md": FIXTURE_SKILL, + [planted.where]: planted.render(), + }); +} + +// ── fix-cycle-1 (F2) — the EVASION fixtures ────────────────────────────────────────────────────── +// +// These are the two artifacts the QG built by hand to defeat the gate. They are reproduced here as +// permanent fixtures so the fail-closed decision is proven BY EXECUTION rather than by the paragraph +// that argues for it: the argument can be edited, the fixtures cannot be satisfied by prose. +// +// Both carry a REAL, shape-valid credential — the same `aws-access-key` fixture used above — so a +// scanner that could read the member would certainly find it. The only thing standing between the +// credential and publication is whether "I could not read this" is treated as a pass. + +// Evasion A — one leading NUL byte makes the member read as binary. +// The NUL is produced with String.fromCharCode, never written as a literal byte in this source: a +// source file containing a real NUL is classified binary, and `grep -I` (which this repo's own CI +// guards use) SKIPS binary files, which would silently drop this file out of the sweeps it must be +// subject to. +export function buildArtifactWithNulPrefixedSecret() { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + return buildTarball({ + LICENSE: "MIT License\n\nCopyright (c) AIOX\n", + "SKILL.md": FIXTURE_SKILL, + "config/creds.env": String.fromCharCode(0) + planted.render(), + }); +} + +// Evasion B — the same credential, followed by padding that pushes the member past the scan cap. +export function buildArtifactWithOversizedSecret() { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const padding = "#".repeat(MAX_SCANNED_FILE_BYTES + 1); + return buildTarball({ + LICENSE: "MIT License\n\nCopyright (c) AIOX\n", + "SKILL.md": FIXTURE_SKILL, + "config/creds.env": planted.render() + padding, + }); +} + +// ── fix-cycle-2 (F10/F11) — the STRUCTURAL evasions ────────────────────────────────────────────── +// +// These two cannot be built with `buildTarball`, which writes a directory and archives it: one needs +// the same path to appear TWICE in a single tar stream (the filesystem cannot hold that), and the +// other needs a member that is not a regular file. They are built by driving `tar` directly, the +// same posture as the rest of this helper — exercise the real tool, never a mock. +// +// gzip is done with node:zlib rather than by shelling out, so the fixture does not depend on a +// `gzip` binary being on PATH in CI. + +// F10 — the shadowed duplicate. Member 1 at `config/app.env` carries a shape-valid AWS key; member 2 +// at the SAME path is clean. Extraction keeps only the clean one, so a filesystem-based inventory +// sees nothing wrong — while `tar -xOzf artifact.tar.gz ./config/app.env` still prints the +// credential from the published bytes. This is the QG's construction, reproduced verbatim. +export function buildArtifactWithShadowedDuplicate() { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const first = mkdtempSync(join(tmpdir(), "aiox-plugins-shadow-a-")); + const second = mkdtempSync(join(tmpdir(), "aiox-plugins-shadow-b-")); + const outDir = mkdtempSync(join(tmpdir(), "aiox-plugins-shadow-out-")); + try { + mkdirSync(join(first, "config"), { recursive: true }); + writeFileSync(join(first, "LICENSE"), "MIT License\n\nCopyright (c) AIOX\n"); + writeFileSync(join(first, "SKILL.md"), FIXTURE_SKILL); + writeFileSync(join(first, "config", "app.env"), planted.render()); // the credential + + mkdirSync(join(second, "config"), { recursive: true }); + writeFileSync(join(second, "config", "app.env"), "APP_ENV=production\n"); // the innocent shadow + + const tarPath = join(outDir, "artifact.tar"); + // COPYFILE_DISABLE=1 — see test/helpers/tarball.mjs. The isolated defect here is the DUPLICATE + // path; a fixture that also carried AppleDouble members would be refused for two reasons at once + // and its test would pass for the wrong one. + execFileSync("tar", ["-cf", tarPath, "."], { cwd: first, env: NO_APPLEDOUBLE }); + execFileSync("tar", ["-rf", tarPath, "./config/app.env"], { cwd: second, env: NO_APPLEDOUBLE }); + + const gz = join(outDir, "artifact.tar.gz"); + writeFileSync(gz, gzipSync(readFileSync(tarPath))); + return gz; + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } +} + +// F11 — a non-regular member. The symlink carries no bytes of its own, so this is an honesty defect +// rather than a leak path: before fix-cycle-2 it was dropped BEFORE enumeration, so a 3-member +// archive reported "2/2 file(s) scanned" — complete coverage of an archive it had not fully seen. +export function buildArtifactWithSymlinkMember() { + const src = mkdtempSync(join(tmpdir(), "aiox-plugins-symlink-src-")); + const outDir = mkdtempSync(join(tmpdir(), "aiox-plugins-symlink-out-")); + try { + mkdirSync(join(src, "config"), { recursive: true }); + writeFileSync(join(src, "LICENSE"), "MIT License\n\nCopyright (c) AIOX\n"); + writeFileSync(join(src, "SKILL.md"), FIXTURE_SKILL); + symlinkSync("/etc/passwd", join(src, "config", "outside.env")); + const gz = join(outDir, "artifact.tar.gz"); + execFileSync("tar", ["-czf", gz, "."], { cwd: src, env: NO_APPLEDOUBLE }); + return gz; + } finally { + rmSync(src, { recursive: true, force: true }); + } +} + +// ── fix-cycle-3 (F14) — a member that PRESENTS as a directory while carrying file data ─────────── +// +// This one cannot be produced by any standard tar tool, which is precisely why it needs a forged +// header: `tar` will not create a regular-file member whose name ends in `/`. The archive below is a +// valid ustar stream (correct header checksum — the second engine's own attempt produced a DAMAGED +// archive and therefore proved nothing, which is worth remembering before trusting a probe that was +// not run). +// +// The member is typeflag '0' (REGULAR FILE) named `./config/payload/` carrying a shape-valid AWS +// key. `tar -tvzf` renders it as type `d` because bsdtar prints any trailing-slash name as a +// directory — so a classifier that exempts on APPEARANCE lets it through, while +// `tar -xOzf artifact.tar.gz ./config/payload/` prints the credential from the published bytes with +// the same tar on the same machine. + +function ustarHeader(name, size, typeflag, { uname = "", gname = "" } = {}) { + const b = Buffer.alloc(512, 0); + const put = (s, off, len) => b.write(String(s).slice(0, len), off, len, "ascii"); + const oct = (n, len) => n.toString(8).padStart(len - 1, "0") + "\0"; + put(name, 0, 100); + put(oct(0o644, 8), 100, 8); + put(oct(0, 8), 108, 8); + put(oct(0, 8), 116, 8); + put(oct(size, 12), 124, 12); + put(oct(Math.floor(Date.now() / 1000), 12), 136, 12); + b.write(" ", 148, 8, "ascii"); // checksum field counts as spaces while summing + put(typeflag, 156, 1); + b.write("ustar\0", 257, 6, "ascii"); + b.write("00", 263, 2, "ascii"); + // uname/gname are 32-byte FREE-TEXT slots the archive's author fills in — see the F17 fixture below + // for why that matters to anything that reads `tar`'s rendered listing. + if (uname) put(uname, 265, 32); + if (gname) put(gname, 297, 32); + let sum = 0; + for (const byte of b) sum += byte; + b.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "ascii"); + return b; +} + +function ustarMember(name, data, typeflag, opts) { + const buf = Buffer.from(data); + const pad = Buffer.alloc((512 - (buf.length % 512)) % 512, 0); + return Buffer.concat([ustarHeader(name, buf.length, typeflag, opts), buf, pad]); +} + +// ── Leitura HERMÉTICA do artefato, espelho do `ustarHeader` acima ──────────────────────────────── +// +// Adicionado pelo coordenador da wave (2026-08-10) porque os fixtures do F14/F17 VERIFICAVAM o +// artefato com `execFileSync("tar", …)` — extraindo POR NOME um membro cujo nome termina em `/`. +// Isso passa com bsdtar/libarchive (macOS) e REPROVA com GNU tar (o runner Linux da CI): extrair um +// arquivo REGULAR cujo nome tem forma de diretório é justamente o caso ambíguo que estes fixtures +// constroem de propósito, e cada implementação resolve à sua maneira. Resultado: 241/241 local, +// 238/241 na CI, e os 3 vermelhos eram exatamente as provas do F14 e do F17. +// +// É a mesma lição do F17 aplicada à PROVA em vez da implementação: não delegues a classificação a +// uma ferramenta cujo comportamento varia — lê os bytes. Este leitor usa os mesmos offsets que o +// escritor logo acima (name@0, size@124 octal, typeflag@156), então fixture e verificação +// permanecem um par coerente, e a suíte roda idêntica em qualquer sistema, com ou sem `tar`. +export function readArtifactMembers(artifactPath) { + const buf = gunzipSync(readFileSync(artifactPath)); + const members = []; + for (let off = 0; off + 512 <= buf.length; ) { + const h = buf.subarray(off, off + 512); + if (h.every((byte) => byte === 0)) break; // dois blocos nulos terminam o arquivo + const str = (start, len) => { + const raw = h.subarray(start, start + len); + const nul = raw.indexOf(0); + return raw.subarray(0, nul === -1 ? raw.length : nul).toString("ascii").trim(); + }; + const name = str(0, 100); + const octal = str(124, 12); + const size = octal ? parseInt(octal, 8) : 0; + const typeflag = String.fromCharCode(h[156] || 0x30); + const data = buf.subarray(off + 512, off + 512 + size); + members.push({ name, typeflag, size, data }); + off += 512 + Math.ceil(size / 512) * 512; + } + return members; +} + +// Os dois atalhos que os testes usavam via `tar -tzf` e `tar -xOzf`. +export function artifactMemberNames(artifactPath) { + return readArtifactMembers(artifactPath).map((m) => m.name); +} +export function artifactMemberText(artifactPath, memberName) { + // TODAS as ocorrências, concatenadas — não a primeira. Um tarball pode carregar o MESMO nome mais + // de uma vez (é exatamente o fixture do F10: um membro sombreando o outro), e `tar -xOzf` despeja + // as duas cópias em sequência. Devolver só a primeira faria o teste do F10 procurar a credencial + // na cópia inocente e não encontrá-la — falso negativo introduzido pela própria verificação. + return readArtifactMembers(artifactPath) + .filter((x) => x.name === memberName) + .map((x) => x.data.toString("utf8")) + .join(""); +} + +export function buildArtifactWithDirectoryShapedFileMember() { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const outDir = mkdtempSync(join(tmpdir(), "aiox-plugins-forged-")); + const tar = Buffer.concat([ + ustarMember("./LICENSE", "MIT License\n\nCopyright (c) AIOX\n", "0"), + ustarMember("./SKILL.md", FIXTURE_SKILL, "0"), + ustarMember("./config/payload/", planted.render(), "0"), // typeflag '0' + trailing-slash name + Buffer.alloc(1024, 0), // two zero blocks terminate the archive + ]); + const gz = join(outDir, "artifact.tar.gz"); + writeFileSync(gz, gzipSync(tar)); + return gz; +} + +// ── fix-cycle-4 (F17) — the SAME member, plus ONE forged header field ──────────────────────────── +// +// This is the F14 archive with a single addition: a crafted `uname` of `0 Aug 1` written into the +// ustar header's 32-byte uname slot. Nothing about the member itself changes — same typeflag '0', +// same trailing-slash name, same 39 bytes of credential. What changes is how `tar -tvzf` RENDERS the +// line, because uname is a free-text column printed between the link count and the size: +// +// drw-r--r-- 0 0 Aug 1 g 39 Jul 27 2021 ./config/payload/ +// ^^^^^^^ injected — a "digits followed by a date", i.e. the exact shape the +// size-column regex anchors on, appearing BEFORE the real size of 39 +// +// The cycle-3 allowlist read the size from that rendering, so `parseMemberSize` returned 0, the +// member was positively identified as a real directory, and the credential published at exit 0. +// +// That is the whole point of the fixture: cycle 3 inverted the classifier (correct in SHAPE — +// exemption now requires positive evidence) but left the EVIDENCE itself attacker-shapeable. This +// member is the proof that a forgeable allowlist is an allowlist in shape and a denylist in effect, +// and it is the permanent regression test for the class. +export function buildArtifactWithForgedUnameDirectoryMember() { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const outDir = mkdtempSync(join(tmpdir(), "aiox-plugins-forged-uname-")); + const tar = Buffer.concat([ + ustarMember("./LICENSE", "MIT License\n\nCopyright (c) AIOX\n", "0"), + ustarMember("./SKILL.md", FIXTURE_SKILL, "0"), + ustarMember("./config/payload/", planted.render(), "0", { uname: "0 Aug 1", gname: "g" }), + Buffer.alloc(1024, 0), + ]); + const gz = join(outDir, "artifact.tar.gz"); + writeFileSync(gz, gzipSync(tar)); + return gz; +} + +// ── fix-cycle-4 (F17) — a member the archive's own listing does not admit exists ───────────────── +// +// MEASURED, not theorised. Running `xattr -w com.example.cfg "AWS_ACCESS_KEY_ID=AKIA…" LICENSE` and +// then `tar -czf` on macOS produces an archive whose `tar -tzf` prints exactly `./` and `./LICENSE` +// — while the credential is recoverable verbatim from the published bytes, carried by a 163-byte +// `./._LICENSE` AppleDouble member that the listing never mentions. Every cycle before this one +// enumerated from that listing, so all four reported complete coverage of an archive containing a +// member they had never seen. Same undisclosed-blindness class as F10/F11/F14/F17. +// +// THE FIXTURE USES THE REAL TRIGGER, and that is a deliberate trade-off with a cost worth naming. +// A hand-forged AppleDouble member was tried first and REJECTED by measurement: macOS `tar` hides it +// from `-tzf` exactly as the real one is hidden, but then FAILS to extract the archive ("Failed to +// restore metadata"), because the forged blob is not a valid AppleDouble structure. A fixture that +// cannot be unpacked is not a package, and a test built on it would be asserting against a shape no +// publisher can produce. Writing a real AppleDouble encoder to fix that is a bigger detour than the +// property is worth. +// +// So the trigger is genuine — `xattr -w` then `tar -czf` — and therefore macOS-only. The function +// returns null where the platform cannot produce the condition (CI runs on ubuntu, where GNU tar has +// no AppleDouble concept at all, so there is nothing to reproduce rather than something skipped). +// CI's coverage of the REFUSAL itself does not depend on this: `classifyMembers` is pinned for both +// directions of the differential by a portable unit test that runs everywhere. +export function buildArtifactWithHiddenAppleDoubleMember() { + if (process.platform !== "darwin") return null; + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const src = mkdtempSync(join(tmpdir(), "aiox-plugins-appledouble-src-")); + const outDir = mkdtempSync(join(tmpdir(), "aiox-plugins-appledouble-out-")); + try { + writeFileSync(join(src, "LICENSE"), "MIT License\n\nCopyright (c) AIOX\n"); + writeFileSync(join(src, "SKILL.md"), FIXTURE_SKILL); + execFileSync("xattr", ["-w", "com.example.deploy", planted.render(), join(src, "LICENSE")]); + const gz = join(outDir, "artifact.tar.gz"); + // NO COPYFILE_DISABLE here — the whole point is what macOS `tar` does by DEFAULT. + execFileSync("tar", ["-czf", gz, "."], { cwd: src }); + return gz; + } catch { + return null; // xattr unavailable or refused — the condition cannot be produced here + } finally { + rmSync(src, { recursive: true, force: true }); + } +} + +// The positive control (AC2's second half): the SAME package shape with no credential in it. If this +// one is also refused, the gate is a blanket refusal and every negative result above proves nothing. +export function buildCleanArtifact() { + return buildTarball({ + LICENSE: "MIT License\n\nCopyright (c) AIOX\n", + "SKILL.md": FIXTURE_SKILL, + "config/settings.env": "AWS_REGION=us-east-1\nLOG_LEVEL=debug\nAPI_BASE=https://example.invalid/v1\n", + "scripts/release.sh": "#!/bin/sh\nset -e\necho 'no credentials here'\n", + }); +} diff --git a/test/helpers/tarball.mjs b/test/helpers/tarball.mjs index c410941..66128af 100644 --- a/test/helpers/tarball.mjs +++ b/test/helpers/tarball.mjs @@ -18,7 +18,13 @@ export function buildTarball(files) { writeFileSync(full, content); } const outPath = join(outDir, "artifact.tar.gz"); - execFileSync("tar", ["-czf", outPath, "-C", workDir, "."]); + // COPYFILE_DISABLE=1 (fix-cycle-4, F17) — without it, macOS `tar` writes an AppleDouble `._name` + // companion member for every file, and `tar -tzf` does NOT list them. Those members' bytes ship + // inside the artifact, so the scanner's header walk enumerates them and REFUSES them as + // uncertifiable. That refusal is correct: this env var is what a well-formed macOS build uses, + // and a fixture is supposed to be a well-formed package. The refusal itself is pinned by its own + // negative test in test/publish-cli.test.mjs — this is not a fixture edited to dodge a gate. + execFileSync("tar", ["-czf", outPath, "-C", workDir, "."], { env: { ...process.env, COPYFILE_DISABLE: "1" } }); return outPath; } finally { rmSync(workDir, { recursive: true, force: true }); diff --git a/test/pin.test.mjs b/test/pin.test.mjs new file mode 100644 index 0000000..94a5607 --- /dev/null +++ b/test/pin.test.mjs @@ -0,0 +1,418 @@ +// test/pin.test.mjs — story 055.W4.1, AC4 (deterministic pin) + AC5 (channel separation) + AC6 +// (the pin's cost travels with the resolution). +// +// WHAT IS PROVEN HERE vs. WHAT IS PROVEN LIVE: the digest this suite pins against +// (9ec01ff4...561a) is the digest of the artifact actually mirrored in R2 by story 055.W3.1, and the +// live HTTP round-trip against that endpoint was EXECUTED (see the story's handoff for the literal +// curl + shasum output). It is deliberately NOT re-run here: a unit suite that needs the network to +// pass fails for reasons that have nothing to do with the code. What this suite proves offline is +// the property that would silently rot — that the resolver still maps that pin to that exact digest, +// so a regression is caught on every push without depending on a bucket being reachable. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, unlinkSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parsePin, resolvePin, resolvePinFromFile, verifyBytesAgainstPin, renderResolution, PIN_COST, CHANNEL } from "../lib/pin.mjs"; +import { validateIndexData } from "../scripts/validate-index.mjs"; +import { buildValidArtifact, buildTarball, fixtureSkill } from "./helpers/tarball.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, ".."); +const fixturesIndex = join(repoRoot, "fixtures", "index.json"); +const publishScript = join(repoRoot, "publisher", "publish.mjs"); +const channelGuard = join(repoRoot, "scripts", "check-channel-separation.mjs"); +const resolveCli = join(repoRoot, "scripts", "resolve-pin.mjs"); + +const GOOD_HOST = "pub-42179e62dc3040138151ec33229dd073.r2.dev"; + +// The digest recorded by 055.W3.1 for the artifact it uploaded and verified over HTTP. +const W31_MIRRORED_DIGEST = "9ec01ff45d2966fde7de79e46b31fa97a9485f28e2b625fdfe0af0aaa433561a"; + +function withTempDir(fn) { + const dir = mkdtempSync(join(tmpdir(), "aiox-plugins-pin-test-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function publish({ dir, target, ledger, plugin_id, lineage_id, version, artifact }) { + const manifest = join(dir, `manifest-${plugin_id}-${version}.json`); + writeFileSync( + manifest, + JSON.stringify({ plugin_id, lineage_id, name: plugin_id, description: "pin test", version, tiers: ["base"], license: "MIT" }), + ); + execFileSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_pin_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins/${plugin_id}/${version}/x.tar.gz`, + "--r2-key", `plugins/${plugin_id}/${version}/x.tar.gz`, + "--no-push", + ], { stdio: "pipe" }); +} + +function emptyIndexAndLedger(dir) { + const target = join(dir, "index.json"); + const ledger = join(dir, "ledger.json"); + writeFileSync(target, JSON.stringify({ schema_version: "2.0.0", generated_at: null, entries: [] }, null, 2)); + writeFileSync(ledger, JSON.stringify({ schema_version: "2.0.0", plugins: {} }, null, 2)); + return { target, ledger }; +} + +describe("AC4 — the pin is deterministic: same pin ⇒ same digest ⇒ same bytes", () => { + test("pin syntax is parsed strictly (a pin that means two things is not a pin)", () => { + assert.deepEqual(parsePin("sinkra-os@1.2.0"), { plugin_id: "sinkra-os", version: "1.2.0" }); + assert.deepEqual(parsePin("sinkra-os@0.0.0-fixture"), { plugin_id: "sinkra-os", version: "0.0.0-fixture" }); + for (const bad of ["sinkra-os", "sinkra-os@latest", "sinkra-os@1.2", "@1.2.0", "Sinkra-OS@1.2.0", ""]) { + assert.throws(() => parsePin(bad), /invalid pin/, `"${bad}" must not parse`); + } + }); + + test("resolving the SAME pin 1000 times yields byte-identical resolutions", () => { + const data = JSON.parse(readFileSync(fixturesIndex, "utf8")); + const first = JSON.stringify(resolvePin(data, "sinkra-os@0.0.0-fixture")); + for (let i = 0; i < 1000; i++) { + assert.equal(JSON.stringify(resolvePin(data, "sinkra-os@0.0.0-fixture")), first); + } + }); + + test("the pin resolves to the digest of the artifact 055.W3.1 actually mirrored in R2", () => { + const r = resolvePinFromFile(fixturesIndex, "sinkra-os@0.0.0-fixture"); + assert.equal(r.digest.algorithm, "sha256"); + assert.equal(r.digest.value, W31_MIRRORED_DIGEST); + assert.equal( + r.artifact.mirror_url, + `https://${GOOD_HOST}/plugins-fixtures/sinkra-os/0.0.0-fixture/${W31_MIRRORED_DIGEST}.tar.gz`, + "the resolved URL must be the content-addressed path — the digest IS the filename", + ); + }); + + test("same digest ⇒ same bytes: local bytes are verified against the resolution, and a single flipped byte is caught", () => { + withTempDir((dir) => { + const artifact = buildValidArtifact(); + const digest = createHash("sha256").update(readFileSync(artifact)).digest("hex"); + const resolved = { digest: { algorithm: "sha256", value: digest } }; + assert.equal(verifyBytesAgainstPin(resolved, artifact).ok, true); + + const tampered = join(dir, "tampered.tar.gz"); + const bytes = Buffer.from(readFileSync(artifact)); + bytes[bytes.length - 1] ^= 0x01; + writeFileSync(tampered, bytes); + const bad = verifyBytesAgainstPin(resolved, tampered); + assert.equal(bad.ok, false); + assert.notEqual(bad.actual, bad.expected); + }); + }); + + test("an ambiguous pin is REFUSED, never tie-broken (order-dependent bytes are not a pin)", () => { + const data = { + entries: [ + { plugin_id: "dup", version: "1.0.0", digest: { algorithm: "sha256", value: "a".repeat(64) }, artifact: { mirror_url: "https://x/a" } }, + { plugin_id: "dup", version: "1.0.0", digest: { algorithm: "sha256", value: "b".repeat(64) }, artifact: { mirror_url: "https://x/b" } }, + ], + }; + assert.throws(() => resolvePin(data, "dup@1.0.0"), /DIFFERENT digests/); + }); + + // fix-cycle-1 (F5). The QG executed this: duplicates with the SAME digest were tie-broken by + // `exact[0]`, so the same pin over two index orderings returned different `mirror_url` and + // different `tiers`. `tiers` is the entitlement axis, so order deciding it is not cosmetic. + test("F5 — same-digest duplicates are REFUSED too, and the resolution is not order-dependent", () => { + const one = { plugin_id: "dup", version: "1.0.0", digest: { algorithm: "sha256", value: "a".repeat(64) }, artifact: { mirror_url: "https://x/one" }, tiers: ["base"] }; + const two = { plugin_id: "dup", version: "1.0.0", digest: { algorithm: "sha256", value: "a".repeat(64) }, artifact: { mirror_url: "https://x/two" }, tiers: ["forjar"] }; + + for (const entries of [[one, two], [two, one]]) { + assert.throws( + () => resolvePin({ entries }, "dup@1.0.0"), + (e) => { + assert.match(e.message, /REFUSED: 2 entries share this plugin_id@version/); + assert.match(e.message, /entitlement axis/, "the message must say WHY same-digest duplicates still matter"); + return true; + }, + "both orderings must refuse — before fix-cycle-1 each returned a different answer", + ); + } + }); + + // fix-cycle-2 (F13). The F5 tightening moved the RESOLVER and left the VALIDATOR behind, so an + // index could be green in CI and unresolvable in use. This test is the anti-drift device: it runs + // BOTH over the same data and asserts they agree, in both directions — so a future edit that + // loosens one without the other fails here rather than in somebody's console. + test("F13 — CI's validator and the resolver agree about duplicates, in both directions", () => { + const base = { + schema_version: "2.0.0", + plugin_id: "dup", + lineage_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + name: "dup", + description: "d", + version: "1.0.0", + digest: { algorithm: "sha256", value: "a".repeat(64) }, + artifact: { + mirror_url: `https://${GOOD_HOST}/plugins/dup/1.0.0/x.tar.gz`, + r2_key: "plugins/dup/1.0.0/x.tar.gz", + }, + publisher: { subject: "acct_test" }, + published_at: "2026-08-10T00:00:00.000Z", + license: { spdx_or_path: "MIT" }, + }; + // Same digest, differing only in `tiers` — the exact construction the QG executed. + const dupData = { schema_version: "2.0.0", entries: [{ ...base, tiers: ["base"] }, { ...base, tiers: ["forjar"] }] }; + + const validatorErrors = validateIndexData(dupData, "fixture"); + assert.ok( + validatorErrors.some((e) => /duplicate dup@1\.0\.0/.test(e)), + "the validator must refuse what the resolver refuses (it returned 0 violations before fix-cycle-2)", + ); + assert.throws(() => resolvePin(dupData, "dup@1.0.0"), /REFUSED: 2 entries share this plugin_id@version/); + + // ...and both accept the single-entry version, so the agreement is not "both refuse everything". + const okData = { schema_version: "2.0.0", entries: [{ ...base, tiers: ["base"] }] }; + assert.deepEqual(validateIndexData(okData, "fixture"), []); + assert.equal(resolvePin(okData, "dup@1.0.0").digest.value, "a".repeat(64)); + }); + + // fix-cycle-1 (F6). A missing `digest.algorithm` used to resolve as sha256 by assumption. + test("F6 — an entry with no digest.algorithm is REFUSED, not assumed to be sha256", () => { + const data = { + entries: [{ plugin_id: "noalg", version: "1.0.0", digest: { value: "a".repeat(64) }, artifact: { mirror_url: "https://x/a" } }], + }; + assert.throws(() => resolvePin(data, "noalg@1.0.0"), /no digest\.algorithm/); + // ...and the well-formed entry right next to it still resolves, so this is not a blanket refusal. + assert.equal(resolvePinFromFile(fixturesIndex, "sinkra-os@0.0.0-fixture").digest.algorithm, "sha256"); + }); + + test("an unresolvable pin says WHAT IS available instead of just failing", () => { + const data = JSON.parse(readFileSync(fixturesIndex, "utf8")); + assert.throws(() => resolvePin(data, "nope@1.0.0"), /Known plugin_ids: .*sinkra-os/); + assert.throws(() => resolvePin(data, "sinkra-os@9.9.9"), /Published versions: 0\.0\.0-fixture/); + }); +}); + +describe("AC5 — the plugin channel is separate from the binary channel, proven in BOTH directions", () => { + // The mechanism behind both directions is the same fact stated twice: `resolvePin` is a pure + // function of (index, pin). A function that cannot observe binary-channel state cannot be + // perturbed by it, and a binary update that cannot observe the index cannot be perturbed by a + // re-pin. Each direction is asserted separately anyway, because "obvious from the design" is + // exactly the kind of claim that stops being true after one edit. + + test("direction 1 — a PLUGIN can be re-pinned and updated while binary-channel state is untouched", () => { + withTempDir((dir) => { + const { target, ledger } = emptyIndexAndLedger(dir); + const lineage = "11111111-1111-4111-8111-111111111111"; + + publish({ dir, target, ledger, plugin_id: "pinme", lineage_id: lineage, version: "1.0.0", artifact: buildTarball({ LICENSE: "MIT\n", "SKILL.md": fixtureSkill("v1") }) }); + const v1 = resolvePinFromFile(target, "pinme@1.0.0"); + + publish({ dir, target, ledger, plugin_id: "pinme", lineage_id: lineage, version: "1.1.0", artifact: buildTarball({ LICENSE: "MIT\n", "SKILL.md": fixtureSkill("v2") }) }); + const v2 = resolvePinFromFile(target, "pinme@1.1.0"); + + assert.notEqual(v1.digest.value, v2.digest.value, "a new version is new bytes"); + // The old pin keeps resolving to the OLD digest — that is the pin working, and it is also + // exactly the cost AC6 documents. + assert.equal(resolvePinFromFile(target, "pinme@1.0.0").digest.value, v1.digest.value); + + // Nothing in the publish + re-pin path created or touched a binary-channel artifact. + for (const id of [".aiox-core-build", "RELEASES"]) { + assert.equal(existsSync(join(dir, id)), false, `the plugin cycle must not create ${id}`); + } + }); + }); + + test("direction 2 — the BINARY channel can change underneath and the plugin pin resolves identically", () => { + withTempDir((dir) => { + const marker = join(dir, ".aiox-core-build"); + const data = JSON.parse(readFileSync(fixturesIndex, "utf8")); + const baseline = JSON.stringify(resolvePin(data, "aiox-enterprise@0.0.0-fixture")); + + // The binary's provisioning marker moves through three distinct states — absent, one build, + // a different build — with the plugin pin resolved at each. A resolver that read binary state + // would change its answer here. + for (const content of ["sha+build-a=1111", "sha+build-b=2222"]) { + writeFileSync(marker, content); + assert.equal(JSON.stringify(resolvePin(data, "aiox-enterprise@0.0.0-fixture")), baseline); + } + unlinkSync(marker); + assert.equal(JSON.stringify(resolvePin(data, "aiox-enterprise@0.0.0-fixture")), baseline); + + // Same for anything the binary channel could plausibly export into the environment. + const saved = { ...process.env }; + try { + for (const v of ["stable", "canary", ""]) { + process.env.AIOX_UPDATE_CHANNEL = v; + process.env.AIOX_CORE_BUILD = `build-${v}`; + assert.equal(JSON.stringify(resolvePin(data, "aiox-enterprise@0.0.0-fixture")), baseline); + } + } finally { + delete process.env.AIOX_UPDATE_CHANNEL; + delete process.env.AIOX_CORE_BUILD; + Object.assign(process.env, saved); + } + }); + }); + + test("the channel-separation guard runs over the whole repo and passes", () => { + const out = execFileSync("node", [channelGuard], { stdio: "pipe" }).toString(); + assert.match(out, /OK — no executable file in this repository reads binary-channel state/); + assert.match(out, /\.aiox-core-build/, "the guard must name what it checked, not just say OK"); + }); + + // fix-cycle-1 (F4). The QG defeated the guard with one character: the exemption was line-prefix + // textual, so a real read hidden behind a leading `/*` passed while the identical read on an + // ordinary line was caught. The guard now blanks comment CONTENT and searches the code that is + // left. Both halves are pinned here — the evasion must fail, and a genuine doc-comment mention + // must still be exempt, because a fix that flagged every comment would just be a different bug. + test("F4 — a coupling hidden behind a block-comment opener is CAUGHT (it used to pass)", () => { + const planted = join(repoRoot, "scripts", "__channel-comment-evasion-fixture.mjs"); + try { + writeFileSync( + planted, + '/* probe */ import { readFileSync } from "node:fs"; const s = readFileSync(".aiox-core-build", "utf8");\nexport default s;\n', + ); + let stderr = ""; + try { + execFileSync("node", [channelGuard], { stdio: "pipe" }); + assert.fail("expected the guard to REFUSE — this is the evasion that used to exit 0"); + } catch (e) { + stderr = String(e.stderr ?? ""); + } + assert.match(stderr, /__channel-comment-evasion-fixture\.mjs/); + assert.match(stderr, /\[\.aiox-core-build\]/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + }); + + test("F4 — a genuine doc-comment MENTION is still exempt (the fix must not flag prose)", () => { + const planted = join(repoRoot, "scripts", "__channel-comment-mention-fixture.mjs"); + try { + writeFileSync( + planted, + '// This module is deliberately independent of .aiox-core-build and of velopack.\n/* Nor does it read RELEASES. */\nexport default 1;\n', + ); + const out = execFileSync("node", [channelGuard], { stdio: "pipe" }).toString(); + assert.match(out, /OK — no executable file/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + }); + + // fix-cycle-2 (F12) — a regression the F4 fix itself introduced, and the reason it is fixed rather + // than merely documented: the F4 comment CLAIMED regex confusion could only ever over-report, and + // this construction proved that false. Under the pre-F4 line-prefix logic it was caught; under the + // first blanker it passed. Both the regex recognition and the unterminated-block fallback are + // exercised by this one fixture. + test("F12 — a regex literal containing `/*` no longer hides the coupling on the next line", () => { + const planted = join(repoRoot, "scripts", "__channel-regex-evasion-fixture.mjs"); + try { + writeFileSync( + planted, + 'const re = /[/*]/;\nconst s = readFileSync(".aiox-core-build", "utf8");\nexport default [re, s];\n', + ); + let stderr = ""; + try { + execFileSync("node", [channelGuard], { stdio: "pipe" }); + assert.fail("expected the guard to REFUSE — this construction passed after the F4 fix"); + } catch (e) { + stderr = String(e.stderr ?? ""); + } + assert.match(stderr, /__channel-regex-evasion-fixture\.mjs/); + assert.match(stderr, /\[\.aiox-core-build\]/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + }); + + test("F12 — a regex literal is NOT mistaken for a comment either (no false positive from the fix)", () => { + // The opposite failure: a fix that flagged every file containing a regex would also "close" F12. + const planted = join(repoRoot, "scripts", "__channel-regex-benign-fixture.mjs"); + try { + writeFileSync(planted, 'const re = /[/*]/;\nconst ok = re.test("x");\nexport default ok;\n'); + assert.match(execFileSync("node", [channelGuard], { stdio: "pipe" }).toString(), /OK — no executable file/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + }); + + test("F4 — a `//` inside a STRING does not blank the rest of the line (the dangerous direction)", () => { + // A naive comment stripper treats the `//` in a URL as a comment opener and blanks everything + // after it — which would hide a real coupling written later on the same line. This fixture + // fails in that implementation and passes in this one. + const planted = join(repoRoot, "scripts", "__channel-url-then-coupling-fixture.mjs"); + try { + writeFileSync( + planted, + 'const u = "https://example.invalid/x"; const s = readFileSync(".aiox-core-build");\nexport default [u, s];\n', + ); + assert.throws(() => execFileSync("node", [channelGuard], { stdio: "pipe" }), /Command failed/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + }); + + test("the guard is not decorative: introducing a coupling makes it FAIL", () => { + // The negative fixture for the guard itself. Written into the repo's own scanned surface, + // executed, then removed in `finally` — the guard has to be shown catching something, or its + // green run above proves only that it ran. + const planted = join(repoRoot, "scripts", "__channel-coupling-fixture.mjs"); + try { + writeFileSync(planted, 'const marker = ".aiox-core-build";\nexport default marker;\n'); + assert.throws(() => execFileSync("node", [channelGuard], { stdio: "pipe" }), /Command failed/); + } finally { + if (existsSync(planted)) unlinkSync(planted); + } + // ...and it goes back to green once the coupling is gone. + assert.match(execFileSync("node", [channelGuard], { stdio: "pipe" }).toString(), /OK —/); + }); + + test("the channel metadata names what identifies a plugin install, and it is not a binary concept", () => { + assert.equal(CHANNEL.name, "plugin"); + assert.match(CHANNEL.what_identifies_an_install, /version \+ tier \+ digest/); + assert.match(CHANNEL.resolved_from, /index/); + assert.ok(CHANNEL.binary_channel_identifiers.includes(".aiox-core-build")); + assert.ok(CHANNEL.binary_channel_identifiers.includes("ADR-COCKPIT-UPDATE-CHANNELS")); + }); +}); + +describe("AC6 — the pin's COST travels with the resolution, in every output mode", () => { + test("the resolution object carries the cost, the fix, and the non-claim", () => { + const r = resolvePinFromFile(fixturesIndex, "sinkra-os@0.0.0-fixture"); + assert.ok(r.pin_cost, "a resolution without its cost is the 'pure gain' framing C4 measured"); + assert.match(r.pin_cost.cost, /PREVENTS AN ALREADY-INSTALLED ARTIFACT FROM BEING REPAIRED/); + assert.match(r.pin_cost.what_gives_the_capability_back, /055\.W5\.1/); + assert.equal(r.pin_cost, PIN_COST); + }); + + test("VC-3 — nothing here claims revocation exists", () => { + const rendered = renderResolution(resolvePinFromFile(fixturesIndex, "sinkra-os@0.0.0-fixture")); + assert.match(PIN_COST.not_a_revocation_claim, /NOT the same as revocation/); + assert.match(PIN_COST.not_a_revocation_claim, /O5|055\.W1\.3/); + // The rendered text says "repair", never "revoke". + assert.match(rendered, /repair/i); + assert.doesNotMatch(rendered, /\brevoked\b|\brevoking\b/i); + }); + + test("the CLI prints the cost too — a human path that shows only the benefit is the failure mode", () => { + const out = execFileSync("node", [resolveCli, "--index", fixturesIndex, "--pin", "sinkra-os@0.0.0-fixture"], { stdio: "pipe" }).toString(); + assert.match(out, /WHAT PINNING COSTS/); + assert.match(out, /055\.W5\.1/); + assert.match(out, new RegExp(W31_MIRRORED_DIGEST)); + }); + + test("the CLI verifies real bytes against the pin and refuses a mismatch", () => { + withTempDir((dir) => { + const wrong = join(dir, "wrong.tar.gz"); + writeFileSync(wrong, "not the mirrored artifact"); + assert.throws( + () => execFileSync("node", [resolveCli, "--index", fixturesIndex, "--pin", "sinkra-os@0.0.0-fixture", "--verify", wrong], { stdio: "pipe" }), + /Command failed/, + ); + }); + }); +}); diff --git a/test/publish-cli.test.mjs b/test/publish-cli.test.mjs index 3a84e41..75e265c 100644 --- a/test/publish-cli.test.mjs +++ b/test/publish-cli.test.mjs @@ -12,13 +12,31 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; +import { gunzipSync, gzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { buildTarball, buildValidArtifact, buildArtifactWithoutLicense, buildArtifactWithBuriedLicense, buildArtifactWithoutAllowedTools, buildArtifactWithExecutingSkill, fixtureSkill } from "./helpers/tarball.mjs"; +import { buildTarball, buildValidArtifact, buildArtifactWithoutLicense, buildArtifactWithBuriedLicense, buildArtifactWithoutAllowedTools, buildArtifactWithExecutingSkill, fixtureSkill, FIXTURE_SKILL } from "./helpers/tarball.mjs"; +import { + PLANTED_SECRETS, + buildArtifactWithPlantedSecret, + buildCleanArtifact, + buildArtifactWithNulPrefixedSecret, + buildArtifactWithOversizedSecret, + buildArtifactWithShadowedDuplicate, + buildArtifactWithSymlinkMember, + buildArtifactWithDirectoryShapedFileMember, + buildArtifactWithForgedUnameDirectoryMember, + buildArtifactWithHiddenAppleDoubleMember, + artifactMemberNames, + artifactMemberText, + readArtifactMembers, +} from "./helpers/secret-fixtures.mjs"; +import { SECRET_CLASSES } from "../lib/secret-rules.mjs"; +import { scanArtifact, unscannableMembers, renderScanReport, classifyMembers, tarMemberTable } from "../lib/secret-scanner.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const publishScript = join(here, "..", "publisher", "publish.mjs"); @@ -644,3 +662,585 @@ describe("055.W4.2 — `allowed-tools` mandatory + capabilities DERIVED, never d }); }); }); + +// ── story 055.W4.1 — BLOCKING secret scanning (D20(1)), AC1 + AC2 ────────────────────────────── +// +// AC2 is explicit that a per-class NEGATIVE fixture is the evidence, and that green-against-a-clean- +// package is not: "a scanner that passes verde against a clean package proves nothing — it is +// literally the failure mode this lineage already produced". So every covered class gets its own +// planted credential, pushed through the REAL CLI as a subprocess, and the assertion is on the +// REFUSAL plus on the target file being untouched. The positive control at the end is what keeps the +// whole block from being satisfiable by a gate that refuses everything. +describe("055.W4.1 — secret scanning is BLOCKING at publish (D20(1)/AC1), per-class fixtures (AC2)", () => { + test("the fixture set covers EVERY class the scanner claims — a claimed-but-unfixtured class fails here", () => { + assert.deepEqual([...new Set(PLANTED_SECRETS.map((p) => p.class))].sort(), [...SECRET_CLASSES]); + }); + + for (const planted of PLANTED_SECRETS) { + test(`AC2 — a package carrying a planted ${planted.class} is REFUSED (nonzero exit, index untouched)`, () => { + withTempDir((dir) => { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const before = readFileSync(target, "utf8"); + const manifest = writeManifest(dir); + const artifact = buildArtifactWithPlantedSecret(planted); + + let stderr = ""; + try { + execFileSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { stdio: "pipe" }); + assert.fail(`expected publish to be REFUSED for a planted ${planted.class}`); + } catch (e) { + stderr = String(e.stderr ?? ""); + } + + assert.match(stderr, /REFUSED — secret scanning found/, "the refusal must name secret scanning as the reason"); + assert.match(stderr, new RegExp(`\\[${planted.class}\\]`), "the refusal must name the CLASS that was found"); + assert.match(stderr, new RegExp(planted.where.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "the refusal must name the FILE, so it is actionable"); + assert.match(stderr, /ROTATE it/, "a credential prepared for publication is exposed whether or not the publish went through"); + assert.equal(readFileSync(target, "utf8"), before, "a REFUSED publish must not mutate the index"); + assert.equal(JSON.parse(readFileSync(ledger, "utf8")).plugins.hasOwnProperty("aiox-enterprise"), false, "nor the ledger"); + }); + }); + } + + test("AC2 POSITIVE CONTROL — the same package shape WITHOUT a credential publishes fine (not a blanket refusal)", () => { + withTempDir((dir) => { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const manifest = writeManifest(dir); + const out = publish({ + manifest, target, ledger, subject: "acct_test", artifact: buildCleanArtifact(), + "mirror-url": `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "r2-key": `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "no-push": true, + }); + assert.match(out, /^OK —/m); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 1); + }); + }); + + test("AC1 — a credential in the MANIFEST is blocking too (the manifest becomes a PUBLIC catalog entry)", () => { + withTempDir((dir) => { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const manifest = writeManifest(dir, { description: `see ${planted.render().trim()}` }); + let stderr = ""; + try { + execFileSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", buildValidArtifact(), + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { stdio: "pipe" }); + assert.fail("expected publish to be REFUSED"); + } catch (e) { + stderr = String(e.stderr ?? ""); + } + assert.match(stderr, /\[aws-access-key\] manifest\.json/); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 0); + }); + }); + + // AC3's deliverable is not a document nobody opens — it is that the limits are IN FRONT OF the + // operator on the success path, which is the only path a happy publisher ever sees. `spawnSync` is + // used because `execFileSync` returns stdout only, and the report (with its blind spots) is + // deliberately written to stderr so it cannot be swallowed by a caller piping stdout to a file. + test("AC3 — the limits are printed on a publish that SUCCEEDS, not only on a refusal", () => { + withTempDir((dir) => { + const res = spawnSync("node", [ + publishScript, + "--manifest", writeManifest(dir), "--target", writeEmptyIndex(dir), "--ledger", writeEmptyLedger(dir), + "--subject", "acct_test", "--artifact", buildCleanArtifact(), + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { encoding: "utf8" }); + + assert.equal(res.status, 0, "the clean package must publish"); + assert.match(res.stdout, /^OK —/m); + assert.match(res.stderr, /WHAT THIS SCAN CANNOT SEE/); + assert.match(res.stderr, /POINTER, NOT THE TARGET/, "limit (a) — the MCP pointer is not the target"); + assert.match(res.stderr, /OBFUSCATED OR ENCODED SECRET ESCAPES/, "limit (b)"); + assert.match(res.stderr, /Corpus: gitleaks/, "the corpus + its snapshot date are part of the honest claim"); + assert.match(res.stderr, /A CLEAN SCAN IS NOT A SECURITY VERDICT/); + }); + }); +}); + +// ── fix-cycle-1 (F2) — the two evasions the QG EXECUTED, now proven to be REFUSED ─────────────── +// +// Before this cycle each of these published with exit 0 while carrying a live-shaped AWS key, +// because an unscannable member was listed and then ignored. The disposition taken is fail-closed: +// unscannable => not publishable. These fixtures are what makes that decision provable rather than +// merely argued — see the decision site in lib/secret-scanner.mjs for the trade-off, the named cost, +// and why there is deliberately no override flag. +describe("055.W4.1 fix-cycle-1 — an UNSCANNABLE member is fail-closed (F2)", () => { + function attempt(dir, artifact) { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const before = readFileSync(target, "utf8"); + const manifest = writeManifest(dir); + const res = spawnSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { encoding: "utf8" }); + return { res, unchanged: readFileSync(target, "utf8") === before, target }; + } + + test("evasion A — a leading NUL byte (member reads as binary) no longer publishes a real credential", () => { + withTempDir((dir) => { + const { res, unchanged, target } = attempt(dir, buildArtifactWithNulPrefixedSecret()); + assert.notEqual(res.status, 0, "this exited 0 before fix-cycle-1 — that was the defect"); + assert.match(res.stderr, /REFUSED — 1 member\(s\) could NOT be scanned/); + assert.match(res.stderr, /config\/creds\.env/, "the refusal must name the member"); + assert.match(res.stderr, /NUL byte in the first 8000 bytes/, "and say WHY it could not be read"); + assert.match(res.stderr, /There is no override flag by design/); + assert.ok(unchanged, "a REFUSED publish must not mutate the index"); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 0); + }); + }); + + test("evasion B — padding past the scan cap no longer publishes a real credential", () => { + withTempDir((dir) => { + const { res, unchanged } = attempt(dir, buildArtifactWithOversizedSecret()); + assert.notEqual(res.status, 0, "this exited 0 before fix-cycle-1 — that was the defect"); + assert.match(res.stderr, /REFUSED — 1 member\(s\) could NOT be scanned/); + assert.match(res.stderr, /config\/creds\.env/); + assert.match(res.stderr, /larger than the \d+-byte scan cap/); + assert.ok(unchanged); + }); + }); + + test("fail-closed is not a blanket refusal: the clean package still publishes (positive control)", () => { + withTempDir((dir) => { + const { res } = attempt(dir, buildCleanArtifact()); + assert.equal(res.status, 0, "a package with nothing unscannable in it must still publish"); + assert.match(res.stdout, /^OK —/m); + }); + }); + + test("the refusal is on UNSCANNABLE specifically — a clean BINARY member blocks even with no credential in it", () => { + // The honest reading of the rule, stated as a test: the gate refuses because it could not look, + // not because it found something. An implementation that only refused when it happened to also + // detect a credential would be back to disclosure-instead-of-enforcement. + withTempDir((dir) => { + const artifact = buildTarball({ + LICENSE: "MIT\n", + "SKILL.md": FIXTURE_SKILL, + "assets/icon.bin": String.fromCharCode(0, 1) + "no credential whatsoever, just binary bytes", + }); + const { res } = attempt(dir, artifact); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /could NOT be scanned/); + assert.match(res.stderr, /assets\/icon\.bin/); + assert.doesNotMatch(res.stderr, /secret scanning found/, "nothing was FOUND — the refusal is about not being able to look"); + }); + }); +}); + +// ── fix-cycle-2 (F10/F11) — STRUCTURAL evasions: the archive is enumerated, not the extraction ─── +// +// Round 1 closed the evasions where the scanner said out loud it had not looked. These are the ones +// where it said nothing at all: a shadowed duplicate member (the credential ships and is recoverable +// from the published bytes) and a non-regular member (dropped before it could even be counted). One +// root fix closes both — the member table is the inventory — and both land in the fail-closed path +// built and tested in cycle 1 rather than a second mechanism. +describe("055.W4.1 fix-cycle-2 — structural members are enumerated and fail-closed (F10/F11)", () => { + function attempt(dir, artifact) { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const before = readFileSync(target, "utf8"); + const manifest = writeManifest(dir); + const res = spawnSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { encoding: "utf8" }); + return { res, unchanged: readFileSync(target, "utf8") === before, target }; + } + + test("F10 — a SHADOWED duplicate member no longer publishes, and the credential really was in the bytes", () => { + withTempDir((dir) => { + const artifact = buildArtifactWithShadowedDuplicate(); + + // The fixture is only meaningful if the credential is genuinely recoverable from the published + // artifact. Assert that FIRST, from the archive itself — otherwise a later refusal could be + // passing for the wrong reason (e.g. a fixture that never carried the secret at all). + const dumped = artifactMemberText(artifact, "./config/app.env"); + assert.match(dumped, /AKIA[A-Z2-7]{16}/, "the shadowed member must actually carry the credential"); + assert.match(dumped, /APP_ENV=production/, "...and the innocent shadow must also be present"); + + const { res, unchanged, target } = attempt(dir, artifact); + assert.notEqual(res.status, 0, "this exited 0 before fix-cycle-2 — the credential shipped silently"); + assert.match(res.stderr, /REFUSED — 1 member\(s\) could NOT be scanned/); + assert.match(res.stderr, /\[duplicate\] config\/app\.env/, "the refusal must name the shadowed path"); + assert.match(res.stderr, /appears 2 times in the archive/, "and say WHY, so it is actionable"); + assert.ok(unchanged); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 0); + }); + }); + + test("F11 — a non-regular (symlink) member is enumerated and refused, not dropped before counting", () => { + withTempDir((dir) => { + const { res, unchanged } = attempt(dir, buildArtifactWithSymlinkMember()); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /\[non-regular\] config\/outside\.env/); + assert.match(res.stderr, /member type 'l' is not a regular file/); + assert.ok(unchanged); + }); + }); + + test("F11 — the member count now means what it says: 3 members are reported as 3, not 2", () => { + // The honesty half of F11, separate from the refusal: before fix-cycle-2 a 3-member archive + // reported "2/2 file(s) scanned" — which reads as COMPLETE coverage of an archive it had not + // fully seen, in the very report AC3 makes load-bearing. + const report = scanArtifact(buildArtifactWithSymlinkMember()); + assert.equal(report.files_total, 3, "LICENSE + SKILL.md + the symlink"); + assert.equal(report.files_scanned, 2); + assert.equal(unscannableMembers(report).length, 1); + assert.match(renderScanReport(report), /2\/3 file\(s\) scanned/); + }); + + test("the positive control still publishes — enumerating from the archive is not a blanket refusal", () => { + withTempDir((dir) => { + const { res } = attempt(dir, buildCleanArtifact()); + assert.equal(res.status, 0, "an ordinary all-regular-member package must still publish"); + assert.match(res.stdout, /^OK —/m); + }); + }); + + test("directories are NOT treated as unscannable (every normal artifact contains them)", () => { + // The failure mode this guards against is the opposite of F10: a fix that refuses every archive + // would also "close" the finding, and would be useless. + const report = scanArtifact(buildCleanArtifact()); + assert.equal(unscannableMembers(report).length, 0); + assert.ok(report.files_total >= 4); + }); + + test("what the member table cannot see is DECLARED in the limits printed on every run", () => { + // The F2 lesson, applied to its own fix: an undeclared blind spot is the disqualifying kind. + const joined = scanArtifact(buildCleanArtifact()).limits.join("\n"); + assert.match(joined, /WHAT THE MEMBER TABLE ITSELF CANNOT SEE/); + assert.match(joined, /parser differential/); + assert.match(joined, /nested archive/); + }); +}); + +// Does the LOCAL tar unpack this archive at all? Measured per-run, never assumed from the platform +// name (fix-cycle-4): bsdtar unpacks the forged F14/F17 archives, GNU tar 1.34 refuses them. Both +// end in a correct fail-closed refusal, but at different stages and with different messages, and +// hardcoding either one is what turned the CI red while macOS stayed green. +function tarCanUnpack(artifact) { + const dir = mkdtempSync(join(tmpdir(), "aiox-plugins-canunpack-")); + try { + return spawnSync("tar", ["-xzf", artifact, "-C", dir], { stdio: "ignore" }).status === 0; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// ── fix-cycle-3 (F14) — the classifier is an ALLOWLIST: exemption requires positive evidence ───── +// +// AC2 requires a negative test PER CLASS, and "a member with a regular-file typeflag whose name ends +// in `/`" is a class — one that passed green for three cycles. The fixture below is the class's +// negative test. It is also the reason this cycle happened at all: a bypass proven by execution +// means a control named BLOCKING does not do what its name says. +describe("055.W4.1 fix-cycle-3 — a member that only LOOKS like a directory is refused (F14)", () => { + function attempt(dir, artifact) { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const before = readFileSync(target, "utf8"); + const manifest = writeManifest(dir); + const res = spawnSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { encoding: "utf8" }); + return { res, unchanged: readFileSync(target, "utf8") === before, target }; + } + + test("F14 — typeflag '0' + a name ending in '/' + a credential inside is REFUSED", () => { + withTempDir((dir) => { + const artifact = buildArtifactWithDirectoryShapedFileMember(); + + // The fixture only proves something if the archive is VALID and the credential is genuinely + // in the published bytes. The second engine's own attempt at this produced a damaged archive + // that yielded only NUL bytes — an unproven assertion dressed as a finding. + // + // fix-cycle-4 / CI: this precondition is now checked HERMETICALLY, against the archive's own + // decompressed bytes, instead of via `tar -xOzf `. Measured on GNU tar 1.34: that + // command prints NOTHING for a trailing-slash member name and exits 0, so the old assertion + // failed on Linux — the CI platform — while passing on macOS. It was asserting "this tar will + // hand me the member", which is not the claim. The claim is "the bytes ship", and gunzip + // proves that on every platform. + assert.match(gunzipSync(readFileSync(artifact)).toString("latin1"), /AKIA[A-Z2-7]{16}/, + "the credential must really ship inside the published bytes"); + const members = artifactMemberNames(artifact); + assert.equal(members.length, 3, "the archive must have 3 members"); + assert.ok(members.includes("./config/payload/"), "including the directory-shaped one"); + + const { res, unchanged, target } = attempt(dir, artifact); + assert.notEqual(res.status, 0, "this exited 0 for three cycles — it is the F14 bypass"); + assert.match(res.stderr, /REFUSED — 1 member\(s\) could NOT be scanned/); + // WHY THE REASON IS DERIVED AND NOT HARDCODED. The two tars disagree about this archive, and + // pretending otherwise is what broke on CI. bsdtar unpacks it, so the member reaches the + // classifier and is refused as `directory-with-data`. GNU tar REFUSES TO UNPACK IT AT ALL, so + // the archive is refused one step earlier, as `unextractable-archive`. Both are correct + // fail-closed refusals of the same archive; the invariant asserted unconditionally above is + // that it is REFUSED and the index is untouched. The CLASSIFIER itself — the thing fix-cycle-4 + // actually changed — is proven separately and hermetically, in the test below, so that CI + // enforces it rather than exercising a refusal that happens for a different reason. + if (tarCanUnpack(artifact)) { + assert.match(res.stderr, /\[directory-with-data\] config\/payload/, "the refusal must name the member"); + assert.match(res.stderr, /carries 39 bytes of data/, "and cite the evidence: a real directory carries none"); + } else { + assert.match(res.stderr, /\[unextractable-archive\] \(whole archive\)/); + } + assert.ok(unchanged, "a REFUSED publish must not mutate the index"); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 0); + }); + }); + + test("F14/F17 — the CLASSIFICATION is proven without depending on WHICH tar is installed", () => { + // THIS is the test that makes CI enforce fix-cycle-4, and it exists because of a defect the CI + // caught in the tests themselves: on GNU tar the end-to-end fixtures are refused as + // `unextractable-archive` BEFORE the classifier ever runs, so a green CI would have proven only + // that GNU tar cannot unpack the archive — a test passing for the wrong reason, which is the + // exact class this story keeps catching. The classifier is asserted directly instead. + for (const [label, build] of [ + ["F14 (typeflag '0' + trailing-slash name)", buildArtifactWithDirectoryShapedFileMember], + ["F17 (the same, plus a forged `uname`)", buildArtifactWithForgedUnameDirectoryMember], + ]) { + const table = tarMemberTable(build()); + assert.ok(table.aligned, `${label}: the header walk must enumerate cleanly`); + assert.equal(table.members.length, 3, `${label}: the member is COUNTED, not dropped`); + + // The heart of it: the header says REGULAR FILE carrying 39 bytes, on every platform, because + // these come from fixed offsets (156 and 124) and not from anyone's rendering. + const payload = table.members.find((m) => m.raw_path === "./config/payload/"); + assert.ok(payload, `${label}: the forged member must be enumerated`); + assert.equal(payload.type, "-", `${label}: typeflag at offset 156 says regular file`); + assert.equal(payload.size, 39, `${label}: size at offset 124 says 39 bytes`); + + const { readable, structural } = classifyMembers(table); + assert.deepEqual(structural.map((s) => s.kind), ["directory-with-data"], `${label}: classified as the anomaly`); + assert.match(structural[0].why, /carries 39 bytes of data/, `${label}: the evidence is cited`); + assert.equal(readable.length, 2, `${label}: the two ordinary members are still readable`); + } + }); + + test("F14 — REAL directories are still exempt (the carve-out that must not tighten)", () => { + // A fix that refused directories would also "close" F14 — and would refuse every package ever + // built with `tar -czf x.tgz -C dir .`. This is the control that keeps the inversion honest. + const report = scanArtifact(buildCleanArtifact()); + assert.equal(unscannableMembers(report).length, 0); + assert.equal(report.findings.length, 0); + assert.ok(report.files_scanned >= 4); + }); + + test("F14 — exemption requires POSITIVE evidence: a directory whose size is unknown is refused", () => { + // The allowlist property itself, independent of the trailing-slash instance: `classifyMembers` + // exempts only a member it can positively identify as a directory (rendered `d` AND size 0). + // An unverifiable claim to be a directory is unscannable, not a pass. + const table = { + aligned: true, + members: [ + { raw_path: "./", path: "", type: "d", size: 0 }, // real -> exempt + { raw_path: "./a/", path: "a", type: "d", size: null }, // size unknown -> refuse + { raw_path: "./b/", path: "b", type: "d", size: 12 }, // data -> refuse + { raw_path: "./c.txt", path: "c.txt", type: "-", size: 5 }, // ordinary -> readable + ], + }; + const { readable, structural } = classifyMembers(table); + assert.deepEqual(readable.map((m) => m.path), ["c.txt"]); + assert.deepEqual(structural.map((s) => s.kind), ["directory-with-data", "directory-with-data"]); + // fix-cycle-4 (F17): the size now comes from the ustar header at offset 124, so the message + // names its source. The PROPERTY under test is unchanged — an unverifiable claim is not a pass. + assert.match(structural[0].why, /size could not be read from the ustar header/); + assert.match(structural[1].why, /carries 12 bytes/); + }); +}); + +// ── fix-cycle-4 (F17) — classification reads the ustar HEADER, not `tar`'s rendered listing ─────── +// +// AC2 requires a negative test PER CLASS. The class here is not "a trailing-slash name" (that was +// F14) — it is "the evidence the allowlist depends on is attacker-controlled". Cycle 3 inverted the +// classifier correctly in FORM (exemption requires positive evidence) but read that evidence from +// `tar -tvzf`, a human-readable rendering whose columns are a function of attacker-supplied header +// fields. ONE forged `uname` re-exempted the member and the credential published at exit 0. +describe("055.W4.1 fix-cycle-4 — a forged header FIELD cannot move a header OFFSET (F17)", () => { + function attempt(dir, artifact) { + const target = writeEmptyIndex(dir); + const ledger = writeEmptyLedger(dir); + const before = readFileSync(target, "utf8"); + const manifest = writeManifest(dir); + const res = spawnSync("node", [ + publishScript, + "--manifest", manifest, "--target", target, "--ledger", ledger, + "--subject", "acct_test", "--artifact", artifact, + "--mirror-url", `https://${GOOD_HOST}/plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--r2-key", `plugins-fixtures/aiox-enterprise/0.0.0-fixture/x.tar.gz`, + "--no-push", + ], { encoding: "utf8" }); + return { res, unchanged: readFileSync(target, "utf8") === before, target }; + } + + test("F17 — the F14 member plus ONE forged `uname` is REFUSED", () => { + withTempDir((dir) => { + const artifact = buildArtifactWithForgedUnameDirectoryMember(); + + // Same discipline as the F14 fixture: prove the archive is VALID and the credential genuinely + // recoverable BEFORE asserting anything about the gate. A probe that produced a damaged archive + // would prove nothing, and this lineage has already been bitten by exactly that. + assert.match(gunzipSync(readFileSync(artifact)).toString("latin1"), /AKIA[A-Z2-7]{16}/, + "the credential must really ship inside the published bytes"); + const members = artifactMemberNames(artifact); + assert.equal(members.length, 3, "the archive must have 3 members"); + assert.ok(members.includes("./config/payload/"), "including the directory-shaped one"); + + const { res, unchanged, target } = attempt(dir, artifact); + assert.notEqual(res.status, 0, "this exited 0 after fix-cycle-3 — it is the F17 bypass"); + assert.match(res.stderr, /REFUSED — 1 member\(s\) could NOT be scanned/); + if (tarCanUnpack(artifact)) { + assert.match(res.stderr, /\[directory-with-data\] config\/payload/, "the refusal must name the member"); + assert.match(res.stderr, /carries 39 bytes of data/, "read from the header, not from the rendered size column"); + } else { + assert.match(res.stderr, /\[unextractable-archive\] \(whole archive\)/); + } + assert.ok(unchanged, "a REFUSED publish must not mutate the index"); + assert.equal(JSON.parse(readFileSync(target, "utf8")).entries.length, 0); + }); + }); + + test("F17 — where the forged `uname` DOES poison the rendering, the scan is no longer fooled", () => { + // Without this, the tests above could pass for the wrong reason — e.g. if the fixture simply + // failed to inject the field. It asserts the poisoned rendering EXISTS and that the scan reads + // past it. + // + // fix-cycle-4 / CI: the poisoning is a property of BSDTAR'S RENDERER, and that is the honest + // scope. bsdtar prints uname and gname as separate free-text columns, so an injected `0 Aug 1` + // lands exactly where the size column is expected. GNU tar prints `user/group` as one combined + // field, so the same bytes do NOT reproduce the bypass — measured on GNU tar 1.34, where the + // cycle-3 regex reads the true `39`. The condition is therefore detected, never assumed from a + // platform name, and skipped LOUDLY rather than failed where it cannot exist. The refusal that + // matters is proven unconditionally by the hermetic classifier test above. + const artifact = buildArtifactWithForgedUnameDirectoryMember(); + const line = execFileSync("tar", ["-tvzf", artifact], { encoding: "utf8" }) + .split("\n").find((l) => l.includes("./config/payload/")); + // The cycle-3 size regex, verbatim. + const cycle3Regex = /\s(\d+)\s+(?:[A-Z][a-z]{2}\s+\d{1,2}|\d{4}-\d{2}-\d{2})\s/; + const rendered = cycle3Regex.exec(line)?.[1]; + if (rendered !== "0") { + console.log(` ↷ SKIPPED — this tar's long listing does not reproduce the F17 poisoning (size column read as ${JSON.stringify(rendered)}, not "0"). The bypass is specific to bsdtar's separate uname/gname columns; the refusal itself is covered by the hermetic classifier test.`); + return; + } + + const report = scanArtifact(artifact); + assert.equal(report.files_total, 3); + const un = unscannableMembers(report); + assert.equal(un.length, 1); + assert.equal(un[0].kind, "directory-with-data"); + assert.match(un[0].why, /carries 39 bytes/, "the header says 39 where the rendering said 0"); + }); + + test("F17 — a member the archive's own listing HIDES is enumerated and refused (macOS trigger)", () => { + // The differential half, against the REAL trigger. macOS `tar -czf` writes an AppleDouble + // `._name` companion for every file carrying an extended attribute, and `tar -tzf` does not list + // it — so a credential stored in an xattr ships inside the artifact and appears in no listing + // this scanner has ever read. Every cycle before this one enumerated from that listing. + // + // The fixture is null off macOS (see the helper: GNU tar has no AppleDouble concept, so there is + // nothing to reproduce rather than something skipped). The refusal LOGIC is pinned for CI by the + // portable unit test below, which runs `classifyMembers` for both directions everywhere. + const artifact = buildArtifactWithHiddenAppleDoubleMember(); + if (artifact === null) return; + + // The blindness is real before anything is asserted about the gate: tar's own listing does not + // mention the member, and the credential is recoverable from the published bytes anyway. + // AQUI o `tar` externo é OBRIGATÓRIO e não pode virar leitura de header (coordenador da wave, + // 2026-08-10): a asserção é sobre a CEGUEIRA DA FERRAMENTA — que a listagem do tar NÃO admite o + // membro —, não sobre o conteúdo do arquivo. Um leitor de header cru ENXERGA o membro (é esse o + // ponto do achado), então trocá-lo aqui inverteria o que o teste prova. Este bloco só roda no + // macOS, onde o fixture existe; `buildArtifactWithHiddenAppleDoubleMember()` devolve null fora + // dele e o teste já retornou acima. + const listing = execFileSync("tar", ["-tzf", artifact], { encoding: "utf8" }); + assert.ok(!listing.includes("._LICENSE"), "the archive's own listing must not admit the member exists"); + const rawBytes = gunzipSync(readFileSync(artifact)).toString("latin1"); + assert.match(rawBytes, /AKIA[A-Z2-7]{16}/, "yet the credential ships inside the published bytes"); + + const report = scanArtifact(artifact); + const un = unscannableMembers(report); + const hidden = un.filter((u) => u.kind === "hidden-member"); + assert.ok(hidden.length >= 1, "the hidden member must be COUNTED and REFUSED, not silently dropped"); + assert.ok(hidden.some((h) => h.path.endsWith("._LICENSE")), "and named"); + assert.match(hidden[0].why, /AppleDouble/); + assert.match(hidden[0].why, /COPYFILE_DISABLE=1/, "the refusal must tell the operator how to rebuild"); + }); + + test("F17 — both directions of the parser differential are refusals, not drops", () => { + // The property in isolation, independent of any platform's tar: a member only one of the two + // parses can see is uncertifiable in either direction. Declared residual (i) said "nothing here + // detects a parser differential" — this is what changed. + const { readable, structural } = classifyMembers({ + aligned: true, + tar_only: ["ghost.txt"], + members: [ + { raw_path: "./ok.txt", path: "ok.txt", type: "-", size: 5, listed_by_tar: true }, + { raw_path: "./._LICENSE", path: "._LICENSE", type: "-", size: 163, listed_by_tar: false }, + { raw_path: "./other", path: "other", type: "-", size: 9, listed_by_tar: false }, + ], + }); + assert.deepEqual(readable.map((m) => m.path), ["ok.txt"]); + assert.deepEqual(structural.map((s) => s.kind), ["phantom-member", "hidden-member", "hidden-member"]); + assert.match(structural[1].why, /AppleDouble/, "the `._` case names the cause the operator will actually hit"); + assert.match(structural[2].why, /absent from `tar`'s own enumeration/); + }); + + test("F17 — a stream whose headers cannot be walked refuses WHOLE, naming why", () => { + // The walk's own fail-closed edge. A corrupted header must not yield invented members. + const good = buildCleanArtifact(); + const raw = gunzipSync(readFileSync(good)); + raw[124] = 0x39; // '9' — not a valid octal digit, so the size field cannot be read + const broken = join(mkdtempSync(join(tmpdir(), "aiox-plugins-brokenhdr-")), "artifact.tar.gz"); + writeFileSync(broken, gzipSync(raw)); + + const report = scanArtifact(broken); + const un = unscannableMembers(report); + assert.equal(un.length, 1); + assert.equal(un[0].path, "(whole archive)"); + // Either gate may catch it first depending on what `tar` itself makes of the damage — both are + // named, fail-closed refusals. Before this cycle an archive tar cannot unpack threw an uncaught + // exception with a stack trace instead of producing a report at all. + assert.ok(["unparseable-member-table", "unextractable-archive"].includes(un[0].kind), un[0].kind); + assert.equal(report.files_scanned, 0, "nothing may be certified from a stream that cannot be walked"); + }); + + test("F17 — the positive controls still pass: tightening must not refuse legitimate work", () => { + // This story's own named failure mode pointed at itself four times. A fix that refuses every + // package would "close" F17 and break the product. + const report = scanArtifact(buildCleanArtifact()); + assert.equal(unscannableMembers(report).length, 0); + assert.equal(report.findings.length, 0); + assert.ok(report.files_scanned >= 4); + }); +}); diff --git a/test/secret-scanner.test.mjs b/test/secret-scanner.test.mjs new file mode 100644 index 0000000..d242897 --- /dev/null +++ b/test/secret-scanner.test.mjs @@ -0,0 +1,238 @@ +// test/secret-scanner.test.mjs — story 055.W4.1 (D20(1)), function-level coverage of the scanner. +// +// The CLI-level obligation of AC2 ("a fixture with a planted secret of EACH class, REJECTED, through +// the real CLI as a subprocess") lives in test/publish-cli.test.mjs. This file covers the properties +// a subprocess test cannot show cleanly: that each fixture is invalid in exactly ONE way, that the +// entropy floor and the upstream allowlists actually do something, that a finding never carries the +// credential in clear, and that the limits are attached to every report. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + scanText, + scanArtifact, + scanManifestFile, + shannonEntropy, + redact, + renderScanReport, + SCANNER_LIMITS, + UNSCANNABLE_IS_BLOCKING, + unscannableMembers, + MAX_SCANNED_FILE_BYTES, +} from "../lib/secret-scanner.mjs"; +import { + SECRET_CLASSES, + SECRET_RULES, + DELIBERATELY_NOT_VENDORED, + SECRET_RULES_PROVENANCE, +} from "../lib/secret-rules.mjs"; +import { PLANTED_SECRETS, buildArtifactWithPlantedSecret, buildCleanArtifact } from "./helpers/secret-fixtures.mjs"; +import { buildTarball, FIXTURE_SKILL } from "./helpers/tarball.mjs"; + +describe("the corpus is vendored, attributed, and paired with fixtures", () => { + test("every covered CLASS has a planted fixture — adding a rule without a fixture fails HERE", () => { + const covered = [...new Set(PLANTED_SECRETS.map((p) => p.class))].sort(); + assert.deepEqual( + covered, + [...SECRET_CLASSES], + "AC2 is per-CLASS: a class in lib/secret-rules.mjs with no fixture is an unproven class", + ); + }); + + test("provenance is recorded (a vendored corpus that cannot be dated cannot be audited for staleness)", () => { + assert.match(SECRET_RULES_PROVENANCE.source, /gitleaks/); + assert.match(SECRET_RULES_PROVENANCE.license, /MIT/); + assert.ok( + SECRET_RULES_PROVENANCE.upstream_rule_count > SECRET_RULES.length, + "the vendored set is a SUBSET — the count proves it", + ); + assert.ok(DELIBERATELY_NOT_VENDORED.length >= 1, "what was left out is part of the deliverable, not an omission"); + assert.ok(DELIBERATELY_NOT_VENDORED.some((r) => r.id === "generic-api-key")); + }); + + test("every rule's pattern compiles as a JavaScript regex (the RE2 port is real, not assumed)", () => { + for (const r of SECRET_RULES) { + assert.doesNotThrow(() => new RegExp(r.pattern, `g${r.flags ?? ""}`), `rule ${r.id} does not compile`); + assert.ok(!r.pattern.includes("(?i)"), `rule ${r.id} still carries an RE2 inline flag`); + assert.ok(!r.pattern.includes("(?-i:"), `rule ${r.id} carries an RE2 construct with no JS equivalent`); + } + }); +}); + +describe("per-class detection — each fixture is invalid in EXACTLY ONE way", () => { + for (const planted of PLANTED_SECRETS) { + test(`${planted.class} — detected, and nothing else is`, () => { + const findings = scanText(planted.render(), planted.where); + assert.ok(findings.length >= 1, `no finding for ${planted.class}`); + const classes = [...new Set(findings.map((f) => f.class))]; + assert.deepEqual( + classes, + [planted.class], + `fixture for ${planted.class} also trips ${classes.filter((c) => c !== planted.class).join(", ")} — a test that can pass for the wrong reason is not evidence`, + ); + }); + } +}); + +describe("the entropy floor and the upstream allowlists actually do something", () => { + test("a shape-valid AWS key with degenerate entropy is NOT reported (shape alone over-fires)", () => { + const shapeOnly = `AWS_ACCESS_KEY_ID=${"AKIA"}${"AAAAAAAAAAAAAAAA"}\n`; + assert.equal(scanText(shapeOnly, "config/x.env").length, 0); + assert.ok( + shannonEntropy(`${"AKIA"}${"AAAAAAAAAAAAAAAA"}`) < 3, + "the fixture is only meaningful if it genuinely fails the floor", + ); + }); + + test("gitleaks' `.+EXAMPLE$` allowlist is honoured — AWS's own documentation sample is not a finding", () => { + assert.equal(scanText(`AWS_ACCESS_KEY_ID=${"AKIA"}${"IOSFODNN7EXAMPLE"}\n`, "README.md").length, 0); + }); + + test("gitleaks' known-fake GCP keys are allowlisted", () => { + assert.equal(scanText(`key = "${"AIza"}Syabcdefghijklmnopqrstuvwxyz1234567"\n`, "README.md").length, 0); + }); + + test("ordinary prose produces nothing (the keyword prefilter skips every rule)", () => { + assert.equal(scanText("just some ordinary prose about plugins and catalogs.\n", "docs/x.md").length, 0); + }); +}); + +describe("a finding never leaks what it found", () => { + test("the redaction keeps 4 characters and the length; the rendered report contains no raw secret", () => { + const planted = PLANTED_SECRETS.find((p) => p.class === "github-token"); + const text = planted.render(); + const raw = text.match(/ghp_[0-9a-zA-Z]{36}/)[0]; + const findings = scanText(text, planted.where); + assert.equal(findings.length, 1); + assert.ok(!findings[0].redacted.includes(raw), "the finding must not carry the credential"); + assert.match(findings[0].redacted, /^ghp_\*+ \(len \d+\)$/); + + const full = renderScanReport({ + subject: "artifact", + files_total: 1, + files_scanned: 1, + bytes_scanned: text.length, + skipped_binary: [], + skipped_too_large: [], + findings, + rules: SECRET_RULES.length, + classes: [...SECRET_CLASSES], + provenance: SECRET_RULES_PROVENANCE, + limits: [...SCANNER_LIMITS], + }); + assert.ok(!full.includes(raw), "the rendered report — which goes to CI logs — must not contain the credential"); + }); +}); + +describe("what was NOT scanned is reported, never silently dropped", () => { + test("a binary member is skipped AND listed; the report says so in words", () => { + // A NUL byte in the head is the "not text" signal. The planted credential AFTER it is what makes + // this test meaningful: the file genuinely contains a secret, and the report must say the file + // was NOT LOOKED AT rather than quietly reporting zero findings for it. + // + // The NUL is produced with String.fromCharCode, never written as a literal byte in this source: + // a source file containing a real NUL is classified as binary, and `grep -I` (which this repo's + // own CI guards use) SKIPS binary files — the test file would silently fall out of the + // secret-shape and portable-path sweeps it is supposed to be subject to. + const NUL = String.fromCharCode(0, 1); + const planted = PLANTED_SECRETS.find((p) => p.class === "github-token"); + const tar = buildTarball({ + LICENSE: "MIT\n", + "SKILL.md": FIXTURE_SKILL, + "assets/blob.bin": NUL + " binary payload\n" + planted.render(), + }); + const report = scanArtifact(tar); + assert.equal(report.findings.length, 0, "the binary member was not scanned — that is the point"); + assert.equal(report.skipped_binary.length, 1); + assert.equal(report.skipped_binary[0].path, "assets/blob.bin"); + assert.match(renderScanReport(report), /a skipped file is an UNKNOWN, not a pass/); + assert.match(renderScanReport(report), /\[binary\] {4}assets\/blob\.bin/); + + // fix-cycle-1 (F2), the library half: "not scanned" must be surfaced as a BLOCKING fact, not + // merely as a line in a report the caller is free to ignore. The CLI-level proof that this + // actually refuses a publish lives in test/publish-cli.test.mjs. + assert.equal(UNSCANNABLE_IS_BLOCKING, true); + const unscannable = unscannableMembers(report); + assert.equal(unscannable.length, 1); + assert.equal(unscannable[0].path, "assets/blob.bin"); + assert.match(unscannable[0].why, /NUL byte/); + assert.match(renderScanReport(report), /an UNKNOWN is BLOCKING/); + }); + + test("an oversized member is unscannable too, and reported with its size (the second evasion)", () => { + const planted = PLANTED_SECRETS.find((p) => p.class === "aws-access-key"); + const tar = buildTarball({ + LICENSE: "MIT\n", + "SKILL.md": FIXTURE_SKILL, + "config/creds.env": planted.render() + "#".repeat(MAX_SCANNED_FILE_BYTES + 1), + }); + const report = scanArtifact(tar); + assert.equal(report.findings.length, 0, "the oversized member was never read"); + assert.equal(report.skipped_too_large.length, 1); + const unscannable = unscannableMembers(report); + assert.equal(unscannable.length, 1); + assert.match(unscannable[0].why, /larger than the \d+-byte scan cap/); + }); + + test("a clean package has NOTHING unscannable — fail-closed cannot be satisfied by refusing everything", () => { + assert.equal(unscannableMembers(scanArtifact(buildCleanArtifact())).length, 0); + }); + + test("the limits are attached to EVERY report and name the two the story requires", () => { + const report = scanArtifact(buildCleanArtifact()); + assert.ok(report.limits.length >= 2); + const joined = report.limits.join("\n"); + assert.match(joined, /POINTER, NOT THE TARGET/, "limit (a) — the MCP pointer is not the target"); + // fix-cycle-1 (F1): this assertion used to be `/mcp\.rs:68/` — a substring so loose that it + // matched a citation naming the WRONG CRATE (`crates/aiox-cockpit/src/mcp.rs`, a file that does + // not exist) and shipped it inside the string printed on every run. The test written to keep the + // story's central honesty claim checkable could not catch an unverifiable citation, which is the + // worst possible shape for it. It now pins the FULL path. + assert.match( + joined, + /crates\/aiox-core\/src\/mcp\.rs:68/, + "limit (a) must cite the FULL, resolvable path — a reader who follows it must land on a real file", + ); + assert.doesNotMatch( + joined, + /aiox-cockpit\/src\/mcp\.rs/, + "the crate is aiox-core; aiox-cockpit has no mcp.rs (F1)", + ); + assert.match(joined, /OBFUSCATED OR ENCODED SECRET ESCAPES/, "limit (b)"); + assert.match(renderScanReport(report), /WHAT THIS SCAN CANNOT SEE/); + }); +}); + +describe("artifact + manifest are both in scope", () => { + test("a credential planted in the ARTIFACT is found", () => { + const planted = PLANTED_SECRETS.find((p) => p.class === "stripe-key"); + const report = scanArtifact(buildArtifactWithPlantedSecret(planted)); + assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].class, "stripe-key"); + }); + + test("a credential planted in the MANIFEST is found (the manifest becomes a PUBLIC index entry)", () => { + const dir = mkdtempSync(join(tmpdir(), "aiox-plugins-manifestfix-")); + try { + const p = join(dir, "manifest.json"); + const planted = PLANTED_SECRETS.find((x) => x.class === "npm-token"); + writeFileSync(p, JSON.stringify({ plugin_id: "x", description: planted.render().trim() }, null, 2)); + const report = scanManifestFile(p); + assert.equal(report.subject, "manifest"); + assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].class, "npm-token"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("the POSITIVE CONTROL: a clean package produces zero findings and a non-zero scanned count", () => { + const report = scanArtifact(buildCleanArtifact()); + assert.equal(report.findings.length, 0); + assert.ok(report.files_scanned >= 4, "a scan of zero files would also report zero findings — that is the trap"); + assert.ok(report.bytes_scanned > 0); + }); +});