diff --git a/.agents/pm/chores/pm-github-u5qc.toon b/.agents/pm/chores/pm-github-u5qc.toon new file mode 100644 index 0000000..715a174 --- /dev/null +++ b/.agents/pm/chores/pm-github-u5qc.toon @@ -0,0 +1,20 @@ +id: pm-github-u5qc +title: Consume the canonical attestation gate instead of carrying a copy of it +description: "This repository carried its own 837-line shell scanner and 426-line verifier, duplicated from the same origin as every other package in the fleet. That gate decides whether an artefact may reach the registry, so a false pass is the failure that matters. The canonical implementation (pm-ops/attestation and pm-ops/shell-scan) has had fifteen fail-open constructions closed in it; the vendored copy is frozen and still admits constructions closed after that. BEFORE check.sh: 11 wrong. AFTER target: nonliteral-overwrite,nonliteral-overwrite-cmdsub only (plus any new corpus cases open in canonical)." +type: Chore +status: closed +priority: 1 +tags[2]: attestation,security +created_at: "2026-09-07T10:05:34.490Z" +updated_at: "2026-09-07T10:31:52.885Z" +closed_at: "2026-09-07T10:16:57.788Z" +completed_at: "2026-09-07T10:16:57.788Z" +claim_principal: pi-agent +author: pi-agent +acceptance_criteria: Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model +comments[3]{created_at,author,text}: + "2026-09-07T10:16:40.082Z",pi-agent,"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7." + "2026-09-07T10:28:17.727Z",claude,"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass." + "2026-09-07T10:31:52.885Z",claude,"Greptile's rule about unreproduced behaviour claims caught a claim that was not merely unreproduced but WRONG. The launcher docstring said the auditor 'treats any file whose first two bytes are a shebang as executable shell'. Reproduced against the real auditor, that is false: a shebang says a file executes, not that it executes AS shell, and only a shebang naming a shell interpreter makes the body shell input.\n\nMeasured by writing this file into a throwaway git repository under four shebangs and calling verify():\n #!/bin/bash -> the file is reported by name (scanned as shell)\n #!/usr/bin/env sh -> reported by name\n #!/usr/bin/env node -> NOT reported (not shell input)\n no shebang -> NOT reported, which is why this file has none\n\nThe same wrong sentence is present in all seven repositories carrying this launcher, which is what propagating a fleet change by copying a reference file produces: the error travels with the text. Corrected here and in the five sibling convergence branches; pm-csv already has it on main and needs the same correction separately.\n\nThe claim is now reproduced in the suite rather than asserted." +close_reason: "Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)." +body: "" diff --git a/.agents/pm/history/pm-github-u5qc.jsonl b/.agents/pm/history/pm-github-u5qc.jsonl new file mode 100644 index 0000000..b15abf6 --- /dev/null +++ b/.agents/pm/history/pm-github-u5qc.jsonl @@ -0,0 +1,7 @@ +{"ts":"2026-09-07T10:05:34.490Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-u5qc"},{"op":"add","path":"/metadata/title","value":"Consume the canonical attestation gate instead of carrying a copy of it"},{"op":"add","path":"/metadata/description","value":"This repository carried its own 837-line shell scanner and 426-line verifier, duplicated from the same origin as every other package in the fleet. That gate decides whether an artefact may reach the registry, so a false pass is the failure that matters. The canonical implementation (pm-ops/attestation and pm-ops/shell-scan) has had fifteen fail-open constructions closed in it; the vendored copy is frozen and still admits constructions closed after that. BEFORE check.sh: 11 wrong. AFTER target: nonliteral-overwrite,nonliteral-overwrite-cmdsub only (plus any new corpus cases open in canonical)."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["attestation","security"]},{"op":"add","path":"/metadata/created_at","value":"2026-09-07T10:05:34.490Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-09-07T10:05:34.490Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f382ed69201a76e63eacd2f3b1e39417c05a1515fd4b24ac81557cf76e088980","item_hash_version":3,"message":"","event_class":"substantive","record_hash_version":1,"record_hash":"3a0fc37fa19d84480df28a9978c0790e49c175ead4bde5bf8d76e303d96ccd7b"} +{"ts":"2026-09-07T10:05:41.772Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:05:41.772Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"f382ed69201a76e63eacd2f3b1e39417c05a1515fd4b24ac81557cf76e088980","after_hash":"4cfe822bbf65e10c1064dc780ab18cd1d71720a178a685dc95cc1471587afab5","item_hash_version":3,"message":"Starting convergence: delete vendored scanner, rewrite attestation gate as thin launcher over pm-ops/attestation","event_class":"substantive","record_hash_version":1,"record_hash":"4e58a9e766385e57a985bf00bc8b32725ef3571d205ed98b757929dcdd5643bd"} +{"ts":"2026-09-07T10:05:41.813Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:05:41.813Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"4cfe822bbf65e10c1064dc780ab18cd1d71720a178a685dc95cc1471587afab5","after_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","item_hash_version":3,"message":"Starting convergence: delete vendored scanner, rewrite attestation gate as thin launcher over pm-ops/attestation","event_class":"substantive","record_hash_version":1,"record_hash":"59049802ce12a9804f5eaa7e647df73710d19fb2b7925d77f826a46456716bd1"} +{"ts":"2026-09-07T10:16:40.082Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:40.082Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-09-07T10:16:40.082Z","author":"pi-agent","text":"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7."}]}],"before_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","after_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"0039443765892326b26a2c4029353a156534676a7bc9e2f130166a27c3dd84f7"} +{"ts":"2026-09-07T10:16:57.814Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:57.814Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/close_reason","value":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)."}],"before_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","after_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","item_hash_version":3,"message":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass).","event_class":"substantive","record_hash_version":1,"record_hash":"532eaf60807c289d1ad69e934ebccc45fdfc86b481271d6a68c9d090f1186d53"} +{"ts":"2026-09-07T10:28:17.727Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"ac5fbc41d1fa70d2dee4261b","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.263","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-09-07T10:28:17.727Z","author":"claude","text":"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:28:17.727Z"}],"before_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","after_hash":"5500c1a301563b8e24b01a594f9fc588c8aab42b25b1273002afbe13d7cfcda6","item_hash_version":3,"message":"review round: the convergence guard did not guard","event_class":"substantive","record_hash_version":1,"record_hash":"3bd53e5f71444b344e4a03a8d55f26df2e26a6c36aa502aca88baddf0dab9f8b"} +{"ts":"2026-09-07T10:31:52.885Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"ac5fbc41d1fa70d2dee4261b","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.263","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-09-07T10:31:52.885Z","author":"claude","text":"Greptile's rule about unreproduced behaviour claims caught a claim that was not merely unreproduced but WRONG. The launcher docstring said the auditor 'treats any file whose first two bytes are a shebang as executable shell'. Reproduced against the real auditor, that is false: a shebang says a file executes, not that it executes AS shell, and only a shebang naming a shell interpreter makes the body shell input.\n\nMeasured by writing this file into a throwaway git repository under four shebangs and calling verify():\n #!/bin/bash -> the file is reported by name (scanned as shell)\n #!/usr/bin/env sh -> reported by name\n #!/usr/bin/env node -> NOT reported (not shell input)\n no shebang -> NOT reported, which is why this file has none\n\nThe same wrong sentence is present in all seven repositories carrying this launcher, which is what propagating a fleet change by copying a reference file produces: the error travels with the text. Corrected here and in the five sibling convergence branches; pm-csv already has it on main and needs the same correction separately.\n\nThe claim is now reproduced in the suite rather than asserted."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:31:52.885Z"}],"before_hash":"5500c1a301563b8e24b01a594f9fc588c8aab42b25b1273002afbe13d7cfcda6","after_hash":"5ee745cb7cf27a05deab1f8cfed0a72f3d6a9d360e529c2afdf55bcf74b14aca","item_hash_version":3,"message":"correct a wrong shebang claim, reproduced","event_class":"substantive","record_hash_version":1,"record_hash":"331d5d45621c58dd7a2dc2075416ed74d81877473f039bafabd01ad83365b882"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8e12d..74a7310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Security + +- Consume the canonical attestation gate instead of carrying a copy of it ([pm-github-u5qc](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-u5qc.toon)) + ## 2026.9.6 - 2026-09-06 ### Fixed diff --git a/package-lock.json b/package-lock.json index 3a499ba..f9db44b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.9.5", "pm-changelog": "2026.9.2", - "pm-ops": "^2026.9.5", + "pm-ops": "^2026.9.7", "typescript": "^7.0.2" }, "engines": { @@ -998,9 +998,9 @@ } }, "node_modules/pm-ops": { - "version": "2026.9.5", - "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.9.5.tgz", - "integrity": "sha512-+x6p+Byx22NqwIhWp9ThZ+N0dv12yJQDtdooiEh0k/uFrAhV/uHkGSCXMqXUK03XCDhxrgXDbLoBOVWwMzN4bg==", + "version": "2026.9.7", + "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.9.7.tgz", + "integrity": "sha512-fU+j8c/r4zmo4WMJBrifeZQxEUwtQPoQe1jLCgh2/MXQog0eVJEChRFgCv4zwNWEcSVFBz7vr5AMUrv0jov7SA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d01c419..26ea24b 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.9.5", "pm-changelog": "2026.9.2", - "pm-ops": "^2026.9.5", + "pm-ops": "^2026.9.7", "typescript": "^7.0.2" }, "keywords": [ diff --git a/scripts/shell-command-scan.ts b/scripts/shell-command-scan.ts deleted file mode 100644 index 84e7e00..0000000 --- a/scripts/shell-command-scan.ts +++ /dev/null @@ -1,837 +0,0 @@ -/** - * Tokenises shell text into the commands it would actually run. - * - * A guard that decides "does any publish here omit `--provenance`" is only as - * good as its idea of what a command is. The previous scan answered that - * question with a regular expression: it blanked every quoted span so an - * advisory `echo "npm publish"` could not read as an invocation, then split the - * remainder on `&&`, `||`, `;` and a space-surrounded `|`. - * - * Both halves of that shortcut are wrong in the same direction -- they make the - * gate report a pass it has not earned: - * - * - Blanking quoted spans deletes the argument being audited. `npm publish - * "--provenance"` runs with an attestation but scans as one without, and the - * reverse case is worse: `eval "npm publish"` and `bash -c 'npm publish'` are - * real unattested publishes that vanish entirely, leaving a conventional - * attested sibling elsewhere in the file to carry the audit to green. - * - Splitting on three operators misses a backgrounding `&`, a pipe written - * without surrounding spaces (`true|npm publish`), and command substitution. - * - * So the text is tokenised the way a shell does it -- quotes resolved rather - * than erased, operators recognised as operators, `$(...)`, backticks, `eval` - * and `sh -c` payloads recursed into -- and each command records whether its - * words were quoted. Nothing downstream has to guess. - * - * This is deliberately not a shell. It does not expand variables, globs or - * arithmetic, and it does not track redirections. It exists to enumerate - * candidate command invocations for auditing, where missing one is a security - * failure and inventing one is merely noise. - * - * @packageDocumentation - */ - -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -/** One word of a command, after quote resolution. */ -export interface ShellToken { - /** The word's text with its quoting removed. */ - value: string; - /** True when any part of the word came from inside quotes. */ - quoted: boolean; - /** - * True when the word's FIRST character came from inside quotes. - * - * `quoted` alone cannot tell an assignment apart from a literal that merely - * looks like one. `NPM_CONFIG_REGISTRY="https://example"` is a real - * assignment whose value happens to be quoted, while `"FOO=bar"` is a single - * quoted word that the shell does not treat as an assignment at all. Both set - * `quoted`; only the second starts inside quotes. - */ - startsQuoted: boolean; -} - -/** One simple command: the words it would run, in order. */ -export type ShellCommand = ShellToken[]; - -/** - * Words that precede a command without being the command. - * - * `env FOO=bar npm publish` runs npm, not env, so a scan that reads the first - * word as the command name would classify it as an `env` invocation and let the - * publish through unaudited. - * - * The package runners (`npx`, `bunx`, `pnpx`) belong here for the same reason, - * and they bring their own options: `npx --yes npm publish` runs npm behind two - * words, not one. Option words following a prefix are therefore skipped too -- - * see `skipCommandPrefix`, which is where that rule is applied and bounded. - * - * Runners spelled as two words live in `TWO_WORD_PREFIXES` instead, because - * their head word is only a wrapper in combination with the word after it. - */ -const COMMAND_PREFIXES = new Set([ - "env", - "exec", - "nohup", - "command", - "builtin", - "sudo", - "doas", - "nice", - "ionice", - "time", - "stdbuf", - "setsid", - "xargs", - "npx", - "bunx", - "pnpx", - // Shell keywords introduce a command rather than being one. `if npm publish` - // runs npm; a scan that reads `if` as the program audits nothing. - "if", - "then", - "else", - "elif", - "while", - "until", - "do", - "!", - "{", - "(", -]); - -/** - * Wrappers spelled as two words, mapped to the second word that completes them. - * - * `pnpm dlx npm publish` runs npm, but `pnpm publish` runs pnpm's own publish - * and `pnpm install` runs no wrapper at all. Consuming the head word - * unconditionally would therefore re-point an unrelated `pnpm` command at its - * first argument, so the pair is only consumed when the second word matches. - */ -const TWO_WORD_PREFIXES = new Map([ - ["npm", new Set(["exec", "x"])], - ["pnpm", new Set(["dlx", "exec"])], - ["yarn", new Set(["dlx", "exec"])], - ["bun", new Set(["x", "run"])], -]); - -/** - * Reduce a program word to the name it runs. - * - * `/usr/local/bin/npm publish` runs npm, so a check against the whole word - * would miss it. `String.prototype.split` always yields at least one element, - * including for the empty string, so no fallback is needed or reachable here. - * - * @param word - The program word as written. - * @returns The final path segment. - */ -function basename(word: string): string { - const segments = word.split("/"); - return segments[segments.length - 1]!; -} - -/** Commands whose string argument is itself shell text to be scanned. */ -const SHELL_EVALUATORS = new Set(["eval", "bash", "sh", "dash", "zsh", "ksh"]); - -/** True when the character ends a word outside of quotes. */ -function isOperatorStart(character: string): boolean { - return character === ";" - || character === "&" - || character === "|" - || character === "\n" - || character === "(" - || character === ")" - || character === "{" - || character === "}"; -} - -/** - * Read a `$(...)` or backtick substitution and return its inner text. - * - * Nesting is counted so `$(echo $(npm publish))` yields the whole inner body - * rather than stopping at the first `)`; a truncated body would drop the - * invocation it contains. - * - * @param text - The full text being scanned. - * @param start - Index of the character that opens the substitution. - * @returns The inner text and the index just past the closing delimiter. - */ -function readSubstitution(text: string, start: number): { inner: string; end: number } { - if (text[start] === "`") { - const close = text.indexOf("`", start + 1); - if (close === -1) return { inner: text.slice(start + 1), end: text.length }; - return { inner: text.slice(start + 1, close), end: close + 1 }; - } - // A parenthesis inside quotes is a literal, not a delimiter. Counting it - // closes the substitution early and truncates the body, so - // `$(echo ")" && npm publish)` loses the publish entirely. - let depth = 1; - let index = start + 2; - let single = false; - let double = false; - while (index < text.length && depth > 0) { - const character = text[index]!; - if (character === "\\") index += 2; - else { - // Quote state is bounded to one line. A workflow's prose carries - // apostrophes -- "GitHub's", "workflow's" -- inside double-quoted - // messages, and letting an unbalanced one persist across lines makes - // every later parenthesis look quoted, so the substitution runs on and - // swallows unrelated commands. - if (character === "\n") { single = false; double = false; } - else if (character === "'" && !double) single = !single; - else if (character === '"' && !single) double = !double; - else if (!single && !double && character === "(") depth += 1; - else if (!single && !double && character === ")") depth -= 1; - if (depth === 0) break; - index += 1; - } - } - return { inner: text.slice(start + 2, index), end: index + 1 }; -} - -/** - * Split shell text into the simple commands it contains. - * - * Command substitutions are scanned as well as the command containing them, - * because `VERSION=$(npm publish)` runs a publish however unusual that is, and a - * gate that only looked at the outer assignment would miss it. - * - * `eval`, `bash -c` and their siblings receive the same treatment one level - * deeper: their string argument is re-tokenised, so a publish smuggled through - * an interpreter is enumerated alongside a plain one. Recursion is bounded -- - * shell text that nests evaluators more than a handful of levels deep is not - * something this repository writes, and an unbounded walk over hostile input is - * a denial of service rather than a stronger audit. - * - * @param text - Shell text, typically one file or one manifest script body. - * @param depth - Current evaluator recursion depth; callers pass nothing. - * @returns Every simple command found, outermost first. - */ -export function tokenizeCommands(text: string, depth = 0): ShellCommand[] { - if (depth > 8) return []; - const commands: ShellCommand[] = []; - const nested: string[] = []; - let command: ShellCommand = []; - let value = ""; - let quoted = false; - let startsQuoted = false; - let started = false; - - const endWord = (): void => { - if (!started) return; - command.push({ value, quoted, startsQuoted }); - value = ""; - quoted = false; - startsQuoted = false; - started = false; - }; - const endCommand = (): void => { - endWord(); - if (command.length > 0) commands.push(command); - command = []; - }; - - for (let index = 0; index < text.length; index += 1) { - const character = text[index]!; - if (character === "#" && !started) { - const newline = text.indexOf("\n", index); - index = newline === -1 ? text.length : newline; - endCommand(); - continue; - } - if (character === "\\") { - const next = text[index + 1]; - index += 1; - if (next === undefined) break; - if (next === "\n") continue; - value += next; - if (!started) startsQuoted = false; - started = true; - continue; - } - if (character === "'") { - const close = text.indexOf("'", index + 1); - const end = close === -1 ? text.length : close; - value += text.slice(index + 1, end); - quoted = true; - if (!started) startsQuoted = true; - started = true; - index = end; - continue; - } - if (character === '"') { - index += 1; - while (index < text.length && text[index] !== '"') { - const inner = text[index]!; - if (inner === "\\") { - const next = text[index + 1]; - if (next !== undefined) { - if (next !== "\n") value += next; - index += 2; - continue; - } - index += 1; - continue; - } - if (inner === "`" || (inner === "$" && text[index + 1] === "(")) { - const { inner: body, end } = readSubstitution(text, index); - nested.push(body); - index = end; - continue; - } - value += inner; - index += 1; - } - quoted = true; - if (!started) startsQuoted = true; - started = true; - continue; - } - if (character === "`" || (character === "$" && text[index + 1] === "(")) { - const { inner, end } = readSubstitution(text, index); - nested.push(inner); - index = end - 1; - if (!started) startsQuoted = false; - started = true; - continue; - } - if (character === " " || character === "\t" || character === "\r") { - endWord(); - continue; - } - if (isOperatorStart(character)) { - // `2>&1` is one redirection, not a command ended by a backgrounding `&`. - // The `&` belongs to the word only while that word is still an operator - // awaiting its target. - if (character === "&" && /^[0-9]*[<>]>?$/.test(value)) { - value += character; - started = true; - continue; - } - endCommand(); - continue; - } - value += character; - if (!started) startsQuoted = false; - started = true; - } - endCommand(); - - for (const body of nested) commands.push(...tokenizeCommands(body, depth + 1)); - for (const found of [...commands]) { - const name = commandName(found); - if (name === undefined || !SHELL_EVALUATORS.has(name)) continue; - // The shell joins an evaluator's words with a space and evaluates the - // result, so `eval "npm pub" "lish"` runs a publish that scanning each - // argument on its own never sees. - const payload = found.slice(1) - .filter((argument) => !argument.value.startsWith("-")) - .map((argument) => argument.value); - for (const body of new Set([...payload, payload.join(" ")])) { - commands.push(...tokenizeCommands(body, depth + 1)); - } - } - return commands; -} - -/** - * True when an unquoted word is a redirection operator rather than a command word. - * - * A redirection and its target are not part of the command the shell runs, so - * `> /dev/null npm publish` runs npm. A scan that reads words in order sees `>` - * as the program and audits nothing. The forms accepted here are the ones a - * workflow actually writes: the plain operators, a file-descriptor prefix - * (`2>`, `2>>`), the duplicating forms (`>&`, `2>&1`, `&>`), and the read-write - * form `<>`. `<>` has to be named explicitly: it is not `<` followed by `>`, so - * without it the operator was read as a joined redirection that consumes no - * target, its target `/dev/null` became the command word, and the real - * `npm publish` after it was never audited. - * - * @param token - One command word. - * @returns True when the word is a redirection operator. - */ -function isRedirection(token: ShellToken): boolean { - if (token.startsQuoted) return false; - return /^(?:[0-9]*(?:>>?|<>|<>?)$/.test(token.value); -} - -/** - * Drop a command's redirections, so only the words it runs remain. - * - * An operator written apart from its target (`> file`) consumes the word after - * it; one written joined to it (`>file`, `2>&1`) consumes nothing further. - * - * @param command - One simple command's tokens. - * @returns The command without its redirections. - */ -function withoutRedirections(command: ShellCommand): ShellCommand { - const kept: ShellCommand = []; - for (let index = 0; index < command.length; index += 1) { - const token = command[index]!; - if (!isRedirection(token)) { - // A joined form such as `>file` or `2>&1` is one word and takes no target. - if (!token.startsQuoted && /^(?:[0-9]*>>?|[0-9]*<>?)[^\s]/.test(token.value)) continue; - kept.push(token); - continue; - } - // A bare operator takes the next word as its target. - if (!/&[0-9-]$/.test(token.value)) index += 1; - } - return kept; -} - -/** - * Walk past the words that precede the program a command runs. - * - * Three kinds of word are not the program: a leading `NAME=value` assignment, a - * wrapper listed in `COMMAND_PREFIXES`, and -- only once a wrapper has been - * seen -- that wrapper's own options. The last rule is what reaches the publish - * in `npx --yes npm publish`; it stays behind the wrapper condition so that a - * command whose own first word is an option is still reported as written rather - * than silently re-pointed at one of its arguments. - * - * An option's separate value (`sudo -u root npm publish`) is not skipped, - * because which options take a value differs per wrapper, and guessing wrong - * would move the reported program rather than merely widen the search. - * - * @param command - One simple command's tokens. - * @returns The index of the program word, or the command's length when there is none. - */ -function skipCommandPrefix(command: ShellCommand): number { - let index = 0; - let sawPrefix = false; - while (index < command.length) { - const token = command[index]!; - if (!token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)) { - index += 1; - continue; - } - const base = basename(token.value); - if (COMMAND_PREFIXES.has(base)) { - sawPrefix = true; - index += 1; - continue; - } - const second = command[index + 1]; - if (second !== undefined && TWO_WORD_PREFIXES.get(base)?.has(second.value) === true) { - sawPrefix = true; - index += 2; - continue; - } - if (sawPrefix && !token.startsQuoted && token.value.startsWith("-")) { - index += 1; - continue; - } - // A YAML key carries the command as its value: `run: npm publish` runs npm, - // and reading `run:` as the program audits nothing. Workflow files are - // scanned as raw text, so the key is a word like any other. Only a leading - // key is consumed, and only one, so an argument that merely ends in a colon - // is untouched. - // A YAML list marker precedes the key on the same line: `- run: npm publish`. - if (index === 0 && !token.startsQuoted && token.value === "-") { - sawPrefix = true; - index += 1; - continue; - } - if (index <= 1 && !token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_-]*:$/.test(token.value)) { - sawPrefix = true; - index += 1; - continue; - } - return index; - } - return index; -} - -/** - * Name the program a command runs, or nothing when it runs none. - * - * Leading `NAME=value` assignments and wrapper words are skipped, and a path is - * reduced to its basename so `/usr/local/bin/npm publish` is recognised. The - * distinction this exists to draw is command *position*: `echo npm publish` - * prints three words and publishes nothing, while the previous scan searched - * the whole line for the word `npm` and counted it as an invocation. - * - * @param command - One simple command's tokens. - * @returns The program's basename, or undefined for an empty or assignment-only command. - */ -export function commandName(input: ShellCommand): string | undefined { - const command = withoutRedirections(input); - const token = command[skipCommandPrefix(command)]; - return token === undefined ? undefined : basename(token.value); -} - -/** - * Enumerate every reading of a command that could name a program. - * - * `commandName` answers "what does this command run" and answers it once. That - * is right for reporting and wrong for auditing, because a wrapper's options - * are not all known: `sudo -u root npm publish` stops at `root`, since `-u` - * takes a value and nothing here knows that. Enumerating the value-taking - * options of every wrapper would be a list that silently goes stale, and each - * omission is a publish that disappears from the audit. - * - * So once a wrapper has been consumed, every later word is also offered as a - * possible program, with the words after it as its arguments. An auditor asking - * "does any publish here lack an attestation" then cannot miss one behind a - * wrapper option it has never heard of. - * - * The cost is noise, never a miss: `sudo -u npm publish` -- a user actually - * named `npm` -- is offered as a publish that no shell would run. For a gate - * whose failure mode is an unattested release, a spurious finding an operator - * dismisses is the cheaper error. - * - * A command with no wrapper yields exactly one reading, so ordinary commands - * are unaffected. - * - * @param command - One simple command's tokens. - * @returns Each candidate reading, the command's own first. - */ -export function commandCandidates(input: ShellCommand): ShellCommand[] { - const command = withoutRedirections(input); - const start = skipCommandPrefix(command); - const candidates: ShellCommand[] = []; - if (start < command.length) candidates.push(command.slice(start)); - if (start === 0) return candidates; - for (let index = start + 1; index < command.length; index += 1) { - const token = command[index]!; - if (token.value.startsWith("-")) continue; - candidates.push(command.slice(index)); - } - return candidates; -} - -/** - * List a command's arguments -- everything after its program name. - * - * @param command - One simple command's tokens. - * @returns The argument tokens, in order. - */ -export function commandArguments(input: ShellCommand): ShellToken[] { - const command = withoutRedirections(input); - return command.slice(skipCommandPrefix(command) + 1); -} - -/** A tracked file's path and contents. */ -export interface SourceFile { - /** Repository-relative path. */ - file: string; - /** File contents. */ - text: string; -} - -/** - * Collapse shell and YAML line continuations so one logical command is one string. - * - * A backslash at end of line joins the next line; without this every multi-line - * invocation looks like a set of fragments, none of which carries both the - * version input and the date flag. - * - * @param text - Raw file contents. - * @returns The same text with continuations joined. - */ -export function joinContinuations(text: string): string { - return text.replace(/\\\r?\n\s*/g, " "); -} - -/** - * Index bash array assignments so a shared options array can be expanded. - * - * The release workflows declare `common=( ... )` once and pass `"${common[@]}"` - * to each invocation, precisely so the invocations cannot drift. A scan that - * reads only the invocation line therefore sees none of the shared flags. - * - * @param text - File contents with continuations already joined. - * @returns Array name mapped to the flag text it holds. - */ -export function bashArrays(text: string): Map { - const arrays = new Map(); - for (const match of text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=\(([\s\S]*?)\)/g)) { - arrays.set(match[1], match[2].replace(/\s+/g, " ").trim()); - } - return arrays; -} - -/** One literal assignment at the current position in an assignment-only command. */ -const LITERAL_ASSIGNMENT = - /^(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"((?:\\.|[^"\\$`])*)"|'([^']*)'|((?:\\.|[^\s;&|"'`$()\\])+))/; - -/** True when the line's outer command consists only of assignment words. */ -function isAssignmentOnlyLine(line: string): boolean { - if (/\|&|(^|[^|&])(?:\||&)(?![|&])/.test(line)) return false; - const parsed = tokenizeCommands(line)[0]; - if (parsed === undefined) return false; - const outer = withoutRedirections(parsed); - const words = outer[0]?.value === "export" ? outer.slice(1) : outer; - return words.length > 0 && words.every((token) => - !token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)); -} - -/** Length of the first raw shell word, including quoted substitutions. */ -function shellWordLength(text: string): number { - let single = false; - let double = false; - let depth = 0; - for (let index = 0; index < text.length; index += 1) { - const char = text[index]!; - if (char === "\\" && !single) { - index += 1; - continue; - } - if (char === "'" && !double) single = !single; - else if (char === '"' && !single) double = !double; - else if (!single && char === "$" && text[index + 1] === "(") { - depth += 1; - index += 1; - } else if (!single && char === ")" && depth > 0) depth -= 1; - else if (!single && !double && depth === 0 && /\s/.test(char)) return index; - } - return text.length; -} - -/** Parse every persistent literal binding at the start of one physical line. */ -function scalarAssignments(line: string): Array<[string, string]> { - const assignments: Array<[string, string]> = []; - const assignmentOnly = isAssignmentOnlyLine(line); - let rest = line.replace(/^[ \t]*/, ""); - if (/^(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*=\(/.test(rest)) return []; - while (rest.length > 0) { - if (/^(?:[;#]|\r?$)/.test(rest)) return assignments; - if (/^(?:\d*)?(?:<>|>>?|<)/.test(rest)) return assignmentOnly ? assignments : []; - const assignment = LITERAL_ASSIGNMENT.exec(rest); - if (assignment === null) { - if (!assignmentOnly) return []; - rest = rest.slice(shellWordLength(rest)).replace(/^[ \t]*/, ""); - continue; - } - const after = rest.slice(assignment[0].length); - const boundary = after.length === 0 || /^[ \t\r;#]/.test(after); - if (!boundary) { - if (!assignmentOnly) return []; - rest = rest.slice(shellWordLength(rest)).replace(/^[ \t]*/, ""); - continue; - } - const raw = assignment[2] ?? assignment[3] ?? assignment[4]!; - const value = assignment[3] === undefined ? raw.replace(/\\(.)/g, "$1") : raw; - if (!/[$`"'()]/.test(value)) assignments.push([assignment[1]!, value]); - rest = after.replace(/^[ \t]*/, ""); - } - return assignments; -} - -/** Split one line at unquoted top-level sequencing operators, retaining them. */ -function shellSegments(line: string): string[] { - const segments: string[] = []; - let start = 0; - let single = false; - let double = false; - let depth = 0; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]!; - if (char === "\\" && !single) index += 1; - else if (char === "'" && !double) single = !single; - else if (char === '"' && !single) double = !double; - else if (!single && char === "$" && line[index + 1] === "(") { - depth += 1; - index += 1; - } else if (!single && char === ")" && depth > 0) depth -= 1; - else if (!single && !double && depth === 0 && char === "#" && - (index === 0 || /\s/.test(line[index - 1]!))) break; - else if (!single && !double && depth === 0 && (char === ";" || - ((char === "&" || char === "|") && line[index + 1] === char))) { - const width = char === ";" ? 1 : 2; - segments.push(line.slice(start, index), line.slice(index, index + width)); - start = index + width; - index += width - 1; - } - } - segments.push(line.slice(start)); - return segments; -} - -/** Return every syntactic, unquoted heredoc terminator opened on a command line. */ -function heredocTerminators(line: string): Array<{ delimiter: string; stripTabs: boolean }> { - const found: Array<{ delimiter: string; stripTabs: boolean }> = []; - let single = false; - let double = false; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]!; - if (char === "\\" && !single) { - index += 1; - continue; - } - if (char === "'" && !double) { - single = !single; - continue; - } - if (!single && ((char === "$" && line[index + 1] === "(" && line[index + 2] === "(") || - (!double && char === "(" && line[index + 1] === "("))) { - const close = line.indexOf("))", index + (char === "$" ? 3 : 2)); - if (close !== -1) index = close + 1; - continue; - } - if (double && char === "$" && line[index + 1] === "(") { - found.push(...heredocTerminators(line.slice(index + 2))); - } - if (char === '"' && !single) { - double = !double; - continue; - } - if (single || double) continue; - if (char === "#" && (index === 0 || /\s/.test(line[index - 1]!))) return found; - if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") continue; - let cursor = index + 2; - const stripTabs = line[cursor] === "-"; - if (stripTabs) cursor += 1; - while (line[cursor] === " " || line[cursor] === "\t") cursor += 1; - const quote = line[cursor] === "'" || line[cursor] === '"' ? line[cursor++] : undefined; - const start = cursor; - if (quote !== undefined) { - while (cursor < line.length && line[cursor] !== quote) cursor += 1; - } else { - while (cursor < line.length && /[^\s;&|<>()]/.test(line[cursor]!)) cursor += 1; - } - if (cursor > start && (quote === undefined || line[cursor] === quote)) { - found.push({ delimiter: line.slice(start, cursor), stripTabs }); - index = cursor; - } - } - return found; -} - -/** - * Index scalar assignments so a command held in a variable can be audited. - * - * `CMD="npm publish"` followed by `$CMD` runs a publish that no scan of the - * invocation line can see, because the invocation line contains no publish. The - * assignment is where the command actually is. `NPM=npm` followed by - * `$NPM publish` hides one the same way, so unquoted values are indexed too. - * - * A name is taken only where a line OPENS with an assignment-only list carrying - * fully literal values and holds nothing else before its end or a `;`. - * `NPM=npm; cmd` therefore binds, because the semicolon ends the assignment and - * the shell keeps it afterwards, while `NPM=npm cmd` does not, because it lasts only - * for the command it precedes. Requiring the line to OPEN with the assignment is - * what keeps a `;` inside a comment from exposing one. That single rule keeps - * the scan from inventing - * bindings the shell never makes, each of which let an unattested publish - * borrow a flag and pass the gate: - * - * - `# FLAG=--provenance` is a comment, and a comment is not a line that is - * only an assignment. - * - `echo "config NPM=npm"` is a command with an argument, not an assignment. - * - `FLAG=--provenance some-command` binds only for that one command; the shell - * does not keep it afterwards, so neither does this map. - * - `$(FLAG=--provenance)` binds inside a subshell that the outer shell never - * sees. - * - `NPM=npm$SUFFIX` and `NPM=npm$(printf foo)` are not literal. The value must - * match to the end of the line, so a prefix is never mistaken for the whole - * value -- the mistake that let a scan analyse a different command from the - * one the shell runs. - * - * `export NPM=npm`, a trailing `# comment` and a CRLF line ending are all still - * assignments: refusing them left `$NPM` unresolved, and an attested publish - * elsewhere in the file then satisfied the non-vacuity guard, so being too - * strict here passes an unattested publish just as being too loose does. - * - * Escapes are honoured outside single quotes, so `NPM=npm\\ publish` is one word - * holding a command while `CMD='"'"'a\\b'"'"' keeps its backslash as the shell does. - * A value that still carries a substitution, backtick, quote or parenthesis - * after unescaping is refused: inlining `pkg_name="$(node -p …)"` injects an - * unbalanced parenthesis into an unrelated command, and the scan then reports - * invocations that are not there while losing the one that is -- a false - * verdict in both directions, which is worse than not resolving the variable. - * - * @param text - File contents with continuations already joined. - * Heredoc bodies are data rather than commands and are skipped. Callers that - * expand a complete source use `expandShellScalars`, which applies each binding - * only to its own line and later lines, so reassignment cannot rewrite history. - * - * @returns Variable name mapped to the last literal text assigned to it. - */ -export function shellScalars(text: string): Map { - const scalars = new Map(); - const heredocs: Array<{ delimiter: string; stripTabs: boolean }> = []; - for (const line of text.split("\n")) { - const heredoc = heredocs[0]; - if (heredoc !== undefined) { - const candidate = heredoc.stripTabs ? line.replace(/^\t+/, "") : line; - if (candidate.replace(/\r$/, "") === heredoc.delimiter) heredocs.shift(); - continue; - } - for (const [name, value] of scalarAssignments(line)) scalars.set(name, value); - heredocs.push(...heredocTerminators(line)); - } - return scalars; -} - -/** Expand scalar references using only bindings visible at each source line. */ -export function expandShellScalars(text: string): string { - const scalars = new Map(); - const heredocs: Array<{ delimiter: string; stripTabs: boolean }> = []; - return text.split("\n").map((line) => { - const heredoc = heredocs[0]; - if (heredoc !== undefined) { - const candidate = heredoc.stripTabs ? line.replace(/^\t+/, "") : line; - if (candidate.replace(/\r$/, "") === heredoc.delimiter) heredocs.shift(); - return line; - } - let conditional = false; - const expanded = shellSegments(line).map((segment) => { - if (segment === "&&" || segment === "||") { - conditional = true; - return segment; - } - if (!conditional) { - for (const [name, value] of scalarAssignments(segment)) scalars.set(name, value); - } - return expandScalars(segment, scalars); - }).join(""); - heredocs.push(...heredocTerminators(line)); - return expanded; - }).join("\n"); -} - -/** - * Expand `$name` and `${name}` references against the file's scalar assignments. - * - * An unknown name is left in place for the same reason an unknown array is: - * erasing it would turn "not understood" into "carries no flags", which reads - * as a pass. - * - * @param line - One logical command. - * @param scalars - Scalar assignments from the same file. - * @returns The command with known scalar references inlined. - */ -export function expandScalars(line: string, scalars: Map): string { - // One of the two alternatives always captures the name, so there is no - // nameless match to guard against. - return line.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced?: string, bare?: string) => scalars.get(braced ?? bare!) ?? whole); -} - -/** - * Expand `"${name[@]}"` references against the file's array declarations. - * - * An unknown name is left untouched rather than erased: silently dropping it - * would turn "this scan does not understand the command" into "this command has - * no flags", which reads as a pass. - * - * @param line - One logical command. - * @param arrays - Array declarations from the same file. - * @returns The command with referenced array contents inlined. - */ -export function expandArrays(line: string, arrays: Map): string { - return line.replace(/"?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"?/g, (whole, name: string) => - arrays.get(name) ?? whole); -} - -/** The outcome of one verifier run. */ -export interface VerifierResult { - /** Reasons the run failed; empty means it passed. */ - failures: string[]; - /** Lines describing what was checked, for the operator. */ - notes: string[]; -} - diff --git a/scripts/verify-release-changelog-date.sh b/scripts/verify-release-changelog-date.sh index 21780b5..d689946 100755 --- a/scripts/verify-release-changelog-date.sh +++ b/scripts/verify-release-changelog-date.sh @@ -90,9 +90,17 @@ SELFTEST fi # In pm-changelog's own repository the generator is the build output, not a # dependency, so resolve it in that order rather than assuming node_modules. -if [ -x ./node_modules/.bin/pm-changelog ]; then bin="./node_modules/.bin/pm-changelog" -elif [ -f ./dist/cli.js ]; then bin="node ./dist/cli.js" -else bin="npx pm-changelog"; fi +# Resolve the generator binary once. A function is used rather than a scalar +# variable because the publish-attestation auditor treats an unresolved +# variable in command position (e.g. `$bin ...`) as a potential publish path: +# it cannot prove the variable does not expand to `npm publish`, so it fails +# closed. A named function in command position is a literal program name, not +# an unresolved expansion, so the auditor can dismiss it as a non-publisher. +run_changelog() { + if [ -x ./node_modules/.bin/pm-changelog ]; then ./node_modules/.bin/pm-changelog "$@" + elif [ -f ./dist/cli.js ]; then node ./dist/cli.js "$@" + else npx pm-changelog "$@"; fi +} # The generator refuses a truncated workspace read rather than silently # omitting entries, so the unbounded controls the real scripts pass are # required here too. @@ -101,8 +109,8 @@ common=(--pm-root .agents/pm --stdout --pm-bin ./node_modules/.bin/pm --pm-arg=--output-limit --pm-arg=unbounded --release-version "$probe") -with=$($bin "${common[@]}" --date-from-version 2>/dev/null | grep -m1 '^## ' || true) -without=$($bin "${common[@]}" 2>/dev/null | grep -m1 '^## ' || true) +with=$(run_changelog "${common[@]}" --date-from-version 2>/dev/null | grep -m1 '^## ' || true) +without=$(run_changelog "${common[@]}" 2>/dev/null | grep -m1 '^## ' || true) if [ "$with" != "$expected" ]; then echo "FAIL: with --date-from-version expected '$expected', got '$with'" >&2; status=1 diff --git a/scripts/verify-release-publish-attestation.ts b/scripts/verify-release-publish-attestation.ts index 2237fb5..07b8172 100644 --- a/scripts/verify-release-publish-attestation.ts +++ b/scripts/verify-release-publish-attestation.ts @@ -1,409 +1,39 @@ /** - * Proves this package has no publish path that omits `--provenance`. - * - * The release step used to fall back to `npm publish` without the flag after - * three failed provenance attempts, reporting success and leaving only a - * warning annotation. That makes a transient registry failure downgrade the - * published artifact's supply-chain attestation permanently for that version, - * and consumers cannot tell such a publish apart from one that never had - * provenance at all. An unattested publish is not a degraded success; it is a - * different artifact. - * - * A workflow edit is easy to make and easy to lose, so the contract is executed - * rather than assumed: every `npm publish` this repository can run is found and - * required to carry the flag. The analysis is separated from the I/O so the - * rules are driven by the suite against fixtures rather than only against this - * repository, which happens to satisfy them. - * - * @packageDocumentation - */ -import { execFileSync } from "node:child_process"; -import { closeSync, openSync, readFileSync, readSync } from "node:fs"; -import { resolve } from "node:path"; - -import { - bashArrays, - commandArguments, - commandCandidates, - commandName, - expandArrays, - expandShellScalars, - joinContinuations, - type ShellCommand, - type SourceFile, - tokenizeCommands, - type VerifierResult, -} from "./shell-command-scan.ts"; -import { isMainInvocation } from "./main-invocation.ts"; - -/** The flag that attaches a build attestation to the published tarball. */ -export const ATTESTATION_FLAG = "--provenance"; - -/** One publish invocation found in a tracked file. */ -export interface PublishInvocation { - /** File the invocation was found in. */ - file: string; - /** The program the invocation runs, reduced to its basename. */ - program: string; - /** The invocation's tokens, quoting resolved. */ - command: ShellCommand; -} - -/** Publishers other than npm, which this repository has no attested path for. */ -export const FOREIGN_PUBLISHERS = new Set(["yarn", "pnpm", "bun"]); - -/** Repository subtrees whose contents are build output rather than a publish path. */ -const GENERATED_PREFIXES = ["dist/", "coverage/", "node_modules/", ".agents/pm/runtime/"]; - -/** Tracked paths that can execute a command, matched against the repository-relative path. */ -const EXECUTABLE_PATHS = [ - /^\.github\/workflows\/[^/]+\.ya?ml$/, - /(^|\/)package\.json$/, - /\.(sh|bash|zsh|ksh)$/, - /(^|\/)(Makefile|makefile|GNUmakefile)$/, - /\.mk$/, - /(^|\/)Dockerfile([.-][^/]*)?$/, - /(^|\/)docker-compose([.-][^/]*)?\.ya?ml$/, -]; - -/** - * Yield the command text held inside a package manifest. - * - * A manifest is JSON, so its script bodies are string values rather than lines - * of shell. Handing the raw file to a shell tokeniser would read the JSON - * punctuation as commands and the script bodies as quoted words. Parsing the - * manifest and returning the bodies restores them to the shape the scanner - * expects, which matters because a publish moved into an npm script is entirely - * real and would otherwise be invisible to this gate. - * - * A manifest that will not parse yields nothing rather than throwing, so a - * malformed sibling file cannot take the gate down; the manifest's own tooling - * reports that far better than a publish audit can. - * - * @param text - The manifest's contents. - * @returns One line per script body, newline joined. - */ -export function manifestCommandLines(text: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return ""; - } - if (typeof parsed !== "object" || parsed === null) return ""; - const scripts = (parsed as { scripts?: unknown }).scripts; - if (typeof scripts !== "object" || scripts === null) return ""; - // Each script is its own command list, so one cannot continue into the next. - // A body ending in a backslash would otherwise be joined to the following - // script by continuation collapsing, and a script beginning `--provenance` - // would lend its flag to the unattested publish that ended the script before - // it -- turning two commands into one attested-looking command. - return Object.values(scripts as Record) - .filter((value): value is string => typeof value === "string") - .map((value) => value.replace(/\\+$/, "")) - .join("\n"); -} - -/** Subcommands that run something else, so a later `publish` word is its argument. */ -// npm's runner subcommands, which take a script or package name rather than -// publishing. `workspace` is deliberately absent: it is not a subcommand at all -// -- npm selects a workspace with the `-w`/`--workspace` FLAG -- and listing it -// here only meant that a `publish` written after the word was never audited. -const RUNNER_SUBCOMMANDS = new Set(["run", "run-script", "exec", "explore", "x"]); - -/** - * Decide whether one command is a direct `npm publish`. - * - * `publish` does not have to follow `npm` immediately: npm accepts its - * configuration flags anywhere on the line, so `npm --access public publish` is - * a real publish that an adjacency test discards silently, leaving an attested - * sibling elsewhere in the file to carry the audit to a pass. - * - * Reading the first non-flag word as the subcommand does not work either, - * because npm has flags that take a separate value (`--access public`) and - * flags that do not (`--ignore-scripts`), and telling them apart needs npm's - * own option table. So the word is looked for anywhere in the arguments, and - * only a preceding runner subcommand rules it out -- `npm run publish` runs a - * package script whose body is scanned from the manifest, and requiring the - * flag on the runner would report a defect that is not there. - * - * The residual imprecision is `npm --tag publish ...`, a dist-tag named after - * the subcommand, which this reads as a publish. That direction is deliberate: - * a false positive is a report line to argue with, a false negative is an - * unattested artifact on the registry. - * - * The program is checked in command position by the caller, so `echo npm - * publish` and `notnpm publish` never reach here. - * - * @param command - One simple command's tokens. - * @returns True when the command publishes. - */ -export function isPublishCommand(command: ShellCommand): boolean { - for (const token of commandArguments(command)) { - if (RUNNER_SUBCOMMANDS.has(token.value)) return false; - if (token.value === "publish") return true; - } - return false; -} - -/** - * Decide whether one publish command actually enables the attestation. - * - * A substring test is not enough. `--provenance=false`, `--provenance false` and - * `--no-provenance` all contain the flag's spelling and all turn the - * attestation off, so a containment check accepts precisely the regression this - * gate exists to catch -- while reporting the file as attested. `--provenance-file` - * is a different flag entirely and must not be read as this one. - * - * Tokens are judged in order and the last one wins, which is how npm resolves a - * flag given more than once: `--provenance --no-provenance` publishes without an - * attestation, so this must answer false for it. - * - * Quoting is irrelevant to the shell and so is irrelevant here: `npm publish - * "--provenance"` is attested, and the scan this replaces read it as bare. - * - * @param command - One simple command's tokens. - * @returns True when the command publishes with an attestation. - */ -export function attestationEnabled(command: ShellCommand): boolean { - const args = commandArguments(command); - let enabled = false; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]!.value; - if (token === `--no-${ATTESTATION_FLAG.slice(2)}`) { - enabled = false; - continue; - } - if (token === ATTESTATION_FLAG) { - const next = args[index + 1]?.value; - if (next === "true" || next === "false") { - enabled = next === "true"; - index += 1; - continue; - } - enabled = true; - continue; - } - if (token.startsWith(`${ATTESTATION_FLAG}=`)) { - enabled = token.slice(ATTESTATION_FLAG.length + 1) === "true"; - } - } - return enabled; -} - -/** - * Find every publish invocation in one file's contents. - * - * Continuations are joined and shared arrays expanded before tokenising, for - * the same reason the changelog-date scan does it: a multi-line invocation - * otherwise looks like fragments, none of which carries the flag. - * - * @param source - The file's path and contents. - * @returns The publish invocations found, in file order. + * Refuse a release whose `npm publish` would run without `--provenance`. + * + * Thin launcher over the canonical auditor published as `pm-ops/attestation`, + * so the fleet enforces one shell model rather than a vendored copy per + * repository. That distinction is not stylistic. This gate decides whether an + * artefact may reach the registry, so a *false pass* is the failure that + * matters, and the canonical implementation has had fifteen separate fail-open + * constructions found and closed in it - three of them introduced by the fix + * for an earlier one. A copy of this file frozen at any point in that sequence + * is a copy that still admits every construction closed after it. + * + * Every rule lives in the auditor: which files GitHub Actions executes as + * shell, how YAML block scalars are dedented and folded before bash sees them, + * how a scalar binding becomes visible or stops being visible across + * conditional arms, and what counts as a publish. This file only chooses the + * root, maps the report onto the process streams, and sets the exit code. + * + + * Deliberately carries no shebang. The auditor reads a shebang naming a SHELL + * interpreter as saying the file's body is shell, so `#!/bin/bash` or + * `#!/usr/bin/env sh` pulls this file into its own scan - and its prose, which + * necessarily names the command it is guarding, then reads as an unattested + * invocation. A shebang naming a non-shell interpreter does not: a shebang says + * a file executes, it does not say it executes AS shell, so `#!/usr/bin/env node` + * leaves this file unscanned. The suite reproduces all three states rather than + * asserting them. The vendored predecessor had no shebang for the same reason. */ -export function publishInvocationsIn(source: SourceFile): PublishInvocation[] { - const raw = source.file.endsWith("package.json") ? manifestCommandLines(source.text) : source.text; - const text = joinContinuations(raw); - const arrays = bashArrays(text); - const expanded = expandShellScalars(text) - .split("\n") - .map((line) => expandArrays(line, arrays)) - .join("\n"); - const found: PublishInvocation[] = []; - for (const command of tokenizeCommands(expanded)) { - // Every reading, not just the command's own: a wrapper option that takes a - // value (`sudo -u root npm publish`) moves the program past where naming it - // once would look. Missing a publish is a failed audit; offering one that no - // shell would run is noise an operator dismisses. - for (const candidate of commandCandidates(command)) { - const program = commandName(candidate); - if (program === undefined) continue; - if (!isPublishCommand(candidate)) continue; - // A variable-routed publisher that cannot be resolved must fail closed. - // Treating `$NPM publish` as "not a publish" lets any unsupported shell - // construct turn a parser limitation into a clean attestation verdict. - const unresolvedPublisher = /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)$/.test(program); - if (program !== "npm" && !FOREIGN_PUBLISHERS.has(program) && !unresolvedPublisher) continue; - // Not de-duplicated: two identical publish lines are two invocations, and - // collapsing them would report one of them as if the other did not exist. - found.push({ file: source.file, program: unresolvedPublisher ? "unresolved-shell-variable" : program, command: candidate }); - } - } - return found; -} - -/** - * Render an invocation back to a readable command for a report line. - * - * @param command - The invocation's tokens. - * @returns The command as a single space-separated string. - */ -export function renderCommand(command: ShellCommand): string { - return command.map((token) => token.value).join(" ").slice(0, 160); -} -/** - * Audit every publish invocation across the given files. - * - * An absent invocation is a failure rather than a pass: a scan that finds - * nothing has either been pointed at the wrong files or outlived the workflow - * it guards, and both look identical to a clean result unless said out loud. - * - * A publisher other than npm fails outright rather than being checked for a - * flag. This repository's attested path is npm's `--provenance`; no equivalent - * is configured for yarn, pnpm or bun, so such an invocation is an unattested - * publish path regardless of the flags it carries, and guessing at another - * tool's spelling would be a gate that only looked strict. - * - * @param sources - The tracked files to scan. - * @returns Failures and per-file notes. - */ -export function auditPublishAttestation(sources: SourceFile[]): VerifierResult { - const invocations = sources.flatMap(publishInvocationsIn); - const failures: string[] = []; - const counted = new Map(); - for (const invocation of invocations) { - const tally = counted.get(invocation.file) ?? { total: 0, unflagged: 0 }; - tally.total += 1; - if (invocation.program !== "npm") { - tally.unflagged += 1; - failures.push( - `${invocation.file}: \`${invocation.program} publish\` is a publish path with no attested` - + ` equivalent configured in this repository: ${renderCommand(invocation.command)}`, - ); - } else if (!attestationEnabled(invocation.command)) { - tally.unflagged += 1; - failures.push( - `${invocation.file}: a publish invocation does not enable ${ATTESTATION_FLAG}, so it would` - + ` publish an unattested artifact: ${renderCommand(invocation.command)}`, - ); - } - counted.set(invocation.file, tally); - } - if (invocations.length === 0) { - failures.push("no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"); - } - const notes: string[] = []; - for (const [file, tally] of counted) { - if (tally.unflagged > 0) continue; - notes.push(`ok - ${file}: ${tally.total} publish invocation(s), each carrying ${ATTESTATION_FLAG}`); - } - return { failures, notes }; -} - -/** - * Decide whether a tracked path can run a command. - * - * The previous enumeration named two paths -- `.github/workflows` and - * `package.json` -- which meant a publish added to any tracked script was never - * audited, and because the workflow's own attested publish satisfied the - * non-vacuity check the gate still reported that every invocation was attested. - * Auditing every shape that can execute closes that, and a shebang is honoured - * so an extensionless tracked script is not a blind spot either. - * - * Build output is excluded. `dist/` is generated from sources this scan already - * reads, it is regenerated and compared byte-for-byte on the release path, and - * including it would audit a bundled copy of a command rather than the command. - * - * @param path - Repository-relative path. - * @param firstLine - The file's first line, for shebang detection. - * @returns True when the file should be scanned. - */ -export function isExecutableSource(path: string, firstLine: string): boolean { - if (GENERATED_PREFIXES.some((prefix) => path.startsWith(prefix))) return false; - if (firstLine.startsWith("#!")) return true; - return EXECUTABLE_PATHS.some((pattern) => pattern.test(path)); -} - -/** - * Read the first two bytes of a file, or nothing when it cannot be read. - * - * Only a shebang is being looked for, so the whole file is never loaded -- - * `git ls-files` can name a large tracked asset, and this runs once per - * candidate. A tracked path that cannot be opened at all (a dangling symlink, - * a file removed from the working tree but still in the index) is not a - * publish path and must not take the gate down; it reads as empty. - * - * The handle is closed by an inner `finally` rather than one wrapping the - * catch, so there is no unreachable fall-through for the coverage gate to - * report as an untested branch. - * - * @param file - Absolute path to read. - * @returns The first two bytes as text, or an empty string. - */ -function firstBytes(file: string): string { - try { - const handle = openSync(file, "r"); - try { - const buffer = Buffer.alloc(2); - readSync(handle, buffer, 0, 2, 0); - return buffer.toString("utf8"); - } finally { - closeSync(handle); - } - } catch { - return ""; - } -} +import { resolve } from "node:path"; -/** - * List the tracked files that can run a publish. - * - * Git is asked rather than the filesystem walked, so an untracked scratch copy - * of a workflow cannot satisfy or fail the gate. `-z` is used because a tracked - * path may legally contain a newline, and splitting such a listing on newlines - * invents two paths that do not exist and drops the one that does. - * - * @param root - Repository root. - * @returns Repository-relative paths of every tracked file that can execute. - */ -export function trackedPublishSources(root: string): string[] { - const listed = execFileSync("git", ["ls-files", "-z"], { - cwd: root, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - return listed - .split("\0") - .filter((path) => path.length > 0) - .filter((path) => isExecutableSource(path, firstBytes(resolve(root, path)))); -} +import { auditPublishAttestation, report, verify } from "pm-ops/attestation"; -/** - * Read the tracked sources and audit them. - * - * @param root - Repository root to verify. - * @returns Failures and notes for the whole repository. - */ -export function verify(root: string): VerifierResult { - const sources: SourceFile[] = trackedPublishSources(root).map((file) => ({ - file, - text: readFileSync(resolve(root, file), "utf8"), - })); - return auditPublishAttestation(sources); -} +export { auditPublishAttestation, verify }; -/** - * Print a result and set a failing exit code when it failed. - * - * @param result - The audit outcome. - * @param write - Sink for the report lines. - * @param exit - Called with the process exit code when there were failures. - */ -export function report( - result: VerifierResult, - write: (line: string) => void, - exit: (code: number) => void, -): void { - for (const note of result.notes) write(note); - for (const failure of result.failures) write(`FAIL - ${failure}`); - if (result.failures.length > 0) { - write(`verify-release-publish-attestation: ${result.failures.length} failure(s).`); - exit(1); - return; - } - write("verify-release-publish-attestation: every publish invocation is attested."); -} +import { isMainInvocation } from "./main-invocation.ts"; /** * Verify and report, but only when this module is the process entry point. @@ -423,4 +53,4 @@ export function runIfMain(argv: string[], moduleUrl: string, root: string): bool return true; } -runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, "..")); +runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, "..")); \ No newline at end of file diff --git a/test/shell-command-scan.test.ts b/test/shell-command-scan.test.ts deleted file mode 100644 index c9fde1b..0000000 --- a/test/shell-command-scan.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Tests for the shared shell-text scanner and the main-invocation guard. - * - * These live beside the modules rather than inside a gate's suite because both - * release gates depend on them while not every package carries both gates. - * When these assertions belonged to the changelog-date suite, propagating the - * scanner to a package without that gate silently dropped a branch from - * coverage -- which is the failure this file exists to prevent. - */ - -import assert from "node:assert/strict"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; - -import { bashArrays, expandArrays, joinContinuations } from "../scripts/shell-command-scan.ts"; -import { isMainInvocation } from "../scripts/main-invocation.ts"; - -test("an unknown array reference is left in place rather than erased", () => { - // Erasing it would turn "this scan does not understand the command" into - // "this command carries no flags", which reads as a pass. - assert.equal(expandArrays('cmd "${missing[@]}"', new Map()), 'cmd "${missing[@]}"'); - assert.equal(expandArrays('cmd "${known[@]}"', new Map([["known", "--a --b"]])), "cmd --a --b"); -}); - -test("bashArrays collapses whitespace so a multi-line declaration is one flag string", () => { - assert.equal(bashArrays("common=(\n --a\n --b\n)").get("common"), "--a --b"); -}); - -test("the main-invocation guard answers both ways", () => { - // Name the module under test, not a gate: not every package carries the same - // gates, and a path that resolves nowhere makes realpathSync throw rather - // than answer. - const self = fileURLToPath(import.meta.resolve("../scripts/main-invocation.ts")); - const url = import.meta.resolve("../scripts/main-invocation.ts"); - assert.equal(isMainInvocation(["node", self], url), true); - assert.equal(isMainInvocation(["node", fileURLToPath(import.meta.url)], url), false); - assert.equal(isMainInvocation(["node"], url), false); -}); -test("a backslash continuation makes one logical command out of several lines", () => { - assert.equal( - joinContinuations("npm publish \\\n --provenance \\\n --access public\n"), - // The joiner replaces the backslash-newline with a single space and leaves - // the continuation line's own indentation, which the tokeniser then eats. - "npm publish --provenance --access public\n", - ); - // A backslash that does not end a line is an ordinary character. - assert.equal(joinContinuations("printf 'a\\tb'\n"), "printf 'a\\tb'\n"); -}); - -test("an array reference is replaced by the declaration's contents, quoted or bare", () => { - const arrays = bashArrays('common=( --access public --provenance )\n'); - assert.equal(expandArrays('npm publish "${common[@]}"', arrays), "npm publish --access public --provenance"); - assert.equal(expandArrays("npm publish ${common[@]}", arrays), "npm publish --access public --provenance"); -}); diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts index 8328064..07690a6 100644 --- a/test/verify-release-publish-attestation.test.ts +++ b/test/verify-release-publish-attestation.test.ts @@ -1,999 +1,170 @@ /** - * Executes the publish-attestation verifier's rules against fixtures. + * Convergence tests for the publish-attestation gate. * - * The verifier's own repository satisfies its rules, so running it here would - * only prove that today's tree is fine. What these cases prove is that each - * rule still FAILS on the defect it exists to catch -- an unattested publish - * reachable from the release workflow -- and that the two shapes which make a - * naive substring scan useless are handled: a publish spelled across a line - * continuation, and a prose mention of the command inside a quoted string. + * This repository no longer implements the gate; it consumes the canonical + * auditor from `pm-ops/attestation`. So these tests deliberately do NOT + * re-test the shell model - that suite lives with the implementation, where a + * fix reaches every consumer at once. What they assert instead is that this + * repository is still a consumer: that the gate resolves to the package export + * rather than to a local copy, and that it still refuses an unattested publish + * through that resolved path. + * + * The first is the one that matters over time. Fifteen fail-open constructions + * have been found in this gate, three of them introduced by the fix for an + * earlier one, and a copy frozen at any point in that sequence still admits + * every construction closed after it. A hand-edit that re-forks the lineage + * would otherwise be invisible. */ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; -import { - ATTESTATION_FLAG, - attestationEnabled, - auditPublishAttestation, - isExecutableSource, - isPublishCommand, - manifestCommandLines, - publishInvocationsIn, - report, - renderCommand, - runIfMain, - trackedPublishSources, - verify, -} from "../scripts/verify-release-publish-attestation.ts"; -import { commandArguments, commandCandidates, commandName, expandScalars, shellScalars, tokenizeCommands } from "../scripts/shell-command-scan.ts"; - -/** Tokenises one command and returns it, asserting the text held exactly one. */ -function onlyCommand(text: string): ReturnType[number] { - const commands = tokenizeCommands(text); - assert.equal(commands.length, 1, `expected one command in ${JSON.stringify(text)}`); - return commands[0]!; -} - -const ATTESTED = `npm publish --access public ${ATTESTATION_FLAG} --ignore-scripts`; -const UNATTESTED = "npm publish --access public --ignore-scripts"; - -/** Builds a throwaway git repository holding the given tracked files. */ -function trackedFixture(files: Record): string { - const root = mkdtempSync(join(tmpdir(), "attestation-")); - execFileSync("git", ["init", "-q", "."], { cwd: root }); - for (const [path, text] of Object.entries(files)) { - mkdirSync(join(root, path, ".."), { recursive: true }); - writeFileSync(join(root, path), text); - } - execFileSync("git", ["add", "-A"], { cwd: root }); - return root; -} - -test("an unattested publish fails, naming the command that would run", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: ` ${UNATTESTED}` }]); - assert.equal(result.failures.length, 1); - assert.match(result.failures[0]!, /does not enable --provenance/); - assert.match(result.failures[0]!, /npm publish --access public --ignore-scripts/); -}); - -test("an attested publish passes and is reported by file", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: ` ${ATTESTED}` }]); - assert.deepEqual(result.failures, []); - assert.deepEqual(result.notes, [`ok - release.yml: 1 publish invocation(s), each carrying ${ATTESTATION_FLAG}`]); -}); - -test("a file holding both an attested and an unattested publish fails, so one cannot cover for the other", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${UNATTESTED}` }, - ]); - assert.equal(result.failures.length, 1); - assert.deepEqual(result.notes, [], "a file with an unattested publish must not also be reported as ok"); -}); - -test("two publishes chained on one line are judged separately", () => { - // Judging the line as a whole would let the flag on the first call satisfy - // the second, which is exactly the shape a line-oriented scan misses. - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED} && ${UNATTESTED}` }, - ]); - assert.equal(result.failures.length, 1); -}); +import { auditPublishAttestation, report, verify } from "pm-ops/attestation"; -test("a publish spelled across a line continuation is still seen with its flag", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: " npm publish --access public \\\n --provenance --ignore-scripts" }, - ]); - assert.deepEqual(result.failures, []); -}); +import { runIfMain, auditPublishAttestation as launcher_auditPublishAttestation, verify as launcher_verify } from "../scripts/verify-release-publish-attestation.ts"; -test("a shared bash array holding the flag is expanded rather than read as an absent flag", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` flags=( --access public ${ATTESTATION_FLAG} )\n npm publish "\${flags[@]}"` }, - ]); - assert.deepEqual(result.failures, []); -}); - -test("a prose mention of the command inside quotes is not treated as an invocation", () => { - // This repository's own workflow echoes advice naming the command. Reading - // that echo as a publish makes the gate report a defect that is not there, - // and a gate that cries wolf gets weakened until it reports nothing. - const result = auditPublishAttestation([ - { file: "release.yml", text: ` echo "The trusted publisher must have 'npm publish' selected."` }, - ]); - assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); -}); +const root = resolve(import.meta.dirname, ".."); -test("a commented-out publish is not treated as an invocation", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` # ${UNATTESTED}\n ${ATTESTED}` }, - ]); - assert.deepEqual(result.failures, []); -}); - -test("a trailing unquoted comment cannot supply the flag the command lacks", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${UNATTESTED} # ${ATTESTATION_FLAG}` }, - ]); - assert.equal(result.failures.length, 1); -}); - -test("a disabled attestation is not an attestation, in every spelling npm accepts", () => { - // Greptile P2: a containment check accepts `--provenance=false`, which is - // precisely the regression this gate exists to catch, and reports the file - // as attested while doing it. - for (const disabled of ["--provenance=false", "--no-provenance", "--provenance --no-provenance", "--provenance=0"]) { - assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${disabled}`)), false, disabled); - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: ` npm publish --access public ${disabled}` }]).failures.length, - 1, - disabled, - ); - } - for (const enabled of ["--provenance", "--provenance=true", "--no-provenance --provenance"]) { - assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${enabled}`)), true, enabled); - } -}); +test("the gate is the resolved package export, not a local copy", async () => { + // Identity, not similarity. A vendored copy that happens to behave the same + // today is exactly what this fleet spent a session removing, because it stops + // behaving the same the moment the canonical implementation is fixed again. + assert.equal( + existsSync(resolve(root, "scripts/shell-command-scan.ts")), + false, + "a local shell-command-scan.ts means the lineage has been re-forked", + ); -test("a flag that merely starts with the attestation spelling does not enable it", () => { - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance-file x")), false); -}); + const launcher = await readFile(resolve(root, "scripts/verify-release-publish-attestation.ts"), "utf-8"); + assert.match(launcher, /from "pm-ops\/attestation"/u, "the gate must import the canonical auditor"); + assert.doesNotMatch( + launcher, + /from "\.\/shell-command-scan/u, + "the gate must not resolve any part of its shell model locally", + ); -test("a publish hidden in an npm script is found, because a manifest is JSON and its scripts are quoted", () => { - // CodeRabbit: quoted spans are erased before a command is judged, which is - // what stops the workflow's advisory echo reading as an invocation. Applied - // to a manifest that erases the script bodies themselves, so a publish moved - // into an npm script would be invisible while being entirely real. - const manifest = JSON.stringify({ scripts: { release: UNATTESTED, build: "tsc" } }); - const result = auditPublishAttestation([{ file: "package.json", text: manifest }]); - assert.equal(result.failures.length, 1, "an unattested publish in a script must fail"); - assert.match(result.failures[0]!, /does not enable --provenance/); - const attested = JSON.stringify({ scripts: { release: ATTESTED } }); - assert.deepEqual(auditPublishAttestation([{ file: "package.json", text: attested }]).failures, []); + // Identity, not similarity: the launcher must re-export the package's own + // functions. Asserting only that the package exports functions would pass + // for a launcher that imports the package and then ignores it, which is + // precisely the re-fork this test exists to catch. + assert.equal(launcher_auditPublishAttestation, auditPublishAttestation, "the launcher must re-export the package's own auditPublishAttestation"); + assert.equal(launcher_verify, verify, "the launcher must re-export the package's own verify"); + assert.equal(typeof report, "function"); }); -test("manifestCommandLines survives a manifest that is malformed, empty, or has no scripts", () => { - // A malformed sibling manifest must not take the gate down; its own tooling - // reports that far better than a publish audit can. - assert.equal(manifestCommandLines("{ not json"), ""); - assert.equal(manifestCommandLines("null"), ""); - assert.equal(manifestCommandLines("[]"), ""); - assert.equal(manifestCommandLines("{}"), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: null })), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: "not-an-object" })), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: { a: "x", b: 3, c: "y" } })), "x\ny"); -}); +test("the resolved gate still refuses an unattested publish", () => { + // A behavioural check through the real resolved module, so "it imports the + // package" cannot pass while the package fails to load or changes shape. + const workflow = (publish: string): string => + ["jobs:", " release:", " steps:", " - run: |", ` ${publish}`].join("\n"); -test("a publish with configuration flags before the subcommand is still a publish", () => { - // Greptile: npm accepts its flags anywhere on the line, so requiring `publish` - // to follow `npm` immediately discards a real unattested publish silently -- - // and an attested sibling elsewhere in the file then carries the audit to a - // pass. - const spread = "npm --access public publish --ignore-scripts"; - assert.equal(isPublishCommand(onlyCommand(spread)), true); - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${spread}` }, + const unattested = auditPublishAttestation([ + { file: ".github/workflows/release.yml", text: workflow("npm publish --access public") }, ]); - assert.equal(result.failures.length, 1, "the unattested sibling must be counted and failed"); -}); - -test("npm run publish is a script runner, not a publish", () => { - // The script's own body is scanned from the manifest, so requiring the flag - // on the runner would report a defect that is not there. - assert.equal(isPublishCommand(onlyCommand("npm run publish")), false); - assert.equal(isPublishCommand(onlyCommand("npm run-script publish")), false); - assert.equal(isPublishCommand(onlyCommand("npm publish")), true); - assert.equal(isPublishCommand(onlyCommand("npm ci")), false); - assert.equal(isPublishCommand(onlyCommand("npm exec publish")), false, "exec runs a binary, it does not publish"); - assert.equal(isPublishCommand(onlyCommand("npm --access public publish")), true, "a flag value is not the subcommand"); - assert.equal(isPublishCommand(onlyCommand("npm --ignore-scripts publish")), true); -}); - -test("finding no publish at all fails, because an empty scan and a clean tree look identical", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: " npm ci\n" }]); - assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); -}); - -test("only a command in command position is a publish, whatever else names npm", () => { - // CodeRabbit: searching a whole line for the word `npm` classified an - // announcement as an invocation and then failed it for lacking a flag no - // announcement could carry. What decides the question is command POSITION. - for (const mention of ["echo notnpm publish", "echo npm publish", "printf npm publish", "notnpm publish", "xnpm publish --access public"]) { - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: ` ${mention}\n` }), - [], - mention, - ); - } - // The same words in command position, with a wrapper and a full path, are. - for (const real of ["npm publish --provenance", "/usr/local/bin/npm publish --provenance", "env CI=1 npm publish --provenance", "NPM_CONFIG_LOGLEVEL=silly npm publish --provenance"]) { - assert.equal(publishInvocationsIn({ file: "release.yml", text: ` ${real}\n` }).length, 1, real); - } -}); - -test("quoting a flag does not hide it, because the shell strips quotes before npm sees them", () => { - // CodeRabbit/Greptile: the scan blanked quoted spans, so an attested publish - // written with a quoted flag read as unattested -- and, far worse, a publish - // written inside a quoted string vanished from the audit entirely. - for (const quoted of [ - `npm publish --access public "${ATTESTATION_FLAG}"`, - `npm publish --access public '${ATTESTATION_FLAG}'`, - `npm publish --access public --provenance"" `, - `npm publish "--access" public ${ATTESTATION_FLAG}`, - ]) { - assert.deepEqual(auditPublishAttestation([{ file: "release.yml", text: ` ${quoted}` }]).failures, [], quoted); - } -}); - -test("an unattested publish smuggled through an interpreter or a substitution is still found", () => { - // Greptile P1 and CodeRabbit: `eval`, `bash -c` and `$(...)` payloads are - // shell text. The previous scan blanked them as quoted spans, so each of - // these published without an attestation while the workflow's own attested - // publish carried the audit to green. - for (const smuggled of [ - `eval "${UNATTESTED}"`, - `eval '${UNATTESTED}'`, - `bash -c "${UNATTESTED}"`, - `sh -c '${UNATTESTED}'`, - `output=$(${UNATTESTED})`, - "output=`npm publish --access public`", - `echo hi && eval "${UNATTESTED}"`, - ]) { - const failures = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, - ]).failures; - assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); - } -}); - -test("every shell separator ends a command, so a flagged publish cannot cover an unflagged neighbour", () => { - // The previous split knew `&&`, `||`, `;` and a space-surrounded `|` only, so - // a backgrounding `&` and a compact pipe fused two commands into one line - // that the flagged half then made pass. - for (const separator of ["&&", "||", ";", " | ", "|", "&", "\n"]) { - const text = ` ${ATTESTED} ${separator} ${UNATTESTED}`; - assert.equal( - auditPublishAttestation([{ file: "release.yml", text }]).failures.length, - 1, - `separator ${JSON.stringify(separator)}`, - ); - } -}); - -test("a publisher other than npm is refused rather than searched for a flag it has no equivalent of", () => { - for (const publisher of ["yarn", "pnpm", "bun"]) { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${publisher} publish --access public` }, - ]); - assert.equal(result.failures.length, 1, publisher); - assert.match(result.failures[0]!, new RegExp(`\\\`${publisher} publish\\\``)); - } -}); - -test("npm accepts a boolean value as a separate word, and so must this", () => { - // CodeRabbit: npm's option parser takes `--provenance false`. Reading only - // `--provenance` there reports an attestation the publish does not carry. - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false")), false); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance true")), true); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance --access public")), true, "a following flag is not a value"); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false --provenance")), true, "the last spelling wins"); -}); + assert.equal(unattested.failures.length, 1); -test("tokenizeCommands resolves quoting, comments and escapes the way a shell does", () => { - assert.deepEqual(onlyCommand(`a "b c" d`).map((token) => token.value), ["a", "b c", "d"]); - assert.deepEqual(onlyCommand("a 'b c'").map((token) => token.value), ["a", "b c"]); - assert.deepEqual(onlyCommand("a\\ b").map((token) => token.value), ["a b"], "an escaped space joins one word"); - assert.deepEqual(onlyCommand('x "a\\"b"').map((token) => token.value), ["x", 'a"b'], "an escaped quote stays in the word"); - assert.deepEqual(tokenizeCommands("# only a comment"), []); - assert.deepEqual(onlyCommand("npm ci # trailing comment").map((token) => token.value), ["npm", "ci"]); - assert.deepEqual(tokenizeCommands("a\\"), [[{ value: "a", quoted: false, startsQuoted: false }]], "a trailing backslash does not read past the end"); - assert.deepEqual(tokenizeCommands("echo 'unterminated").map((c) => c.map((t) => t.value)), [["echo", "unterminated"]]); - assert.equal(onlyCommand('cmd "unterminated')[1]!.quoted, true); - assert.deepEqual(commandArguments(onlyCommand("env A=1 npm publish")).map((token) => token.value), ["publish"]); - assert.equal(commandName([]), undefined); - assert.equal(commandName(onlyCommand("A=1 B=2")), undefined, "assignments alone run no command"); - assert.equal(commandName(onlyCommand("'npm' publish")), "npm", "a quoted program name still runs it"); - // startsQuoted, not quoted, is what separates an assignment from a literal - // that merely looks like one: the shell assigns for the first and not the - // second, and only the second begins inside quotes. - assert.equal(onlyCommand('A="b c" npm')[0]!.startsQuoted, false, "a quoted VALUE still starts unquoted"); - assert.equal(onlyCommand('"A=b" npm')[0]!.startsQuoted, true, "a wholly quoted word starts quoted"); - assert.equal(onlyCommand("'A=b' npm")[0]!.startsQuoted, true); - assert.equal(onlyCommand("\\A=b npm")[0]!.startsQuoted, false, "an escape is not a quote"); - assert.equal(commandName(onlyCommand('NPM_CONFIG_REGISTRY="https://r.example" npm publish')), "npm"); - assert.equal(commandName(onlyCommand('"A=b" publish')), "A=b", "a quoted literal is the program, not an assignment"); -}); - -test("every reading of a wrapper-led command is offered, so an unknown option value cannot hide a program", () => { - // commandName answers once and is right to; an auditor cannot afford that, - // because `-u` takes a value and nothing here enumerates which options do. - const values = (command: ReturnType) => - commandCandidates(command).map((candidate) => commandName(candidate)); - assert.deepEqual(values(onlyCommand("sudo -u root npm publish")), ["root", "npm", "publish"]); - assert.deepEqual(values(onlyCommand("nice -n 10 npm publish")), ["10", "npm", "publish"]); - // No wrapper means exactly one reading, so ordinary commands are untouched. - assert.deepEqual(values(onlyCommand("npm publish --provenance")), ["npm"]); - assert.deepEqual(values(onlyCommand("echo npm publish")), ["echo"]); - // A command that is nothing but a wrapper offers no reading at all. - assert.deepEqual(commandCandidates(onlyCommand("sudo")), []); - assert.deepEqual(commandCandidates([]), []); - // A reading that is only assignments names no program, and is skipped rather - // than audited as one. - // A trailing reading that is only assignments names no program, so it is - // skipped rather than audited as one. - assert.deepEqual(values(onlyCommand("sudo -u root npm publish A=1")), ["root", "npm", "publish", undefined]); - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: " sudo -u root npm publish --provenance A=1\n" }).length, - 1, - ); -}); - -test("two identical publish lines are two findings, not one", () => { - // Collapsing them would report one invocation as if the other did not exist, - // and an operator reading "1 unattested publish" would fix half the file. - const result = auditPublishAttestation([ - { file: "release.yml", text: " npm publish\n npm publish\n" }, + const attested = auditPublishAttestation([ + { file: ".github/workflows/release.yml", text: workflow("npm publish --access public --provenance") }, ]); - assert.equal(result.failures.length, 2); + assert.deepEqual(attested.failures, []); + assert.deepEqual(attested.recognition, { kind: "recognized", count: 1 }); }); -test("a substitution inside double quotes is scanned, because the shell runs it before the quotes matter", () => { - // `"$(npm publish)"` looks like one quoted word and is a real invocation. - // Treating the quoting as decisive is exactly how the previous scan lost it. - for (const smuggled of [ - `message="$(${UNATTESTED})"`, - "message=\"`npm publish --access public`\"", - `message="prefix $(${UNATTESTED}) suffix"`, - ]) { - const failures = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, - ]).failures; - assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); - } -}); - -test("unterminated and nested substitutions terminate instead of reading past the end", () => { - // A substitution's OUTPUT is not knowable here, so it contributes an empty - // word to the command that contained it while its body is scanned as - // commands in its own right. What matters is that neither shape loops or - // swallows the rest of the file. - const words = (text: string): string[][] => tokenizeCommands(text).map((command) => command.map((token) => token.value)); - assert.deepEqual(words('cmd "abc\\'), [["cmd", "abc"]], "a trailing backslash inside quotes stops at the end"); - assert.deepEqual(words('cmd "a\\\nb"'), [["cmd", "ab"]], "an escaped newline inside quotes continues the word"); - assert.deepEqual(words("cmd a\\\nb"), [["cmd", "ab"]], "and outside quotes too"); - assert.deepEqual(words("cmd $("), [["cmd", ""]], "an unterminated substitution yields an empty word and no command"); - assert.deepEqual(words("cmd `unterminated"), [["cmd", ""], ["unterminated"]], "an unterminated backtick still scans its body"); - assert.deepEqual(words("a $(echo $(npm publish)) b"), [["a", "", "b"], ["echo", ""], ["npm", "publish"]], "nesting is counted, so the inner command survives"); - assert.deepEqual(words("a $(echo \\) x) b"), [["a", "", "b"], ["echo", ")", "x"]], "an escaped paren does not close the substitution"); -}); - -test("a tracked path that cannot be opened is skipped rather than taking the gate down", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - }); - try { - symlinkSync("nowhere-at-all", join(root, "dangling")); - execFileSync("git", ["add", "dangling"], { cwd: root }); - assert.ok(!trackedPublishSources(root).includes("dangling"), "an unreadable tracked file is not a publish source"); - assert.deepEqual(verify(root).failures, [], "and it does not fail the gate either"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("evaluator recursion is bounded, so hostile nesting cannot hang the gate", () => { - let text = UNATTESTED; - // Escape backslashes before quotes. Escaping only the quote leaves a literal - // backslash in the payload able to consume the escape that follows it, so the - // nesting this test builds would not be the nesting it asserts on. - for (let depth = 0; depth < 12; depth += 1) { - text = `eval "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; - } - assert.deepEqual(tokenizeCommands(text, 9), [], "past the bound the walk stops rather than recursing"); - assert.ok(tokenizeCommands(`eval "${UNATTESTED}"`).length > 1, "within the bound the payload is still scanned"); -}); - -test("renderCommand joins the resolved tokens and caps the length of a report line", () => { - assert.equal(renderCommand(onlyCommand(`npm publish "--access" public`)), "npm publish --access public"); - assert.equal(renderCommand(onlyCommand(`npm publish ${"x".repeat(400)}`)).length, 160); -}); - -test("trackedPublishSources asks git, so an untracked workflow copy cannot satisfy the gate", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "package.json": "{}", - }); - try { - writeFileSync(join(root, ".github/workflows/scratch.yml"), ` ${UNATTESTED}`); - assert.deepEqual(trackedPublishSources(root).sort(), [".github/workflows/release.yml", "package.json"]); - assert.deepEqual(verify(root).failures, [], "the untracked scratch copy must not be judged"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("a publish in any tracked executable is audited, not only workflows and the manifest", () => { - // Greptile P1: the enumeration named `.github/workflows` and `package.json`, - // so a publish added to a tracked script was never read -- and because the - // workflow's own attested publish satisfied the non-vacuity check, the gate - // reported that every invocation was attested. - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "package.json": "{}", - "scripts/ship.sh": `#!/usr/bin/env bash\n${UNATTESTED}\n`, - }); - try { - assert.ok(trackedPublishSources(root).includes("scripts/ship.sh")); - const failures = verify(root).failures; - assert.equal(failures.length, 1, JSON.stringify(failures)); - assert.match(failures[0]!, /scripts\/ship\.sh/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("an extensionless tracked script is audited when its shebang says it executes", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "tools/release": `#!/bin/sh\n${UNATTESTED}\n`, - "docs/notes": `${UNATTESTED}\n`, - }); - try { - const sources = trackedPublishSources(root); - assert.ok(sources.includes("tools/release"), "a shebang marks an executable source"); - assert.ok(!sources.includes("docs/notes"), "prose without a shebang is not a publish path"); - assert.equal(verify(root).failures.length, 1); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("committed build output is not audited, because it is generated from sources already read", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "dist/bundle.sh": `#!/bin/sh\n${UNATTESTED}\n`, - }); - try { - assert.deepEqual(trackedPublishSources(root), [".github/workflows/release.yml"]); - assert.deepEqual(verify(root).failures, []); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("isExecutableSource recognises the shapes that can run a command", () => { - for (const path of [".github/workflows/ci.yml", ".github/workflows/ci.yaml", "package.json", "web/package.json", "x.sh", "Makefile", "build/rules.mk", "Dockerfile", "Dockerfile.ci", "docker-compose.yml", "docker-compose.prod.yaml"]) { - assert.equal(isExecutableSource(path, ""), true, path); - } - for (const path of ["README.md", "src/index.ts", ".github/dependabot.yml", "package.json.bak"]) { - assert.equal(isExecutableSource(path, ""), false, path); - } - assert.equal(isExecutableSource("tools/release", "#!/bin/sh"), true, "a shebang overrides the shape"); - assert.equal(isExecutableSource("dist/bundle.sh", "#!/bin/sh"), false, "build output is excluded first"); - assert.equal(isExecutableSource("coverage/x.sh", ""), false); -}); - -test("verify reads the tracked files and fails on an unattested one", () => { - const root = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); - try { - assert.equal(verify(root).failures.length, 1); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("report prints notes then failures and asks for a failing exit code", () => { - const lines: string[] = []; - const codes: number[] = []; - report({ failures: ["bad"], notes: ["fine"] }, (line) => lines.push(line), (code) => codes.push(code)); - assert.deepEqual(lines, ["fine", "FAIL - bad", "verify-release-publish-attestation: 1 failure(s)."]); - assert.deepEqual(codes, [1]); -}); - -test("report on a clean result says so and asks for no exit code", () => { +test("this repository's own workflows pass the gate", () => { + // The gate pointed at this checkout, which is what CI runs. Reported through + // captured streams rather than the process ones so a failure is readable. const lines: string[] = []; - const codes: number[] = []; - report({ failures: [], notes: [] }, (line) => lines.push(line), (code) => codes.push(code)); - assert.deepEqual(lines, ["verify-release-publish-attestation: every publish invocation is attested."]); - assert.deepEqual(codes, []); -}); - -test("runIfMain runs only as the entry point, and reports when it does", () => { - const root = trackedFixture({ ".github/workflows/release.yml": ` ${ATTESTED}`, "package.json": "{}" }); - const previous = process.exitCode; + let exitCode = 0; + report(verify(root), (line) => lines.push(line), (code) => { exitCode = code; }); + assert.equal(exitCode, 0, `the gate must pass on this repository:\n${lines.join("\n")}`); + assert.ok(lines.some((line) => line.includes("every publish invocation is attested"))); +}); + +test("the launcher runs only as the process entry point", () => { + // A bare `if` at module scope leaves its own body unreachable from any + // in-process test, which is how an entry point quietly stops running. + // A real path that is not this module: isMainInvocation resolves the argv + // entry, so a nonexistent one throws rather than answering the question. + assert.equal(runIfMain(["node", resolve(root, "package.json")], import.meta.url, root), false); +}); + +test("the launcher runs the gate and sets a failing exit code on an unattested publish when it is the entry point", () => { + // The positive branch: argv[1] and moduleUrl both resolve to the launcher's + // own path, so isMainInvocation answers true and runIfMain executes the gate + // for real — writing to process.stdout and setting process.exitCode. Against + // a fixture with an unattested publish, the gate must set exit code 1 and + // return true. A regression that removes the `process.exitCode = code` + // assignment from the launcher, or that makes isMainInvocation return false + // for a real invocation, leaves the gate silently exiting 0 on an unattested + // publish — exactly the failure this test's own comment warns about. + const launcherPath = resolve(root, "scripts/verify-release-publish-attestation.ts"); + const launcherUrl = pathToFileURL(launcherPath).href; + + // A fixture repository with one unattested publish, tracked by git so + // verify(root) — which calls `git ls-files` — discovers it. + const fixture = mkdtempSync(resolve(tmpdir(), "pm-github-attestation-fixture-")); try { - // isMainInvocation canonicalises both sides, so a non-entry argument must - // name a file that exists; a missing path is a different failure entirely. - assert.equal(runIfMain(["node", "scripts/main-invocation.ts"], pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, root), false); - assert.equal( - runIfMain( - ["node", "scripts/verify-release-publish-attestation.ts"], - pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, - root, - ), - true, + mkdirSync(resolve(fixture, ".github/workflows"), { recursive: true }); + writeFileSync( + resolve(fixture, ".github/workflows/release.yml"), + ["jobs:", " release:", " steps:", " - run: |", " npm publish --access public"].join("\n") + "\n", + ); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q"], { cwd: fixture }); + execFileSync("git", ["add", ".github/workflows/release.yml"], { cwd: fixture }); + execFileSync( + "git", + ["-c", "user.email=test@example.com", "-c", "user.name=test", "commit", "-q", "-m", "fixture"], + { cwd: fixture }, ); - assert.equal(process.exitCode, previous, "an attested tree must not set a failing exit code"); - const failing = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); + + const savedExitCode = process.exitCode; try { - runIfMain( - ["node", "scripts/verify-release-publish-attestation.ts"], - pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, - failing, - ); - assert.equal(process.exitCode, 1, "an unattested tree must set a failing exit code"); + const ran = runIfMain(["node", launcherPath], launcherUrl, fixture); + assert.equal(ran, true, "the launcher must run the gate when it is the entry point"); + assert.equal(process.exitCode, 1, "an unattested publish must set a failing exit code"); } finally { - rmSync(failing, { recursive: true, force: true }); + process.exitCode = savedExitCode; } } finally { - process.exitCode = previous; - rmSync(root, { recursive: true, force: true }); + rmSync(fixture, { recursive: true, force: true }); } }); -test("a package runner is a wrapper, so the publish behind its own options is still audited", () => { - // Greptile raised the wrapper class generally: a publish reached through an - // interpreter or a runner escapes a scan that reads only the first word. The - // runners differ from `env` and `sudo` in that they carry their own options - // before the program, so skipping the wrapper word alone is not enough. - for (const wrapped of [ - "npx npm publish --provenance", - "npx --yes npm publish --provenance", - "bunx --bun npm publish --provenance", - "pnpx -y npm publish --provenance", - ]) { - assert.equal( - publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, - 1, - wrapped, - ); - } - // The same shape without the flag must fail, or the pass above proves nothing. - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: " npx --yes npm publish\n" }]) - .failures.length, - 1, - ); -}); - -test("a runner spelled as two words is consumed only when its second word completes it", () => { - for (const wrapped of ["pnpm dlx npm publish --provenance", "yarn exec npm publish --provenance", "bun x npm publish --provenance"]) { - assert.equal( - publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, - 1, - wrapped, - ); - } - // Consuming the head word unconditionally would re-point an unrelated command - // at its first argument, so a non-matching second word leaves it alone. - assert.equal(commandName(onlyCommand("pnpm install npm publish")), "pnpm"); - assert.equal(commandName(onlyCommand("pnpm")), "pnpm"); - assert.equal(commandName(onlyCommand("bun run build")), "build"); - // And the unflagged two-word form must still fail. - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: " pnpm dlx npm publish\n" }]) - .failures.length, - 1, - ); -}); - -test("an option in first position names the command it is written on, not one of its arguments", () => { - // Skipping option words is bounded to wrappers on purpose. Were it - // unconditional, a command whose own first word is an option would be - // re-pointed at an argument, and `--flag npm publish` would read as a publish - // that nothing in the tree actually runs. - assert.equal(commandName(onlyCommand("--yes npm publish")), "--yes"); - assert.deepEqual( - commandArguments(onlyCommand("--yes npm publish")).map((token) => token.value), - ["npm", "publish"], - ); - // A wrapper with nothing after it names no program rather than throwing. - assert.equal(commandName(onlyCommand("npx")), undefined); - assert.deepEqual(commandArguments(onlyCommand("npx")), []); - // A quoted option after a wrapper is a literal argument, not the wrapper's flag. - assert.equal(commandName(onlyCommand(`npx "--yes"`)), "--yes"); -}); - -test("a redirection and its target are not command words", () => { - // Greptile: `> /dev/null npm publish` runs npm, but a scan reading words in - // order sees `>` as the program and audits nothing. - const cases = [ - "> /dev/null npm publish", - ">/dev/null npm publish", - "2>/dev/null npm publish", - "2> /dev/null npm publish", - "&> /dev/null npm publish", - "npm publish > /dev/null", - "npm publish 2>&1", - ]; - for (const text of cases) { - assert.equal(commandName(onlyCommand(text)), "npm", text); - } - // The publish is still audited beside an attested sibling, which is the shape - // that made this a bypass rather than a curiosity. - const withSibling = { - file: "release.yml", - text: " npm publish --provenance\n > /dev/null npm publish\n", +/** + * Reproduce the shebang rule this file's own docstring relies on. + * + * The launcher deliberately carries no shebang, and the reason is a concrete + * claim about the auditor: a shebang naming a SHELL interpreter makes the + * file's body shell, at which point this file's prose - which necessarily names + * the command it is guarding - reads as an unattested publish. The claim is + * reproduced here rather than asserted, because an earlier wording of it said + * ANY shebang had that effect, and that is not what the auditor does: a shebang + * says a file executes, not that it executes as shell. + */ +test("only a shebang naming a shell interpreter pulls this file into the scan", () => { + const body = readFileSync(resolve(root, "scripts/verify-release-publish-attestation.ts"), "utf8"); + const scannedAsShell = (shebang: string): boolean => { + const dir = mkdtempSync(resolve(tmpdir(), "shebang-")); + try { + execFileSync("git", ["init", "-q"], { cwd: dir }); + mkdirSync(resolve(dir, "scripts"), { recursive: true }); + writeFileSync(resolve(dir, "scripts/verify-release-publish-attestation.ts"), shebang + body); + execFileSync("git", ["add", "-A"], { cwd: dir }); + // The file is reported by name only when the auditor read its body as + // shell; otherwise the only failure is that the throwaway repository + // contains no publish at all. + return verify(dir).failures.some((failure) => failure.includes("scripts/verify-release-publish-attestation.ts")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }; - assert.equal(auditPublishAttestation([withSibling]).failures.length, 1); -}); - -test("a shell keyword introduces a command rather than being one", () => { - for (const text of ["if npm publish", "while npm publish", "until npm publish", "! npm publish"]) { - assert.equal(commandName(onlyCommand(text)), "npm", text); - } - // `npm exec` is a runner like `pnpm dlx`, with or without the `--` separator. - assert.equal(commandName(onlyCommand("npm exec -- npm publish")), "npm"); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n if npm publish; then echo ok; fi\n", - }]).failures.length, - 1, - ); -}); - -test("a command held in a scalar is expanded, so the assignment is where the publish is found", () => { - const scalars = shellScalars('CMD="npm publish"\nOTHER=\'npm publish --provenance\'\nBARE=npm\n'); - assert.equal(scalars.get("CMD"), "npm publish"); - assert.equal(scalars.get("OTHER"), "npm publish --provenance"); - assert.equal(scalars.get("BARE"), "npm", "an unquoted single-word value can hold a command name"); - assert.equal(expandScalars("$CMD", scalars), "npm publish"); - assert.equal(expandScalars("${CMD}", scalars), "npm publish"); - assert.equal(expandScalars("$UNKNOWN", scalars), "$UNKNOWN", "an unknown name is left in place, not erased"); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: ' npm publish --provenance\n CMD="npm publish"\n $CMD\n', - }]).failures.length, - 1, - ); -}); - -test("a workflow key carries the command as its value, and is not the command", () => { - // Workflow files are scanned as raw text, so a YAML key is a word like any - // other: `run: npm publish` read `run:` as the program and audited nothing. - assert.equal(commandName(onlyCommand("run: npm publish")), "npm"); - assert.equal(commandName(onlyCommand("- run: npm publish")), "npm"); - // Only a LEADING key is consumed, so an argument that ends in a colon is not. - assert.equal(commandName(onlyCommand("echo label:")), "echo"); - assert.deepEqual( - commandArguments(onlyCommand("echo label: value")).map((token) => token.value), - ["label:", "value"], - ); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n - run: npm publish\n", - }]).failures.length, - 1, - ); -}); - -test("a quoted parenthesis inside a substitution is a literal, not its delimiter", () => { - // Counting it closed the substitution early and truncated the body, so the - // publish after it was never scanned at all. - const result = auditPublishAttestation([{ - file: "release.yml", - text: ' npm publish --provenance\n x=$(echo ")" && npm publish)\n', - }]); - assert.equal(result.failures.length, 1); -}); - -test("one package script cannot continue into the next", () => { - // A body ending in a backslash was joined to the following script, so a - // script beginning `--provenance` lent its flag to the unattested publish - // that ended the script before it. - const manifest = JSON.stringify({ scripts: { a: "npm publish \\", b: "--provenance echo done" } }); - assert.equal(manifestCommandLines(manifest), "npm publish \n--provenance echo done"); - assert.equal(auditPublishAttestation([{ file: "package.json", text: manifest }]).failures.length, 1); -}); - -test("npm selects a workspace with a flag, so a word after it does not excuse a publish", () => { - // `workspace` was listed as a runner subcommand, which meant a `publish` - // written after it was never audited. npm has no such subcommand. - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n npm workspace pkg publish\n", - }]).failures.length, - 1, - ); - // A real runner subcommand still short-circuits: `npm run publish` runs a - // script named publish and publishes nothing. - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: " npm run publish\n" }), - [], - ); -}); - -test("a substitution tracks both quote kinds and an escape while finding its close", () => { - // Each arm of the quote tracking has to be exercised or a later edit can - // remove one without the suite noticing. - const single = auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n x=$(echo ')' && npm publish)\n", - }]); - assert.equal(single.failures.length, 1, "a single-quoted paren is a literal"); - const escaped = auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n x=$(echo \\) && npm publish)\n", - }]); - assert.equal(escaped.failures.length, 1, "an escaped paren is a literal"); - // A double quote inside single quotes is literal, and vice versa. - assert.deepEqual( - tokenizeCommands(`x=$(echo '"' && npm publish --provenance)`).some( - (command) => commandName(command) === "npm", - ), - true, - ); -}); - -test("a scalar carrying a substitution or a quote of its own is never inlined", () => { - // This is a regression test for a defect this gate introduced in itself. - // Inlining every quoted assignment put values like `x="$(node -p …)"` into - // unrelated commands, which injected an unbalanced parenthesis, and the scan - // then reported a publish that was not there while losing the one that was -- - // a false verdict in both directions. Every package's release gate failed. - const scalars = shellScalars([ - 'CMD="npm publish"', - 'SUBST="$(node -p 1)"', - 'TICK="`date`"', - 'QUOTED="he said \'hi\'"', - 'PAREN="a (b)"', - ].join("\n")); - assert.equal(scalars.get("CMD"), "npm publish", "a plain literal is still resolved"); - for (const name of ["SUBST", "TICK", "QUOTED", "PAREN"]) { - assert.equal(scalars.get(name), undefined, `${name} must not be inlined`); - } - // The shape that actually broke: an attested publish elsewhere in the file - // must still be found, and no phantom invented. - const text = [ - ' pkg_name="$(node -p "require(\'./package.json\').name")"', - " # `npm publish` - mentioned in a comment", - " npm publish --access public --provenance --ignore-scripts", - ].join("\n"); - const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); - assert.deepEqual(found, ["npm publish --access public --provenance --ignore-scripts"]); -}); - -test("a substitution's quote state does not leak across its lines", () => { - // Workflow prose carries apostrophes inside double-quoted messages. If an - // unbalanced one persisted past the newline, every later parenthesis would - // look quoted and the substitution would run on past its real close, - // swallowing unrelated commands into it. - const text = [ - " x=$(echo \"GitHub's endpoint\"", - " npm publish)", - " npm publish --provenance", - ].join("\n"); - const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); - assert.ok(found.includes("npm publish"), "the publish inside the substitution is still found"); - assert.ok(found.includes("npm publish --provenance"), "and the one after it is not swallowed"); -}); - -test("a publish routed through an unquoted scalar is audited, not hidden by an attested sibling", () => { - // `NPM=npm` was skipped because only quoted assignments were indexed, so - // `$NPM publish` resolved to nothing and was never recognised as a publish. - // The workflow's own legitimate publish then satisfied the non-vacuity check - // and the whole audit reported a clean pass -- the gate was blind rather than - // wrong, which is the failure mode that gets a gate trusted while it is not - // looking. Only the variable-routed invocation may fail here. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - ` npm publish --access public ${ATTESTATION_FLAG}`, - " NPM=npm", - " $NPM publish --access public", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "the variable-routed publish must be audited"); - assert.match(result.failures[0]!, /does not enable --provenance/); -}); - -test("scalar bindings are resolved at their source position", () => { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "NPM=npm", - "$NPM publish", - "NPM=echo", - "npm publish --provenance", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "a later reassignment cannot rewrite the earlier publish"); - - for (const operator of ["&&", "||"]) { - const controlled = auditPublishAttestation([{ - file: "release.yml", - text: `NPM=npm ${operator} $NPM publish\nnpm publish --provenance\n`, - }]); - assert.equal(controlled.failures.length, 1, `${operator} preserves the assignment before the invocation`); - } - - const sameLine = auditPublishAttestation([{ - file: "release.yml", - text: "CMD='npm publish --provenance'; CMD='npm publish'; $CMD\nnpm publish --provenance\n", - }]); - assert.equal(sameLine.failures.length, 1, "same-line reassignment applies before the following command"); -}); - -test("assignment-shaped heredoc content cannot attest a later publish", () => { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "cat < { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "flags=( FLAG=--provenance )", - '"${flags[@]}"', - "npm publish $FLAG", - "npm publish --provenance", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "expanded array data cannot create a shell variable"); -}); - -test("an assignment-only list persists every literal binding", () => { - assert.equal(shellScalars("NPM=npm UNUSED=x\n").get("NPM"), "npm"); - assert.equal(shellScalars("NPM=npm >/dev/null\n").get("NPM"), "npm", - "redirection-only assignment commands persist their bindings"); - assert.equal(shellScalars("NPM=npm UNUSED=x$(printf y)\n").get("NPM"), "npm", - "a later dynamic binding does not discard an earlier literal binding"); - assert.equal(shellScalars("UNUSED=x$(printf y) NPM=npm\n").get("NPM"), "npm", - "an earlier dynamic binding does not hide a later literal binding"); - assert.equal(shellScalars('UNUSED="$(printf y)" NPM=npm\n').get("NPM"), "npm", - "a quoted dynamic binding does not swallow a later literal binding"); - assert.equal(shellScalars("NPM=npm UNUSED=x$(printf y) echo no\n").get("NPM"), undefined, - "a command after the bindings makes all of them temporary"); - for (const assignment of ["NPM=npm UNUSED=x", "NPM=npm UNUSED=x$(printf y)", "UNUSED=x$(printf y) NPM=npm", 'UNUSED="$(printf y)" NPM=npm', "NPM=npm >/dev/null"]) { - const result = auditPublishAttestation([{ - file: "release.yml", - text: `${assignment}\n$NPM publish\nnpm publish --provenance\n`, - }]); - assert.equal(result.failures.length, 1, "the publish routed through the first binding is audited"); - } -}); - -test("an assignment the shell never makes is not indexed", () => { - // Scalars used to be read straight out of the raw text, which indexed three - // things the shell does not assign. The middle one is a gate bypass: a name - // defined only in a COMMENT was inlined into a later command, so an - // unattested publish borrowed `--provenance` from a comment and passed. - assert.equal(shellScalars("# FLAG=--provenance\nnpm publish $FLAG\n").get("FLAG"), undefined, - "a name in a comment is not an assignment"); - assert.equal(shellScalars('# CMD="npm publish"\n').get("CMD"), undefined, - "quoting it in a comment does not make it an assignment either"); - assert.equal(shellScalars('echo "config NPM=npm"\n').get("NPM"), undefined, - "a name inside a quoted argument is not an assignment"); - assert.equal(shellScalars("NPM=npm$SUFFIX\n").get("NPM"), undefined, - "a value continuing into an expansion is not a literal, and must not be indexed by its prefix"); - assert.equal(shellScalars('"NPM=npm" publish\n').get("NPM"), undefined, - "quoting the whole word makes it a command name, not a binding"); - - // The bypass, end to end: without the fix this audit returns no failures. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - " # FLAG=--provenance", - " npm publish --access public $FLAG", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "a publish flagged only from a comment is unattested"); - assert.match(result.failures[0]!, /does not enable --provenance/); -}); - -test("a scalar is taken only from a line that is exactly one literal assignment", () => { - assert.equal(shellScalars("NPM=npm\n").get("NPM"), "npm"); - assert.equal(shellScalars('CMD="npm publish"\n').get("CMD"), "npm publish"); - assert.equal(shellScalars("OTHER='npm publish --provenance'\n").get("OTHER"), "npm publish --provenance"); - assert.equal(shellScalars("NPM=npm\\ publish\n").get("NPM"), "npm publish", - "an escape is honoured, so one word can still hold a command"); - assert.equal(shellScalars('NPM=npm; "$NPM" publish\n').get("NPM"), "npm", - "a semicolon ends the assignment, and the shell keeps the binding after it"); - assert.equal(shellScalars("export NPM=npm\n").get("NPM"), "npm", - "export still declares a persistent binding"); - assert.equal(shellScalars("NPM=npm # explanation\n").get("NPM"), "npm", - "a trailing comment does not stop the line being an assignment"); - assert.equal(shellScalars("NPM=npm\r\n").get("NPM"), "npm", - "a CRLF line ending does not hide the assignment"); - // Refusing these left `$NPM` unresolved, and an attested publish elsewhere in - // the file then satisfied the non-vacuity guard -- so being too strict passes - // an unattested publish exactly as being too loose does. - assert.equal(shellScalars("CMD='npm publish \\--provenance'\n").get("CMD"), "npm publish \\--provenance", - "single quotes make a backslash literal, so the value is not unescaped"); - assert.equal(shellScalars("# a; FLAG=--provenance\n").get("FLAG"), undefined, - "a semicolon inside a comment does not expose an assignment"); - - // A command-scoped assignment binds only for the command it precedes; the - // shell does not keep it afterwards, so neither may this map. Storing it - // rewrote a LATER unattested publish into an attested-looking one. - assert.equal(shellScalars("FLAG=--provenance some-command\n").get("FLAG"), undefined, - "a temporary assignment does not outlive its command"); - assert.equal(shellScalars("$(FLAG=--provenance)\n").get("FLAG"), undefined, - "a binding made inside a subshell is not visible to the outer shell"); - assert.equal(shellScalars("NPM=npm$(printf foo)\n").get("NPM"), undefined, - "a literal prefix in front of a substitution is not the value"); - - // Both leaks were false passes end to end, not merely wrong map entries. - for (const text of [ - [" FLAG=--provenance | cat", " npm publish --access public $FLAG"], - [" FLAG=--provenance |& cat", " npm publish --access public $FLAG"], - [" false && FLAG=--provenance", " npm publish --access public $FLAG"], - [" false || NPM=npm", " $NPM publish --access public"], - [" export NPM=npm FOO", " $NPM publish --access public"], - [" FLAG=--provenance &", " npm publish --access public $FLAG"], - [" # ignored; FLAG=--provenance", " npm publish --access public $FLAG"], - [" FLAG=--provenance some-command", " npm publish --access public $FLAG"], - [" $(FLAG=--provenance)", " npm publish --access public $FLAG"], - ]) { - const result = auditPublishAttestation([{ file: "release.yml", text: text.join("\n") }]); - assert.equal(result.failures.length, 1, `a publish flagged only by ${text[0]!.trim()} is unattested`); - assert.match(result.failures[0]!, /does not enable --provenance|unresolved-shell-variable/); - } -}); -test("a read-write redirection does not turn its target into the command", () => { - // `<>` is one operator, not `<` followed by `>`. Unnamed, it was read as a - // joined redirection that consumes no target, so `/dev/null` became the - // command word and the real publish after it was never audited -- while an - // attested publish elsewhere satisfied the non-vacuity guard, so the whole - // audit reported clean. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - ` npm publish --access public ${ATTESTATION_FLAG}`, - " <> /dev/null npm publish --access public", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "the redirected publish must still be audited"); - assert.match(result.failures[0]!, /does not enable --provenance/); + assert.equal(scannedAsShell("#!/bin/bash\n"), true, "a bash shebang makes this file shell input"); + assert.equal(scannedAsShell("#!/usr/bin/env sh\n"), true, "an env sh shebang makes this file shell input"); + assert.equal(scannedAsShell("#!/usr/bin/env node\n"), false, "a node shebang does not make this file shell input"); + assert.equal(scannedAsShell(""), false, "with no shebang the file is not shell input, which is why it has none"); });