From 86c4aadc3334c4833cbd468209bde9ac20e44a55 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Wed, 22 Jul 2026 17:38:38 +0800 Subject: [PATCH 1/2] Harden Public Preview pre-visibility gates --- .github/ACTIONLINT.md | 11 +- .github/workflows/ci.yml | 3 + AGENTS.md | 3 + CHANGELOG.md | 4 + CONTRIBUTING.md | 7 +- README.md | 9 +- RELEASING.md | 31 +- package-lock.json | 619 ++++++++++++++++++++++++++- package.json | 4 +- scripts/check-secrets.mjs | 584 +++++++++++++++++++++++-- scripts/check-self-contained.mjs | 93 ++-- scripts/check-standalone-content.mjs | 283 +++++++++++- scripts/release-validation.mjs | 409 +++++++++++++++++- scripts/test-live-smoke-contract.mjs | 144 ++++++- scripts/verify.mjs | 1 + tests/check-secrets.test.mjs | 294 +++++++++++++ tests/ci-workflow.test.mjs | 47 ++ tests/release-validation.test.mjs | 397 ++++++++++++++++- tests/self-contained.test.mjs | 143 +++++++ tests/standalone-content.test.mjs | 257 +++++++++++ 20 files changed, 3205 insertions(+), 138 deletions(-) create mode 100644 tests/check-secrets.test.mjs create mode 100644 tests/ci-workflow.test.mjs create mode 100644 tests/self-contained.test.mjs diff --git a/.github/ACTIONLINT.md b/.github/ACTIONLINT.md index 7939252..be0be2a 100644 --- a/.github/ACTIONLINT.md +++ b/.github/ACTIONLINT.md @@ -14,8 +14,9 @@ directory and verifies it against `.github/actionlint-checksums.txt` before execution. Set `ACTIONLINT_BIN` to use a separately installed exact-version binary. -`actionlint` statically validates workflow syntax, expressions, and embedded -shell. A passing local run does not emulate GitHub-hosted runners, exercise -repository settings, prove secret or environment configuration, perform a live -CometAPI request, or prove npm Trusted Publishing. Those remain separate remote -evidence. +`actionlint` statically validates workflow syntax and expressions. Its optional +ShellCheck integration supplies additional embedded-shell diagnostics only when +ShellCheck is available on the host. A passing local run does not emulate +GitHub-hosted runners, exercise repository settings, prove secret or environment +configuration, perform a live CometAPI request, or prove npm Trusted Publishing. +Those remain separate remote evidence. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b47bf5b..45c4b7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,7 @@ jobs: - name: Check out the repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + fetch-depth: 0 persist-credentials: false - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -87,6 +88,8 @@ jobs: run: npm test - name: Scan tracked material for secret patterns run: npm run test:secrets + - name: Scan current and historical standalone content + run: npm run check:standalone-content - name: Build ESM and CommonJS outputs run: npm run build - name: Validate the package shape diff --git a/AGENTS.md b/AGENTS.md index 8d4a3f7..8bf6fdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,11 +171,14 @@ npm test npm run typecheck npm run lint npm run format:check +npm run test:secrets npm run test:package +npm run test:live-contract npm run test:fixtures npm run test:compat npm run check:standalone-content npm run check:self-contained +npm run check:public-preview npm run actionlint npm run verify ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 80fb474..82f7983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ No user-visible changes are currently recorded beyond the initial alpha scope. applicable gate. - Added standalone-content scanning to the aggregated Public Preview gate and encoded the protected, opt-in npm token bootstrap for `0.1.0-alpha.1` only. +- Hardened pre-visibility evidence by scanning tracked files and reachable Git + history for credential patterns, verifying an exact clean `HEAD` copy, + requiring substantive public documentation, and exercising every documented + live-smoke stream failure state with mocked transport. - Made the release workflow the sole npm dist-tag source: prereleases use `next`, stable releases use `latest`, and the package manifest has no static dist-tag. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa8eed..f2184ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,11 +36,14 @@ npm test npm run typecheck npm run lint npm run format:check +npm run test:secrets npm run test:package npm run test:live-contract npm run test:fixtures npm run test:compat +npm run check:standalone-content npm run check:self-contained +npm run check:public-preview npm run actionlint npm run verify ``` @@ -49,7 +52,9 @@ npm run verify transport and must not require `COMETAPI_KEY` or access the production API. `npm run actionlint` obtains the checksum-pinned tool when it is not already installed, then validates workflow syntax and static policy locally. It is not -evidence that GitHub Actions ran the workflows. +evidence that GitHub Actions ran the workflows. Run the self-containment gate +from a clean tracked worktree; it materializes the exact `HEAD` tree and excludes +untracked local files from the isolated verification copy. ## Tests and compatibility claims diff --git a/README.md b/README.md index 4807d84..c6e13bb 100644 --- a/README.md +++ b/README.md @@ -183,11 +183,14 @@ npm test npm run typecheck npm run lint npm run format:check +npm run test:secrets npm run test:package npm run test:live-contract npm run test:fixtures npm run test:compat +npm run check:standalone-content npm run check:self-contained +npm run check:public-preview npm run actionlint npm run verify ``` @@ -196,7 +199,11 @@ npm run verify use mocked transport and require no production credential. `npm run actionlint` downloads and checksum-verifies the repository-pinned version when needed, then performs static workflow validation. It does not prove that a workflow ran -successfully on GitHub Actions. +successfully on GitHub Actions. The secret gate scans the current tracked tree +plus reachable Git blobs, commit and tag messages, and historical paths without +printing matched values. The self-containment gate requires a clean tracked +worktree and verifies an exact materialized copy of `HEAD` in an empty temporary +parent. ## Project status diff --git a/RELEASING.md b/RELEASING.md index d7209be..d35295d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -99,12 +99,14 @@ npm test npm run typecheck npm run lint npm run format:check +npm run test:secrets npm run test:package npm run test:live-contract npm run test:fixtures npm run test:compat npm run check:standalone-content npm run check:self-contained +npm run check:public-preview npm run actionlint npm run verify ``` @@ -122,9 +124,22 @@ install the exact artifact, then upload that same file. `npm run test:compat` covers the minimum, locked, and applicable canary dependency lanes with ESM and CommonJS runtime checks plus `.mts` and `.cts` consumer type checks. -`npm run check:self-contained` copies repository files into an empty temporary -parent, scans documentation and configuration for outside-root dependencies, and -runs the documented offline setup and tests from the copied root. +`npm run test:secrets` fails on a shallow Git clone and scans the current tracked +tree plus reachable Git blobs, commit and tag messages, and historical paths. +It reports only the rule and a safe object identifier or path hash rather than a +matched value. + +`npm run check:standalone-content` fails on a shallow Git clone, materializes +every unique tracked tree reachable from all local refs and `HEAD` without +honoring export exclusions, and reports the commit and tree for every +outside-root or private-content violation. In the isolated self-containment copy +it scans that exact file tree because Git metadata is intentionally absent. + +`npm run check:self-contained` requires a clean tracked worktree, materializes +the exact `HEAD` tree into an empty temporary parent, scans documentation and +configuration for outside-root dependencies, and runs the documented offline +setup and tests from the copied root. Untracked local files cannot satisfy a +missing repository dependency. `npm run test:live-contract` uses mocked transport to prove the bounded live runner rejects empty Chat results and failed, incomplete, or unterminated @@ -366,4 +381,14 @@ Every release candidate records these evidence layers separately: - npm ownership and Trusted Publisher evidence - Tag, release, provenance, publication, and post-publication evidence +For a pre-visibility closeout, use the merged private pull request as the +durable evidence record because a commit cannot contain its own final object +ID. After merge, add one timeline comment that records the exact final `main` +commit, the complete local gate results for that commit, pull-request and +default-branch Node.js 22/24 CI URLs, failed dependency-update dispositions, +the read-only private/public-only configuration audit, and every skipped or +unknown boundary. The comment must explicitly confirm that no visibility, +repository-rule, environment, secret, live API, tag, release, or registry state +was changed. + Only a publicly installed and verified npm artifact may be called released. diff --git a/package-lock.json b/package-lock.json index dac8a5a..34f6c6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "@types/node": "^24.10.13", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "mdast-util-from-markdown": "^2.0.3", "prettier": "^3.8.1", "publint": "^0.3.17", "tsup": "^8.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "yaml": "^2.9.0" }, "engines": { "node": "^22.0.0 || ^24.0.0" @@ -1572,6 +1574,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1593,6 +1605,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1603,6 +1632,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", @@ -2198,6 +2234,17 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2365,6 +2412,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2372,6 +2433,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2382,6 +2453,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3400,6 +3485,508 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4414,6 +5001,20 @@ "node": ">=4" } }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4683,6 +5284,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "16.2.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", diff --git a/package.json b/package.json index fd29f15..9cbfd2f 100644 --- a/package.json +++ b/package.json @@ -82,11 +82,13 @@ "@types/node": "^24.10.13", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "mdast-util-from-markdown": "^2.0.3", "prettier": "^3.8.1", "publint": "^0.3.17", "tsup": "^8.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "yaml": "^2.9.0" } } diff --git a/scripts/check-secrets.mjs b/scripts/check-secrets.mjs index b3c5db6..81567f2 100644 --- a/scripts/check-secrets.mjs +++ b/scripts/check-secrets.mjs @@ -1,6 +1,9 @@ -import assert from "node:assert/strict"; -import { readdirSync, readFileSync } from "node:fs"; -import { extname, join, relative } from "node:path"; +import { isUtf8 } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readlinkSync, readdirSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ROOT } from "./lib.mjs"; @@ -12,44 +15,557 @@ const ignoredDirectories = new Set([ "dist", "node_modules", ]); -const textExtensions = new Set([ - ".cjs", - ".js", - ".json", - ".md", - ".mjs", - ".ts", - ".yaml", - ".yml", -]); -const forbidden = [ - /(?:^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}/, - /_authToken\s*=\s*[^$\s{][^\s]*/, - /npm_[A-Za-z0-9]{24,}/, +const rules = [ + { + id: "openai-style-key", + pattern: /(?:^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}/, + }, + { + id: "npm-auth-token-assignment", + pattern: /_authToken\s*=\s*[^$\s{][^\s]*/, + }, + { id: "npm-access-token", pattern: /npm_[A-Za-z0-9]{24,}/ }, ]; +const gitEnvironment = { + ...process.env, + GIT_NO_LAZY_FETCH: "1", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_OPTIONAL_LOCKS: "0", + LC_ALL: "C", +}; +const gitOutputLimit = 256 * 1024 * 1024; +const blobBatchSize = 64; +const gitObjectTypes = new Set(["blob", "commit", "tag", "tree"]); -function visit(directory) { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; - const path = join(directory, entry.name); - if (entry.isDirectory()) { - visit(path); - continue; +function runGit(root, args, { allowFailure = false, input } = {}) { + const result = spawnSync("git", args, { + cwd: root, + env: gitEnvironment, + input, + maxBuffer: gitOutputLimit, + }); + + if (result.error) { + throw new Error("Secret scan could not execute Git."); + } + if (result.status !== 0 && !allowFailure) { + throw new Error( + `Secret scan could not inspect Git data (${args[0]} failed).`, + ); + } + return result; +} + +function gitText(root, args, options) { + const output = runGit(root, args, options).stdout; + if (!isUtf8(output)) { + throw new Error("Secret scan cannot safely decode Git metadata as UTF-8."); + } + return output.toString("utf8"); +} + +function addMatches(contents, location, violations) { + const text = contents.toString(isUtf8(contents) ? "utf8" : "latin1"); + + for (const rule of rules) { + if (!rule.pattern.test(text)) continue; + let key; + if (location.blob !== undefined) { + key = `blob:${location.blob}:${rule.id}`; + } else if (location.commit !== undefined) { + key = `commit:${location.commit}:${rule.id}`; + } else if (location.tag !== undefined) { + key = `tag:${location.tag}:${rule.id}`; + } else if (location.pathHash !== undefined) { + key = `path-hash:${location.pathHash}:${rule.id}`; + } else { + key = `path:${location.path}:${rule.id}`; + } + violations.set(key, { ...location, rule: rule.id }); + } +} + +function locationForPath(path) { + const contents = Buffer.from(path, "utf8"); + const text = contents.toString("utf8"); + if (rules.some(({ pattern }) => pattern.test(text))) { + return { + pathHash: createHash("sha256").update(contents).digest("hex"), + }; + } + return { path }; +} + +function reportPath(path) { + const location = locationForPath(path); + return location.pathHash === undefined + ? JSON.stringify(path) + : `sha256:${location.pathHash}`; +} + +function readRegularFile(path, displayPath) { + try { + return readFileSync(path); + } catch { + throw new Error( + `Secret scan could not read path ${reportPath(displayPath)}.`, + ); + } +} + +function readSymbolicLink(path, displayPath) { + try { + return readlinkSync(path, { encoding: "buffer" }); + } catch { + throw new Error( + `Secret scan could not read symbolic link ${reportPath(displayPath)}.`, + ); + } +} + +function scanPath(path, violations) { + const location = locationForPath(path); + addMatches(Buffer.from(path, "utf8"), location, violations); + return location; +} + +function parseBlobBatch(output, expectedIds) { + const blobs = new Map(); + let offset = 0; + + for (const expectedId of expectedIds) { + const headerEnd = output.indexOf(0x0a, offset); + if (headerEnd === -1) { + throw new Error("Secret scan received incomplete Git blob metadata."); + } + const header = output.subarray(offset, headerEnd).toString("ascii"); + const match = /^([0-9a-f]+) blob ([0-9]+)$/.exec(header); + if (match === null || match[1] !== expectedId) { + throw new Error("Secret scan could not read a reachable Git blob."); + } + + const size = Number.parseInt(match[2], 10); + const contentStart = headerEnd + 1; + const contentEnd = contentStart + size; + if ( + !Number.isSafeInteger(size) || + contentEnd >= output.length || + output[contentEnd] !== 0x0a + ) { + throw new Error("Secret scan received incomplete Git blob content."); + } + blobs.set(expectedId, output.subarray(contentStart, contentEnd)); + offset = contentEnd + 1; + } + + if (offset !== output.length) { + throw new Error("Secret scan received unexpected Git blob output."); + } + return blobs; +} + +function readBlobs(root, objectIds, callback) { + for (let start = 0; start < objectIds.length; start += blobBatchSize) { + const batch = objectIds.slice(start, start + blobBatchSize); + const result = runGit(root, ["cat-file", "--batch"], { + input: `${batch.join("\n")}\n`, + }); + const blobs = parseBlobBatch(result.stdout, batch); + for (const objectId of batch) callback(objectId, blobs.get(objectId)); + } +} + +function parseObjectBatch(output, expectedObjects) { + const objects = new Map(); + let offset = 0; + + for (const expected of expectedObjects) { + const headerEnd = output.indexOf(0x0a, offset); + if (headerEnd === -1) { + throw new Error("Secret scan received incomplete Git object metadata."); } - if (!textExtensions.has(extname(entry.name)) && entry.name !== ".npmrc") { - continue; + const header = output.subarray(offset, headerEnd).toString("ascii"); + const match = /^([0-9a-f]+) ([a-z]+) ([0-9]+)$/.exec(header); + if ( + match === null || + match[1] !== expected.objectId || + match[2] !== expected.type + ) { + throw new Error("Secret scan could not read a reachable Git object."); } - const contents = readFileSync(path, "utf8"); - for (const pattern of forbidden) { - assert.doesNotMatch( - contents, - pattern, - `possible credential found in ${relative(ROOT, path)}`, + const size = Number.parseInt(match[3], 10); + const contentStart = headerEnd + 1; + const contentEnd = contentStart + size; + if ( + !Number.isSafeInteger(size) || + contentEnd >= output.length || + output[contentEnd] !== 0x0a + ) { + throw new Error("Secret scan received incomplete Git object content."); + } + objects.set(expected.objectId, output.subarray(contentStart, contentEnd)); + offset = contentEnd + 1; + } + + if (offset !== output.length) { + throw new Error("Secret scan received unexpected Git object output."); + } + return objects; +} + +function readObjects(root, objects, callback) { + for (let start = 0; start < objects.length; start += blobBatchSize) { + const batch = objects.slice(start, start + blobBatchSize); + const result = runGit(root, ["cat-file", "--batch"], { + input: `${batch.map(({ objectId }) => objectId).join("\n")}\n`, + }); + const contentsByObjectId = parseObjectBatch(result.stdout, batch); + for (const object of batch) { + callback(object, contentsByObjectId.get(object.objectId)); + } + } +} + +function trackedEntries(root) { + const output = gitText(root, ["ls-files", "--cached", "--stage", "-z"]); + if (output.length === 0) return []; + + return output + .split("\0") + .filter((entry) => entry.length > 0) + .map((entry) => { + const separator = entry.indexOf("\t"); + const header = entry.slice(0, separator); + const path = entry.slice(separator + 1); + const match = /^(\d{6}) ([0-9a-f]+) ([0-3])$/.exec(header); + if (separator === -1 || match === null || path.length === 0) { + throw new Error("Secret scan received malformed tracked-file data."); + } + if (match[3] !== "0") { + throw new Error( + "Secret scan cannot prove coverage while the Git index is unmerged.", + ); + } + return { mode: match[1], objectId: match[2], path }; + }); +} + +function scanTrackedFiles(root, violations) { + const entries = trackedEntries(root).filter( + ({ mode }) => mode.startsWith("100") || mode === "120000", + ); + const pathsByObjectId = new Map(); + + for (const entry of entries) { + scanPath(entry.path, violations); + const paths = pathsByObjectId.get(entry.objectId) ?? []; + paths.push(entry.path); + pathsByObjectId.set(entry.objectId, paths); + } + readBlobs(root, [...pathsByObjectId.keys()], (objectId, contents) => { + for (const path of pathsByObjectId.get(objectId)) { + addMatches(contents, locationForPath(path), violations); + } + }); + + for (const entry of entries) { + const absolutePath = join(root, entry.path); + let status; + try { + status = lstatSync(absolutePath); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw new Error( + `Secret scan could not inspect tracked path ${reportPath(entry.path)}.`, + ); + } + + if (status.isFile()) { + addMatches( + readRegularFile(absolutePath, entry.path), + locationForPath(entry.path), + violations, + ); + } else if (status.isSymbolicLink()) { + addMatches( + readSymbolicLink(absolutePath, entry.path), + locationForPath(entry.path), + violations, + ); + } else { + throw new Error( + `Secret scan cannot read tracked path ${reportPath(entry.path)} as a regular file or symbolic link.`, + ); + } + } +} + +function reachableObjects(root) { + const head = runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"], { + allowFailure: true, + }); + const revisions = head.status === 0 ? ["--all", "HEAD"] : ["--all"]; + const objects = gitText(root, [ + "rev-list", + "--objects", + ...revisions, + "--no-object-names", + ]) + .trim() + .split("\n") + .filter((objectId) => objectId.length > 0); + const objectsByType = Object.fromEntries( + [...gitObjectTypes].map((type) => [type, new Set()]), + ); + + for (let start = 0; start < objects.length; start += 4096) { + const batch = objects.slice(start, start + 4096); + const types = gitText( + root, + ["cat-file", "--batch-check=%(objectname) %(objecttype)"], + { input: `${batch.join("\n")}\n` }, + ); + const lines = types.trim().split("\n"); + if (lines.length !== batch.length) { + throw new Error("Secret scan received incomplete Git object data."); + } + for (const [index, line] of lines.entries()) { + const match = /^([0-9a-f]+) ([a-z]+)$/.exec(line); + if ( + match === null || + match[1] !== batch[index] || + !gitObjectTypes.has(match[2]) + ) { + throw new Error("Secret scan received malformed Git object data."); + } + objectsByType[match[2]].add(match[1]); + } + } + return Object.fromEntries( + Object.entries(objectsByType).map(([type, objectIds]) => [ + type, + [...objectIds], + ]), + ); +} + +function splitMetadataObject(contents, type, objectId) { + const separator = contents.indexOf("\n\n"); + if (separator === -1) { + throw new Error( + `Secret scan received malformed reachable Git ${type} ${objectId}.`, + ); + } + return { + headers: contents.subarray(0, separator), + message: contents.subarray(separator + 2), + }; +} + +function commitTree(headers, objectId) { + const firstLineEnd = headers.indexOf(0x0a); + const firstLine = headers + .subarray(0, firstLineEnd === -1 ? headers.length : firstLineEnd) + .toString("ascii"); + const match = /^tree ([0-9a-f]+)$/.exec(firstLine); + if (match === null) { + throw new Error( + `Secret scan received malformed reachable Git commit ${objectId}.`, + ); + } + return match[1]; +} + +function taggedTree(headers, objectId) { + const text = headers.toString("latin1"); + const match = /^object ([0-9a-f]+)\ntype ([a-z]+)(?:\n|$)/.exec(text); + if (match === null) { + throw new Error( + `Secret scan received malformed reachable Git tag ${objectId}.`, + ); + } + return match[2] === "tree" ? match[1] : undefined; +} + +function scanTreePaths(root, treeIds, violations) { + for (const tree of treeIds) { + const output = runGit(root, [ + "ls-tree", + "--full-tree", + "-r", + "-t", + "-z", + tree, + ]).stdout; + let offset = 0; + + while (offset < output.length) { + const terminator = output.indexOf(0x00, offset); + if (terminator === -1) { + throw new Error( + `Secret scan received malformed Git tree data for ${tree}.`, + ); + } + const entry = output.subarray(offset, terminator); + const separator = entry.indexOf(0x09); + const header = entry.subarray(0, separator).toString("ascii"); + const path = entry.subarray(separator + 1); + if ( + separator === -1 || + !/^[0-7]{6} (?:blob|commit|tree) [0-9a-f]+$/.test(header) || + path.length === 0 + ) { + throw new Error( + `Secret scan received malformed Git tree data for ${tree}.`, + ); + } + addMatches( + path, + { pathHash: createHash("sha256").update(path).digest("hex") }, + violations, ); + offset = terminator + 1; } } } -visit(ROOT); -console.log("Secret-pattern scan passed without printing candidate values."); +function scanHistory(root, violations) { + const objects = reachableObjects(root); + readBlobs(root, objects.blob, (blob, contents) => { + addMatches(contents, { blob }, violations); + }); + + const rootTrees = new Set(); + const metadataObjects = [ + ...objects.commit.map((objectId) => ({ objectId, type: "commit" })), + ...objects.tag.map((objectId) => ({ objectId, type: "tag" })), + ]; + readObjects(root, metadataObjects, ({ objectId, type }, contents) => { + const metadata = splitMetadataObject(contents, type, objectId); + addMatches(metadata.message, { [type]: objectId }, violations); + if (type === "commit") { + rootTrees.add(commitTree(metadata.headers, objectId)); + } else { + const tree = taggedTree(metadata.headers, objectId); + if (tree !== undefined) rootTrees.add(tree); + } + }); + + scanTreePaths( + root, + rootTrees.size > 0 ? [...rootTrees] : objects.tree, + violations, + ); +} + +function scanCopiedFiles(directory, root, violations) { + const directoryPath = relative(root, directory) || "."; + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + throw new Error( + `Secret scan could not read directory ${reportPath(directoryPath)}.`, + ); + } + for (const entry of entries) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + scanCopiedFiles(path, root, violations); + } else if (entry.isFile()) { + const displayPath = relative(root, path); + const location = scanPath(displayPath, violations); + addMatches(readRegularFile(path, displayPath), location, violations); + } else if (entry.isSymbolicLink()) { + const displayPath = relative(root, path); + const location = scanPath(displayPath, violations); + addMatches(readSymbolicLink(path, displayPath), location, violations); + } + } +} + +function ensureCompleteRepository(root) { + const repository = runGit(root, ["rev-parse", "--is-inside-work-tree"], { + allowFailure: true, + }); + if ( + repository.status !== 0 || + repository.stdout.toString("utf8").trim() !== "true" + ) { + throw new Error( + "Secret scan requires a Git repository unless COMETAPI_SELF_CONTAINMENT=1.", + ); + } + const shallow = gitText(root, ["rev-parse", "--is-shallow-repository"]); + if (shallow.trim() !== "false") { + throw new Error( + "Secret scan requires complete Git history; shallow repositories are rejected.", + ); + } +} + +export function collectSecretViolations(root, { mode = "git" } = {}) { + const violations = new Map(); + if (mode === "files") { + scanCopiedFiles(root, root, violations); + } else if (mode === "git") { + ensureCompleteRepository(root); + scanTrackedFiles(root, violations); + scanHistory(root, violations); + } else { + throw new Error(`Unknown secret scan mode: ${mode}`); + } + return [...violations.values()].sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)), + ); +} + +export function formatSecretViolations(violations) { + return [ + `Secret-pattern scan found ${String(violations.length)} possible credential(s):`, + ...violations.map((violation) => { + let location; + if (violation.blob !== undefined) { + location = `blob=${violation.blob}`; + } else if (violation.commit !== undefined) { + location = `commit=${violation.commit}`; + } else if (violation.tag !== undefined) { + location = `tag=${violation.tag}`; + } else if (violation.pathHash !== undefined) { + location = `path-sha256=${violation.pathHash}`; + } else { + location = `path=${JSON.stringify(violation.path)}`; + } + return `- rule=${violation.rule} ${location}`; + }), + ].join("\n"); +} + +export function checkSecrets(root, options) { + const violations = collectSecretViolations(root, options); + if (violations.length > 0) { + throw new Error(formatSecretViolations(violations)); + } +} + +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + try { + checkSecrets(ROOT, { + mode: process.env.COMETAPI_SELF_CONTAINMENT === "1" ? "files" : "git", + }); + console.log( + "Secret-pattern scan passed without printing candidate values.", + ); + } catch (error) { + console.error( + error instanceof Error ? error.message : "Secret scan failed.", + ); + process.exitCode = 1; + } +} diff --git a/scripts/check-self-contained.mjs b/scripts/check-self-contained.mjs index f528c8d..a91551f 100644 --- a/scripts/check-self-contained.mjs +++ b/scripts/check-self-contained.mjs @@ -1,5 +1,5 @@ -import { cpSync } from "node:fs"; -import { basename, join } from "node:path"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ROOT, @@ -7,38 +7,71 @@ import { removeTemporaryDirectory, run, } from "./lib.mjs"; -import { - collectStandaloneContentViolations, - STANDALONE_CONTENT_EXCLUSIONS, -} from "./standalone-content.mjs"; +import { collectStandaloneContentViolations } from "./standalone-content.mjs"; +import { materializeStandaloneTree } from "./check-standalone-content.mjs"; + +export function materializeTrackedCandidate(root, temporaryParent) { + const gitEnvironment = { + GIT_NO_REPLACE_OBJECTS: "1", + GIT_OPTIONAL_LOCKS: "0", + }; + const trackedStatus = run( + "git", + ["--no-replace-objects", "status", "--short", "--untracked-files=no"], + { capture: true, cwd: root, env: gitEnvironment }, + ).trim(); + if (trackedStatus) { + throw new Error( + "The self-containment gate requires a clean tracked worktree so it can verify the exact HEAD tree.", + ); + } -function shouldCopy(source) { - return !STANDALONE_CONTENT_EXCLUSIONS.has(basename(source)); + const candidateRoot = join(temporaryParent, "cometapi-node"); + const headTree = run( + "git", + ["--no-replace-objects", "rev-parse", "--verify", "HEAD^{tree}"], + { capture: true, cwd: root, env: gitEnvironment }, + ).trim(); + if (!/^[0-9a-f]+$/.test(headTree)) { + throw new Error( + "The self-containment gate could not resolve the HEAD tree.", + ); + } + materializeStandaloneTree(root, headTree, candidateRoot); + return candidateRoot; } -const temporaryParent = makeTemporaryDirectory("cometapi-standalone-"); -const candidateRoot = join(temporaryParent, "cometapi-node"); +export function checkSelfContained(root = ROOT) { + const temporaryParent = makeTemporaryDirectory("cometapi-standalone-"); -try { - cpSync(ROOT, candidateRoot, { filter: shouldCopy, recursive: true }); - const violations = collectStandaloneContentViolations(candidateRoot); - if (violations.length > 0) { - throw new Error( - `Standalone repository scan found ${String(violations.length)} outside-root reference(s):\n- ${violations.join("\n- ")}`, + try { + const candidateRoot = materializeTrackedCandidate(root, temporaryParent); + const violations = collectStandaloneContentViolations(candidateRoot); + if (violations.length > 0) { + throw new Error( + `Standalone repository scan found ${String(violations.length)} outside-root reference(s):\n- ${violations.join("\n- ")}`, + ); + } + run("npm", ["ci", "--no-audit", "--no-fund"], { cwd: candidateRoot }); + run("npm", ["run", "verify:offline"], { + cwd: candidateRoot, + env: { COMETAPI_SELF_CONTAINMENT: "1" }, + }); + run("npm", ["run", "actionlint"], { + cwd: candidateRoot, + env: { COMETAPI_SELF_CONTAINMENT: "1" }, + }); + console.log( + "Exact tracked HEAD copy, standalone scan, offline verification, and actionlint passed.", ); + } finally { + removeTemporaryDirectory(temporaryParent); } - run("npm", ["ci", "--no-audit", "--no-fund"], { cwd: candidateRoot }); - run("npm", ["run", "verify:offline"], { - cwd: candidateRoot, - env: { COMETAPI_SELF_CONTAINMENT: "1" }, - }); - run("npm", ["run", "actionlint"], { - cwd: candidateRoot, - env: { COMETAPI_SELF_CONTAINMENT: "1" }, - }); - console.log( - "Standalone-copy scan, offline verification, and actionlint passed.", - ); -} finally { - removeTemporaryDirectory(temporaryParent); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + checkSelfContained(); } diff --git a/scripts/check-standalone-content.mjs b/scripts/check-standalone-content.mjs index 1db2af0..d5e1be5 100644 --- a/scripts/check-standalone-content.mjs +++ b/scripts/check-standalone-content.mjs @@ -1,13 +1,282 @@ -import { ROOT } from "./lib.mjs"; +import { isUtf8 } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ROOT, + makeTemporaryDirectory, + removeTemporaryDirectory, +} from "./lib.mjs"; import { collectStandaloneContentViolations, formatStandaloneContentViolations, } from "./standalone-content.mjs"; -const violations = collectStandaloneContentViolations(ROOT); -if (violations.length > 0) { - console.error(formatStandaloneContentViolations(violations)); - process.exitCode = 1; -} else { - console.log("Standalone content gate passed."); +const gitEnvironment = { + ...process.env, + GIT_NO_LAZY_FETCH: "1", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_OPTIONAL_LOCKS: "0", + LC_ALL: "C", +}; +const gitOutputLimit = 256 * 1024 * 1024; + +function runGit(root, args) { + const result = spawnSync("git", args, { + cwd: root, + env: gitEnvironment, + maxBuffer: gitOutputLimit, + }); + + if (result.error) { + throw new Error("Standalone content scan could not execute Git."); + } + if (result.status !== 0) { + throw new Error( + `Standalone content scan could not inspect Git data (${args[0]} failed).`, + ); + } + return result; +} + +function gitText(root, args) { + const output = runGit(root, args).stdout; + if (!isUtf8(output)) { + throw new Error( + "Standalone content scan cannot safely decode Git metadata as UTF-8.", + ); + } + return output.toString("utf8"); +} + +function ensureCompleteRepository(root) { + const repository = gitText(root, ["rev-parse", "--is-inside-work-tree"]); + if (repository.trim() !== "true") { + throw new Error( + "Standalone content history scan requires a complete Git repository.", + ); + } + + const shallow = gitText(root, ["rev-parse", "--is-shallow-repository"]); + if (shallow.trim() !== "false") { + throw new Error( + "Standalone content history scan requires complete Git history; shallow repositories are rejected.", + ); + } +} + +function reachableCommitTrees(root) { + const head = gitText(root, ["rev-parse", "--verify", "HEAD"]); + const revisions = ["--all", head.trim()]; + const output = gitText(root, ["log", "--format=%H%x09%T", ...revisions]); + const trees = new Map(); + + for (const line of output.trim().split("\n")) { + if (line.length === 0) continue; + const match = /^([0-9a-f]+)\t([0-9a-f]+)$/.exec(line); + if (match === null) { + throw new Error( + "Standalone content scan received malformed Git commit data.", + ); + } + const [, commit, tree] = match; + if (!trees.has(tree)) trees.set(tree, commit); + } + + if (trees.size === 0) { + throw new Error( + "Standalone content history scan requires at least one reachable Git commit.", + ); + } + return trees; +} + +export function reachableStandaloneTrees(root) { + ensureCompleteRepository(root); + return reachableCommitTrees(root); +} + +function parseTreeEntries(output, tree) { + const entries = []; + let offset = 0; + + while (offset < output.length) { + const separator = output.indexOf(0x09, offset); + const terminator = output.indexOf(0, separator + 1); + if (separator === -1 || terminator === -1) { + throw new Error( + `Standalone content scan received malformed entries for tree ${tree}.`, + ); + } + + const header = output.subarray(offset, separator).toString("ascii"); + const match = /^(100644|100755|120000) blob ([0-9a-f]+)$/.exec(header); + const pathBytes = output.subarray(separator + 1, terminator); + if (match === null || pathBytes.length === 0 || !isUtf8(pathBytes)) { + throw new Error( + `Standalone content scan cannot materialize every tracked entry in tree ${tree}.`, + ); + } + entries.push({ + mode: match[1], + objectId: match[2], + path: pathBytes.toString("utf8"), + }); + offset = terminator + 1; + } + return entries; +} + +function checkedDestination(root, path, tree) { + if (path.length === 0 || isAbsolute(path)) { + throw new Error( + `Standalone content scan found an invalid path in tree ${tree}.`, + ); + } + const destination = resolve(root, path); + const pathFromRoot = relative(root, destination); + if ( + pathFromRoot === ".." || + pathFromRoot.startsWith(`..${sep}`) || + isAbsolute(pathFromRoot) + ) { + throw new Error( + `Standalone content scan found an escaping path in tree ${tree}.`, + ); + } + return destination; +} + +function assertPortableTreePaths(entries, tree) { + const originalPrefixes = new Map(); + + for (const { path } of entries) { + const segments = path.split("/"); + for (let length = 1; length <= segments.length; length += 1) { + const original = segments.slice(0, length).join("/"); + const portable = segments + .slice(0, length) + .map((segment) => segment.normalize("NFC").toLowerCase()) + .join("/"); + const prior = originalPrefixes.get(portable); + if (prior !== undefined && prior !== original) { + throw new Error( + `Standalone content scan cannot safely materialize filesystem-equivalent paths in tree ${tree}.`, + ); + } + originalPrefixes.set(portable, original); + } + } +} + +export function materializeStandaloneTree(root, tree, destinationRoot) { + mkdirSync(destinationRoot, { recursive: true }); + const output = runGit(root, [ + "ls-tree", + "-r", + "-z", + "--full-tree", + tree, + ]).stdout; + const entries = parseTreeEntries(output, tree); + assertPortableTreePaths(entries, tree); + + for (const { path } of entries) { + checkedDestination(destinationRoot, path, tree); + } + for (const { mode, objectId, path } of entries) { + const destination = checkedDestination(destinationRoot, path, tree); + mkdirSync(dirname(destination), { recursive: true }); + const contents = runGit(root, ["cat-file", "blob", objectId]).stdout; + + if (mode === "120000") { + if (!isUtf8(contents) || contents.includes(0)) { + throw new Error( + `Standalone content scan cannot materialize a symbolic link in tree ${tree}.`, + ); + } + symlinkSync(contents.toString("utf8"), destination); + } else { + writeFileSync(destination, contents, { flag: "wx" }); + chmodSync(destination, mode === "100755" ? 0o755 : 0o644); + } + } +} + +export function collectStandaloneHistoryViolations(root) { + const trees = reachableStandaloneTrees(root); + const temporaryParent = makeTemporaryDirectory("cometapi-content-history-"); + const violations = []; + + try { + let index = 0; + for (const [tree, commit] of trees) { + const candidateRoot = join( + temporaryParent, + `tree-${String(index).padStart(4, "0")}`, + ); + index += 1; + materializeStandaloneTree(root, tree, candidateRoot); + for (const violation of collectStandaloneContentViolations( + candidateRoot, + )) { + violations.push(`commit=${commit} tree=${tree}: ${violation}`); + } + } + } finally { + removeTemporaryDirectory(temporaryParent); + } + return violations; +} + +export function contentScanMode(environment = process.env) { + return environment.COMETAPI_SELF_CONTAINMENT === "1" ? "files" : "git"; +} + +export function collectStandaloneGateViolations( + root, + { mode = contentScanMode() } = {}, +) { + if (mode === "files") return collectStandaloneContentViolations(root); + if (mode === "git") return collectStandaloneHistoryViolations(root); + throw new Error(`Unknown standalone content scan mode: ${mode}`); +} + +export function checkStandaloneContent( + root = ROOT, + { mode = contentScanMode() } = {}, +) { + const violations = collectStandaloneGateViolations(root, { mode }); + if (violations.length > 0) { + throw new Error(formatStandaloneContentViolations(violations)); + } +} + +function main() { + const mode = contentScanMode(); + const scanRoot = mode === "files" ? process.cwd() : ROOT; + try { + checkStandaloneContent(scanRoot, { mode }); + console.log( + mode === "git" + ? "Standalone content gate passed for every reachable Git tree." + : "Standalone content gate passed for the isolated tracked copy.", + ); + } catch (error) { + console.error( + error instanceof Error + ? error.message + : "Standalone content scan failed.", + ); + process.exitCode = 1; + } +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); } diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs index 16c8522..f1212c3 100644 --- a/scripts/release-validation.mjs +++ b/scripts/release-validation.mjs @@ -1,3 +1,5 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + const NUMERIC_IDENTIFIER = "(?:0|[1-9]\\d*)"; const NON_NUMERIC_IDENTIFIER = "(?:\\d*[A-Za-z-][0-9A-Za-z-]*)"; const PRERELEASE_IDENTIFIER = `(?:${NUMERIC_IDENTIFIER}|${NON_NUMERIC_IDENTIFIER})`; @@ -9,6 +11,7 @@ const SEMVER_PATTERN = new RegExp( ); export const SUPPORTED_NODE_ENGINES = "^22.0.0 || ^24.0.0"; +export const SUPPORTED_OPENAI_RANGE = "^6.47.0"; export const CANONICAL_IDENTITY = Object.freeze({ author: "CometAPI", @@ -63,6 +66,12 @@ const PREPARATION_NARRATIVE_PATTERNS = [ /cometapi-worksapce/i, /\b(?:Claude|Codex)\b/i, ]; +const MARKDOWN_BLOCK_CONTAINERS = new Set([ + "blockquote", + "list", + "listItem", + "root", +]); export function escapeRegularExpression(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -138,17 +147,68 @@ function assertNoStaticDistTag(manifest, label) { } } +function markdownNodeText(node, { includeLinkTargets = false } = {}) { + if (node.type === "text" || node.type === "inlineCode") return node.value; + if (node.type === "break") return "\n"; + if ( + node.type === "code" || + node.type === "definition" || + node.type === "html" || + node.type === "image" || + node.type === "imageReference" || + node.type === "thematicBreak" + ) { + return ""; + } + + const separator = MARKDOWN_BLOCK_CONTAINERS.has(node.type) ? "\n" : ""; + const text = Array.isArray(node.children) + ? node.children + .map((child) => markdownNodeText(child, { includeLinkTargets })) + .join(separator) + : ""; + if (node.type === "link" && includeLinkTargets) { + return `${text} ${node.url}`.trim(); + } + return text; +} + +function parseMarkdownDocument(text) { + const root = fromMarkdown(text); + const headings = []; + for (const [nodeIndex, node] of root.children.entries()) { + if (node.type !== "heading") continue; + headings.push({ + level: node.depth, + nodeIndex, + title: markdownNodeText(node).trim(), + }); + } + return { + headings, + referenceText: markdownNodeText(root, { includeLinkTargets: true }), + root, + text: markdownNodeText(root), + }; +} + +export function visibleMarkdownText(text) { + return parseMarkdownDocument(text).text; +} + function validateChangelog(changelog, version, requireDatedChangelog) { const escapedVersion = escapeRegularExpression(version); - const headingPattern = new RegExp(`^## \\[${escapedVersion}\\](.*)$`, "gm"); - const headings = [...changelog.matchAll(headingPattern)]; + const headingPattern = new RegExp(`^\\[${escapedVersion}\\](.*)$`); + const headings = markdownHeadings(parseMarkdownDocument(changelog)).filter( + ({ level, title }) => level === 2 && headingPattern.test(title), + ); if (headings.length !== 1) { throw new Error( `CHANGELOG.md must contain exactly one heading for ${version}; found ${String(headings.length)}.`, ); } - const suffix = headings[0][1]; + const suffix = headingPattern.exec(headings[0].title)[1]; const suffixMatch = /^ - (Unreleased|\d{4}-\d{2}-\d{2})$/.exec(suffix); if (!suffixMatch) { throw new Error( @@ -216,6 +276,109 @@ const PUBLIC_DOCUMENTS = [ ["support", "SUPPORT.md"], ]; +const DOCUMENT_HEADING_REQUIREMENTS = [ + [ + "agents", + "AGENTS.md", + [ + [ + /\b(?:agent instructions|engineering contract)\b/i, + "an agent instructions title", + ], + ], + ], + [ + "architecture", + "ARCHITECTURE.md", + [ + [/^architecture$/i, "an Architecture title"], + [/^0\.1 boundary$/i, "a 0.1 boundary section"], + ], + ], + ["changelog", "CHANGELOG.md", [[/^changelog$/i, "a Changelog title"]]], + [ + "compatibility", + "COMPATIBILITY.md", + [ + [/^compatibility$/i, "a Compatibility title"], + [/^supported protocol surface$/i, "a supported protocol surface section"], + ], + ], + [ + "conduct", + "CODE_OF_CONDUCT.md", + [ + [/^code of conduct$/i, "a Code of Conduct title"], + [ + /^reporting(?: and enforcement)?$/i, + "a reporting and enforcement section", + ], + ], + ], + [ + "contributing", + "CONTRIBUTING.md", + [ + [/^contributing$/i, "a Contributing title"], + [ + /^(?:development setup|required checks)$/i, + "a development setup or required checks section", + ], + ], + ], + [ + "releasing", + "RELEASING.md", + [ + [/^releasing$/i, "a Releasing title"], + [/^authorization boundary$/i, "an authorization boundary section"], + ], + ], + [ + "roadmap", + "ROADMAP.md", + [ + [/\broadmap$/i, "a Roadmap title"], + [/^public preview$/i, "a Public Preview section"], + ], + ], + [ + "security", + "SECURITY.md", + [ + [/^security policy$/i, "a Security Policy title"], + [/^reporting a vulnerability$/i, "a vulnerability reporting section"], + ], + ], + [ + "support", + "SUPPORT.md", + [ + [/^support$/i, "a Support title"], + [/^getting help$/i, "a getting help section"], + ], + ], +]; + +const MIT_LICENSE_REQUIREMENTS = [ + [/^MIT License\s*$/im, "the MIT License title"], + [ + /permission\s+is\s+hereby\s+granted,\s+free\s+of\s+charge,\s+to\s+any\s+person\s+obtaining\s+a\s+copy/i, + "the MIT permission grant", + ], + [ + /the\s+software\s+is\s+provided\s+["']AS IS["']/i, + 'the MIT "AS IS" warranty disclaimer', + ], +]; + +const README_OPERATIONS = [ + "chat.completions.create", + "responses.create", + "models.list", +]; +const README_STREAMING_OPERATIONS = README_OPERATIONS.slice(0, 2); + function collectViolation(violations, validation) { try { validation(); @@ -232,16 +395,112 @@ function requireExact(actual, expected, label) { } } +function markdownHeadings(document) { + return document.headings; +} + +function hasMarkdownHeading(document, titlePattern) { + return markdownHeadings(document).some(({ title }) => + titlePattern.test(title), + ); +} + +function findMarkdownSection(document, titlePattern) { + const headings = markdownHeadings(document); + const headingIndex = headings.findIndex(({ title }) => + titlePattern.test(title), + ); + if (headingIndex === -1) return undefined; + + const heading = headings[headingIndex]; + const nextHeading = headings + .slice(headingIndex + 1) + .find(({ level }) => level <= heading.level); + const nodes = document.root.children.slice( + heading.nodeIndex + 1, + nextHeading?.nodeIndex, + ); + return { + nodes, + text: nodes.map((node) => markdownNodeText(node)).join("\n"), + }; +} + +function containsSubstantiveProse(text) { + const prose = text.replace(/^\s{0,3}#{1,6}[ \t]+.*$/gm, ""); + return (prose.match(/[A-Za-z][A-Za-z'-]+/g) ?? []).length >= 3; +} + +function markdownStatements(nodes) { + const statements = []; + const visit = (node) => { + if (node.type === "code" || node.type === "html") return; + if (node.type === "paragraph") { + statements.push(markdownNodeText(node)); + return; + } + if (Array.isArray(node.children)) node.children.forEach(visit); + }; + nodes.forEach(visit); + return statements; +} + +function containsExactOperation(text, operation) { + const operationPattern = new RegExp( + `(?:^|[^A-Za-z0-9_$.])${escapeRegularExpression(operation)}(?![A-Za-z0-9_$.])`, + ); + return operationPattern.test(text); +} + +function containsStreamingModes(text) { + const nonStreamingPattern = /\bnon(?:-|\s)?streaming\b/i; + return ( + nonStreamingPattern.test(text) && + /\bstreaming\b/i.test(text.replace(/\bnon(?:-|\s)?streaming\b/gi, "")) + ); +} + +function containsOperationModes(section, operation) { + return markdownStatements(section.nodes).some( + (statement) => + containsExactOperation(statement, operation) && + containsStreamingModes(statement), + ); +} + +function assertManifestKeyword(manifest, keyword) { + const keywords = manifest?.keywords; + if ( + !Array.isArray(keywords) || + !keywords.some( + (value) => typeof value === "string" && value.toLowerCase() === keyword, + ) + ) { + throw new Error(`package.json keywords must contain ${keyword}.`); + } +} + export function collectPublicPreviewViolations({ documents = {}, sourceManifest, } = {}) { const violations = []; + const markdownDocuments = new Map(); + const documentReferences = new Map(); const documentText = new Map(); for (const [field, filename] of PUBLIC_DOCUMENTS) { collectViolation(violations, () => { - documentText.set(field, assertDocumentText(documents, field, filename)); + const text = assertDocumentText(documents, field, filename); + if (filename.endsWith(".md")) { + const document = parseMarkdownDocument(text); + markdownDocuments.set(field, document); + documentReferences.set(field, document.referenceText); + documentText.set(field, document.text); + } else { + documentReferences.set(field, text); + documentText.set(field, text); + } }); } @@ -287,6 +546,46 @@ export function collectPublicPreviewViolations({ "package.json bugs.url", ), ); + for (const keyword of ["typescript", "nodejs"]) { + collectViolation(violations, () => + assertManifestKeyword(sourceManifest, keyword), + ); + } + collectViolation(violations, () => + assertSupportedNodeEngines(sourceManifest, "package.json"), + ); + collectViolation(violations, () => + requireExact( + sourceManifest?.dependencies?.openai, + SUPPORTED_OPENAI_RANGE, + "package.json dependencies.openai", + ), + ); + + for (const [field, filename, requirements] of DOCUMENT_HEADING_REQUIREMENTS) { + const document = markdownDocuments.get(field); + if (document === undefined) continue; + for (const [ + requirementIndex, + [titlePattern, description], + ] of requirements.entries()) { + collectViolation(violations, () => { + if (!hasMarkdownHeading(document, titlePattern)) { + throw new Error(`${filename} must contain ${description}.`); + } + if ( + requirementIndex > 0 && + !containsSubstantiveProse( + findMarkdownSection(document, titlePattern)?.text ?? "", + ) + ) { + throw new Error( + `${filename} must give ${description} substantive contract content.`, + ); + } + }); + } + } const license = documentText.get("license"); if (license !== undefined) { @@ -300,6 +599,64 @@ export function collectPublicPreviewViolations({ ); } }); + for (const [pattern, description] of MIT_LICENSE_REQUIREMENTS) { + collectViolation(violations, () => { + if (!pattern.test(license)) { + throw new Error(`LICENSE must contain ${description}.`); + } + }); + } + } + + const readmeDocument = markdownDocuments.get("readme"); + if (readmeDocument !== undefined) { + const firstSection = markdownHeadings(readmeDocument).find( + ({ level, nodeIndex }) => nodeIndex > 0 && level >= 2, + ); + const preamble = readmeDocument.root.children + .slice(0, firstSection?.nodeIndex) + .map((node) => markdownNodeText(node)) + .join("\n"); + collectViolation(violations, () => { + if (!/\bpre(?:-|\s)?release\b/i.test(preamble)) { + throw new Error( + "README.md must label the project as a pre-release near the top of the document.", + ); + } + }); + + const supportedSurface = findMarkdownSection( + readmeDocument, + /^(?=.*\b0\.1\b)(?=.*\bsupport(?:ed)?\b).+$/i, + ); + collectViolation(violations, () => { + if (supportedSurface === undefined) { + throw new Error( + "README.md must contain a supported 0.1 surface section.", + ); + } + }); + + if (supportedSurface !== undefined) { + for (const operation of README_OPERATIONS) { + collectViolation(violations, () => { + if (!containsExactOperation(supportedSurface.text, operation)) { + throw new Error( + `README.md supported 0.1 surface must contain the exact operation ${operation}.`, + ); + } + }); + } + for (const operation of README_STREAMING_OPERATIONS) { + collectViolation(violations, () => { + if (!containsOperationModes(supportedSurface, operation)) { + throw new Error( + `README.md must describe ${operation} as streaming and non-streaming.`, + ); + } + }); + } + } } for (const [field, filename] of [ @@ -307,7 +664,7 @@ export function collectPublicPreviewViolations({ ["security", "SECURITY.md"], ["support", "SUPPORT.md"], ]) { - const text = documentText.get(field); + const text = documentReferences.get(field); if (text === undefined) continue; collectViolation(violations, () => assertNoOwnerPlaceholder(text, filename), @@ -320,7 +677,7 @@ export function collectPublicPreviewViolations({ ["support", "SUPPORT.md", CANONICAL_IDENTITY.supportEmail], ["support", "SUPPORT.md", CANONICAL_IDENTITY.bugsUrl], ]) { - const text = documentText.get(field); + const text = documentReferences.get(field); if (text === undefined) continue; collectViolation(violations, () => { if (!text.includes(expected)) { @@ -330,7 +687,7 @@ export function collectPublicPreviewViolations({ } for (const [field, filename] of PUBLIC_DOCUMENTS) { - const text = documentText.get(field); + const text = documentReferences.get(field); if (text === undefined) continue; collectViolation(violations, () => { if ( @@ -359,17 +716,17 @@ export function validatePublicPreviewDocuments(input) { } function releaseChangelogSection(changelog, version) { + const document = parseMarkdownDocument(changelog); const headingPattern = new RegExp( - `^## \\[${escapeRegularExpression(version)}\\].*$`, - "m", + `^\\[${escapeRegularExpression(version)}\\].*$`, + ); + const heading = markdownHeadings(document).find( + ({ level, title }) => level === 2 && headingPattern.test(title), ); - const heading = headingPattern.exec(changelog); if (!heading) { throw new Error(`CHANGELOG.md has no release section for ${version}.`); } - const remainder = changelog.slice(heading.index + heading[0].length); - const nextHeading = /^## /m.exec(remainder); - return nextHeading ? remainder.slice(0, nextHeading.index) : remainder; + return findMarkdownSection(document, headingPattern)?.text ?? ""; } export function validateReleasableDocuments({ changelog, documents, version }) { @@ -381,26 +738,32 @@ export function validateReleasableDocuments({ changelog, documents, version }) { ); } - const readme = assertDocumentText(documents, "readme", "README.md"); - assertNoOwnerPlaceholder(readme, "README.md"); - assertNoStalePublicationState(readme, "README.md"); + const readmeDocument = parseMarkdownDocument( + assertDocumentText(documents, "readme", "README.md"), + ); + assertNoOwnerPlaceholder(readmeDocument.referenceText, "README.md"); + assertNoStalePublicationState(readmeDocument.text, "README.md"); const approvalPattern = new RegExp( `\\b${escapeRegularExpression(version)}\\s+is\\s+approved\\s+for\\s+npm\\s+publication\\b`, "i", ); - if (!approvalPattern.test(readme)) { + if (!approvalPattern.test(readmeDocument.text)) { throw new Error( "README.md must explicitly state ' is approved for npm publication' before tagging.", ); } - const security = assertDocumentText(documents, "security", "SECURITY.md"); - assertCanonicalContact(security, "SECURITY.md"); - assertNoStalePublicationState(security, "SECURITY.md"); + const securityDocument = parseMarkdownDocument( + assertDocumentText(documents, "security", "SECURITY.md"), + ); + assertCanonicalContact(securityDocument.referenceText, "SECURITY.md"); + assertNoStalePublicationState(securityDocument.text, "SECURITY.md"); - const support = assertDocumentText(documents, "support", "SUPPORT.md"); - assertCanonicalContact(support, "SUPPORT.md"); - assertNoStalePublicationState(support, "SUPPORT.md"); + const supportDocument = parseMarkdownDocument( + assertDocumentText(documents, "support", "SUPPORT.md"), + ); + assertCanonicalContact(supportDocument.referenceText, "SUPPORT.md"); + assertNoStalePublicationState(supportDocument.text, "SUPPORT.md"); const releaseSection = releaseChangelogSection(changelog, version); if ( diff --git a/scripts/test-live-smoke-contract.mjs b/scripts/test-live-smoke-contract.mjs index 41aa9da..2d0020b 100644 --- a/scripts/test-live-smoke-contract.mjs +++ b/scripts/test-live-smoke-contract.mjs @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; const script = new globalThis.URL("./live-smoke.mjs", import.meta.url); +const LIVE_BASE_URL = "https://api.cometapi.com/v1"; +const FULL_REQUEST_SEQUENCE = [ + `${LIVE_BASE_URL}/models`, + `${LIVE_BASE_URL}/chat/completions`, + `${LIVE_BASE_URL}/responses`, +]; const originalFetch = globalThis.fetch; const originalLog = console.log; const environmentNames = [ @@ -47,9 +54,10 @@ function completedResponse() { }; } -function createMockFetch(scenario) { +function createMockFetch(scenario, requestURLs = []) { return async (input, init) => { const request = new Request(input, init); + requestURLs.push(request.url); assert.equal(request.headers.get("authorization"), "Bearer mock-live-key"); if (request.url.endsWith("/models")) { @@ -102,6 +110,36 @@ function createMockFetch(scenario) { }, ]); } + if (scenario === "incomplete-response") { + return sse([ + { + data: { + response: { + ...completedResponse(), + incomplete_details: { reason: "max_output_tokens" }, + status: "incomplete", + }, + sequence_number: 0, + type: "response.incomplete", + }, + event: "response.incomplete", + }, + ]); + } + if (scenario === "error-response") { + return sse([ + { + data: { + code: "server_error", + message: "mock response error", + param: null, + sequence_number: 0, + type: "error", + }, + event: "error", + }, + ]); + } const messages = [ { @@ -131,9 +169,59 @@ function createMockFetch(scenario) { }; } -async function runScenario(scenario) { - globalThis.fetch = createMockFetch(scenario); - await import(`${script.href}?scenario=${scenario}`); +async function runSuccessfulScenario() { + const requestURLs = []; + globalThis.fetch = createMockFetch("success", requestURLs); + await import(`${script.href}?scenario=success`); + assert.deepEqual(requestURLs, FULL_REQUEST_SEQUENCE); +} + +function runFailingScenario(scenario, environment = {}) { + const childProgram = [ + 'import assert from "node:assert/strict";', + `const requestURLs = [];`, + json.toString(), + sse.toString(), + completedResponse.toString(), + createMockFetch.toString(), + `globalThis.fetch = createMockFetch(${JSON.stringify(scenario)}, requestURLs);`, + "try {", + ` await import(${JSON.stringify(script.href)});`, + "} finally {", + " process.stdout.write(JSON.stringify(requestURLs));", + "}", + ].join("\n"); + const childEnvironment = { + ...process.env, + COMETAPI_KEY: "mock-live-key", + COMETAPI_LIVE_CONCURRENCY: "1", + COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16", + COMETAPI_LIVE_REQUEST_LIMIT: "3", + COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000", + COMETAPI_LIVE_SMOKE: "1", + ...environment, + }; + if (!("COMETAPI_BASE_URL" in environment)) { + Reflect.deleteProperty(childEnvironment, "COMETAPI_BASE_URL"); + } + + return spawnSync( + process.execPath, + ["--input-type=module", "--eval", childProgram], + { encoding: "utf8", env: childEnvironment }, + ); +} + +function assertFailingScenario({ + environment, + expectedError, + expectedRequests, + scenario, +}) { + const result = runFailingScenario(scenario, environment); + assert.notEqual(result.status, 0, `${scenario} must fail the live runner`); + assert.match(result.stderr, expectedError); + assert.deepEqual(JSON.parse(result.stdout), expectedRequests); } try { @@ -146,22 +234,38 @@ try { process.env.COMETAPI_LIVE_CONCURRENCY = "1"; delete process.env.COMETAPI_BASE_URL; - await runScenario("success"); - process.env.COMETAPI_BASE_URL = "https://attacker.invalid/v1"; - await assert.rejects( - () => runScenario("redirected-endpoint"), - /endpoint is pinned/, - ); - delete process.env.COMETAPI_BASE_URL; - await assert.rejects(() => runScenario("empty-chat"), /returned no choices/); - await assert.rejects( - () => runScenario("failed-response"), - /ended with response\.failed/, - ); - await assert.rejects( - () => runScenario("missing-completed"), - /returned no completed event/, - ); + await runSuccessfulScenario(); + assertFailingScenario({ + environment: { COMETAPI_BASE_URL: "https://attacker.invalid/v1" }, + expectedError: /endpoint is pinned/, + expectedRequests: [], + scenario: "redirected-endpoint", + }); + assertFailingScenario({ + expectedError: /returned no choices/, + expectedRequests: FULL_REQUEST_SEQUENCE.slice(0, 2), + scenario: "empty-chat", + }); + assertFailingScenario({ + expectedError: /ended with response\.failed/, + expectedRequests: FULL_REQUEST_SEQUENCE, + scenario: "failed-response", + }); + assertFailingScenario({ + expectedError: /ended with response\.incomplete/, + expectedRequests: FULL_REQUEST_SEQUENCE, + scenario: "incomplete-response", + }); + assertFailingScenario({ + expectedError: /ended with error/, + expectedRequests: FULL_REQUEST_SEQUENCE, + scenario: "error-response", + }); + assertFailingScenario({ + expectedError: /returned no completed event/, + expectedRequests: FULL_REQUEST_SEQUENCE, + scenario: "missing-completed", + }); originalLog( "Live-smoke semantic contract checks passed with mocked transport.", ); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 0be57b3..a37cde1 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,6 +7,7 @@ const offlineChecks = [ "typecheck", "test", "test:secrets", + "check:standalone-content", "check:public-preview", "test:package", "test:live-contract", diff --git a/tests/check-secrets.test.mjs b/tests/check-secrets.test.mjs new file mode 100644 index 0000000..59b2191 --- /dev/null +++ b/tests/check-secrets.test.mjs @@ -0,0 +1,294 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { URL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + checkSecrets, + collectSecretViolations, + formatSecretViolations, +} from "../scripts/check-secrets.mjs"; + +function withTemporaryDirectory(callback) { + const directory = mkdtempSync(join(tmpdir(), "cometapi-secret-test-")); + try { + return callback(directory); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +function git(root, args) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function initializeRepository(root) { + git(root, ["init", "--initial-branch=main"]); + git(root, ["config", "user.name", "Secret Gate Test"]); + git(root, ["config", "user.email", "secret-gate@example.com"]); +} + +function commitAll(root, message) { + git(root, ["add", "--all"]); + git(root, ["commit", "--message", message]); +} + +const openAISecret = () => ["sk", "a".repeat(24)].join("-"); +const npmSecret = () => ["npm", "b".repeat(24)].join("_"); +const authTokenAssignment = () => + ["//registry.example/:_authToken", "literal-token-value"].join(" = "); + +describe("secret-pattern scan", () => { + it("scans tracked shell, extensionless, environment, and link content", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + writeFileSync(join(root, "deploy.sh"), "echo safe\n"); + writeFileSync(join(root, "Dockerfile"), "FROM scratch\n"); + writeFileSync(join(root, ".env"), "SAFE=true\n"); + symlinkSync("Dockerfile", join(root, "credential-link")); + commitAll(root, "add safe fixtures"); + + writeFileSync(join(root, "deploy.sh"), `${openAISecret()}\n`); + writeFileSync(join(root, "Dockerfile"), `${npmSecret()}\n`); + writeFileSync(join(root, ".env"), `${authTokenAssignment()}\n`); + rmSync(join(root, "credential-link")); + symlinkSync(openAISecret(), join(root, "credential-link")); + + const violations = collectSecretViolations(root); + const currentPaths = violations + .filter(({ path }) => path !== undefined) + .map(({ path }) => path); + + expect(currentPaths).toEqual( + expect.arrayContaining([ + ".env", + "Dockerfile", + "credential-link", + "deploy.sh", + ]), + ); + }); + }); + + it("finds a secret that was deleted from the current tree", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const path = join(root, "removed.sh"); + writeFileSync( + path, + Buffer.concat([Buffer.from([0xff]), Buffer.from(openAISecret())]), + ); + commitAll(root, "add credential"); + const leakedBlob = git(root, ["rev-parse", "HEAD:removed.sh"]); + rmSync(path); + commitAll(root, "remove credential"); + + const violations = collectSecretViolations(root); + expect(violations).toContainEqual({ + blob: leakedBlob, + rule: "openai-style-key", + }); + expect( + violations.some(({ path: candidate }) => candidate === "removed.sh"), + ).toBe(false); + }); + }); + + it("finds a secret in a reachable commit message without printing it", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const secret = openAISecret(); + writeFileSync(join(root, "README"), "safe\n"); + commitAll(root, `record ${secret}`); + const commit = git(root, ["rev-parse", "HEAD"]); + + const violations = collectSecretViolations(root); + const output = formatSecretViolations(violations); + expect(violations).toContainEqual({ + commit, + rule: "openai-style-key", + }); + expect(output).toContain(`commit=${commit}`); + expect(output).not.toContain(secret); + expect(() => checkSecrets(root)).toThrowError( + expect.not.stringContaining(secret), + ); + }); + }); + + it("finds a secret in a reachable annotated tag message without printing it", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const secret = npmSecret(); + writeFileSync(join(root, "README"), "safe\n"); + commitAll(root, "add safe fixture"); + git(root, ["tag", "--annotate", "credential-test", "--message", secret]); + const tag = git(root, ["rev-parse", "refs/tags/credential-test"]); + + const violations = collectSecretViolations(root); + const output = formatSecretViolations(violations); + expect(violations).toContainEqual({ + tag, + rule: "npm-access-token", + }); + expect(output).toContain(`tag=${tag}`); + expect(output).not.toContain(secret); + expect(() => checkSecrets(root)).toThrowError( + expect.not.stringContaining(secret), + ); + }); + }); + + it("finds a secret in a deleted historical path without printing it", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const secret = npmSecret(); + const unsafePath = `history/${secret}.txt`; + mkdirSync(join(root, "history")); + writeFileSync(join(root, unsafePath), "safe\n"); + commitAll(root, "add historical fixture"); + rmSync(join(root, unsafePath)); + commitAll(root, "remove historical fixture"); + const pathHash = createHash("sha256") + .update(Buffer.from(unsafePath, "utf8")) + .digest("hex"); + + const violations = collectSecretViolations(root); + const output = formatSecretViolations(violations); + expect(violations).toContainEqual({ + pathHash, + rule: "npm-access-token", + }); + expect(output).toContain(`path-sha256=${pathHash}`); + expect(output).not.toContain(secret); + expect(output).not.toContain(unsafePath); + expect(() => checkSecrets(root)).toThrowError( + expect.not.stringContaining(secret), + ); + }); + }); + + it("does not let replacement objects hide the original history", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + writeFileSync(join(root, "credential.txt"), `${openAISecret()}\n`); + commitAll(root, "add credential"); + const originalCommit = git(root, ["rev-parse", "HEAD"]); + const originalBlob = git(root, ["rev-parse", "HEAD:credential.txt"]); + + writeFileSync(join(root, "credential.txt"), "safe\n"); + git(root, ["add", "credential.txt"]); + const safeTree = git(root, ["write-tree"]); + const replacementCommit = git(root, [ + "commit-tree", + safeTree, + "-m", + "safe replacement", + ]); + git(root, ["replace", originalCommit, replacementCommit]); + + expect(collectSecretViolations(root)).toContainEqual({ + blob: originalBlob, + rule: "openai-style-key", + }); + }); + }); + + it("reports rules and locations without exposing matched values", () => { + withTemporaryDirectory((root) => { + const secret = openAISecret(); + writeFileSync(join(root, ".env"), `${secret}\n`); + + const violations = collectSecretViolations(root, { mode: "files" }); + const output = formatSecretViolations(violations); + expect(output).toContain("rule=openai-style-key"); + expect(output).toContain('path=".env"'); + expect(output).not.toContain(secret); + expect(() => checkSecrets(root, { mode: "files" })).toThrowError( + expect.not.stringContaining(secret), + ); + }); + }); + + it("scans credential-like path names without printing them", () => { + withTemporaryDirectory((root) => { + const secret = npmSecret(); + const unsafePath = `${secret}.txt`; + writeFileSync(join(root, unsafePath), "safe\n"); + + const violations = collectSecretViolations(root, { mode: "files" }); + const output = formatSecretViolations(violations); + expect(violations).toEqual([ + { + pathHash: expect.stringMatching(/^[0-9a-f]{64}$/), + rule: "npm-access-token", + }, + ]); + expect(output).toContain("path-sha256="); + expect(output).not.toContain(secret); + expect(output).not.toContain(unsafePath); + }); + }); + + it("fails closed for shallow repositories", () => { + withTemporaryDirectory((parent) => { + const source = join(parent, "source"); + const shallow = join(parent, "shallow"); + mkdirSync(source); + initializeRepository(source); + writeFileSync(join(source, "README"), "first\n"); + commitAll(source, "first"); + writeFileSync(join(source, "README"), "second\n"); + commitAll(source, "second"); + git(parent, ["clone", "--depth=1", `file://${source}`, shallow]); + + expect(() => collectSecretViolations(shallow)).toThrow(/shallow/i); + }); + }); + + it("requires Git by default and supports explicit copied-file mode", () => { + withTemporaryDirectory((root) => { + writeFileSync(join(root, "release.env"), `${npmSecret()}\n`); + + expect(() => collectSecretViolations(root)).toThrow( + /requires a Git repository/, + ); + expect(collectSecretViolations(root, { mode: "files" })).toEqual([ + { + path: "release.env", + rule: "npm-access-token", + }, + ]); + }); + }); + + it("fetches full CI history only where the complete scan runs", () => { + const workflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", + ); + const locked = workflow.slice( + workflow.indexOf(" locked:\n"), + workflow.indexOf(" minimum-openai:\n"), + ); + + expect(workflow.match(/fetch-depth: 0/g)).toHaveLength(1); + expect(locked).toContain("fetch-depth: 0"); + expect(locked).toContain("run: npm run test:secrets"); + }); +}); diff --git a/tests/ci-workflow.test.mjs b/tests/ci-workflow.test.mjs new file mode 100644 index 0000000..2901215 --- /dev/null +++ b/tests/ci-workflow.test.mjs @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { URL } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { parseDocument } from "yaml"; + +function readWorkflow() { + const source = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", + ); + const document = parseDocument(source, { uniqueKeys: true }); + expect(document.errors).toEqual([]); + return document.toJS({ maxAliasCount: 100 }); +} + +describe("blocking CI workflow", () => { + it("runs the live-smoke contract in the locked Node.js 22 and 24 job", () => { + const workflow = readWorkflow(); + const locked = workflow.jobs?.locked; + + expect(locked).toBeDefined(); + expect(Object.keys(locked).sort()).toEqual([ + "name", + "runs-on", + "steps", + "strategy", + "timeout-minutes", + ]); + expect(Object.keys(locked.strategy).sort()).toEqual([ + "fail-fast", + "matrix", + ]); + expect(Object.keys(locked.strategy.matrix)).toEqual(["node-version"]); + expect(locked.strategy?.matrix?.["node-version"]).toEqual(["22.x", "24.x"]); + + const liveContractSteps = locked.steps.filter( + (step) => step.run === "npm run test:live-contract", + ); + expect(liveContractSteps).toHaveLength(1); + expect(Object.keys(liveContractSteps[0]).sort()).toEqual(["name", "run"]); + expect(liveContractSteps[0]).toMatchObject({ + name: "Verify live-smoke semantic checks with mocked transport", + run: "npm run test:live-contract", + }); + }); +}); diff --git a/tests/release-validation.test.mjs b/tests/release-validation.test.mjs index 4981531..50667f0 100644 --- a/tests/release-validation.test.mjs +++ b/tests/release-validation.test.mjs @@ -4,22 +4,56 @@ import { URL } from "node:url"; import { describe, expect, it } from "vitest"; import { + CANONICAL_IDENTITY, collectPublicPreviewViolations, distTagForVersion, escapeRegularExpression, SUPPORTED_NODE_ENGINES, + SUPPORTED_OPENAI_RANGE, validatePublicPreviewDocuments, validateReleaseMetadata, + visibleMarkdownText, } from "../scripts/release-validation.mjs"; +const PUBLIC_DOCUMENT_FILES = [ + ["agents", "AGENTS.md"], + ["architecture", "ARCHITECTURE.md"], + ["changelog", "CHANGELOG.md"], + ["compatibility", "COMPATIBILITY.md"], + ["conduct", "CODE_OF_CONDUCT.md"], + ["contributing", "CONTRIBUTING.md"], + ["license", "LICENSE"], + ["readme", "README.md"], + ["releasing", "RELEASING.md"], + ["roadmap", "ROADMAP.md"], + ["security", "SECURITY.md"], + ["support", "SUPPORT.md"], +]; + +function repositoryPublicPreviewFixture() { + return { + documents: Object.fromEntries( + PUBLIC_DOCUMENT_FILES.map(([field, filename]) => [ + field, + readFileSync(new URL(`../${filename}`, import.meta.url), "utf8"), + ]), + ), + sourceManifest: JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ), + }; +} + function fixture(version = "0.1.0-alpha.1") { const sourceManifest = { author: "CometAPI", bugs: { url: "https://github.com/cometapi-dev/cometapi-node/issues", }, + dependencies: { openai: SUPPORTED_OPENAI_RANGE }, engines: { node: SUPPORTED_NODE_ENGINES }, homepage: "https://www.cometapi.com", + keywords: ["cometapi", "typescript", "nodejs"], name: "cometapi", publishConfig: { access: "public", provenance: true }, repository: { @@ -47,20 +81,28 @@ function fixture(version = "0.1.0-alpha.1") { version, }, releaseDocuments: { - agents: "# Engineering contract\n", - architecture: "# Architecture\n", - changelog: `# Changelog\n\n## [${version}] - Unreleased\n`, - compatibility: "# Compatibility\n", - conduct: "Report privately to support@cometapi.com.\n", - contributing: "# Contributing\n", - license: "MIT License\n\nCopyright (c) 2026 CometAPI\n", - readme: `${version} is approved for npm publication.\n`, - releasing: "# Releasing\n", - roadmap: "# Roadmap\n", + agents: + "# CometAPI SDK Agent Instructions\n\nThis repository has a standalone engineering contract.\n", + architecture: + "# Architecture\n\nThe SDK reuses the official OpenAI client.\n\n## 0.1 boundary\n\nOnly the documented operations are supported.\n", + changelog: `# Changelog\n\nAll notable changes are recorded here.\n\n## [${version}] - Unreleased\n`, + compatibility: + "# Compatibility\n\nThis document records tested support.\n\n## Supported protocol surface\n\nOnly contract-tested operations are supported.\n", + conduct: + "# Code of Conduct\n\nContributors must participate respectfully.\n\n## Reporting and enforcement\n\nReport privately to support@cometapi.com.\n", + contributing: + "# Contributing\n\nContributions must include tests.\n\n## Development setup\n\nInstall from the lock file before running checks.\n", + license: + 'MIT License\n\nCopyright (c) 2026 CometAPI\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software, to use the Software subject to the MIT conditions.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.\n', + readme: `# CometAPI SDK\n\n**Pre-release:** the SDK is under active development. ${version} is approved for npm publication.\n\n## Supported 0.1 surface\n\n- \`chat.completions.create\`, streaming and non-streaming\n- \`responses.create\`, streaming and non-streaming\n- \`models.list\`\n`, + releasing: + "# Releasing\n\nRelease status is evidence-based.\n\n## Authorization boundary\n\nRemote publication requires maintainer authorization.\n", + roadmap: + "# CometAPI SDK Roadmap\n\nThis roadmap defines the release sequence.\n\n## Public Preview\n\nThe preview requires public documentation and offline CI.\n", security: - "Report vulnerabilities at https://github.com/cometapi-dev/cometapi-node/security/advisories/new.\n", + "# Security Policy\n\nNever disclose credentials publicly.\n\n## Reporting a vulnerability\n\nReport vulnerabilities at https://github.com/cometapi-dev/cometapi-node/security/advisories/new.\n", support: - "Email support@cometapi.com or use https://github.com/cometapi-dev/cometapi-node/issues.\n", + "# Support\n\nSupport covers the tested SDK surface.\n\n## Getting help\n\nEmail support@cometapi.com or use https://github.com/cometapi-dev/cometapi-node/issues.\n", }, releaseConfig: { packages: { ".": {} } }, releaseManifest: { ".": version }, @@ -79,6 +121,277 @@ describe("Public Preview content", () => { ).not.toThrow(); }); + it("accepts the real repository documents and package metadata", () => { + expect( + collectPublicPreviewViolations(repositoryPublicPreviewFixture()), + ).toEqual([]); + }); + + it.each(PUBLIC_DOCUMENT_FILES)( + "rejects single-character %s content", + (field, filename) => { + const values = fixture(); + values.releaseDocuments[field] = "x"; + expect( + collectPublicPreviewViolations({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }).join("\n"), + ).toMatch(new RegExp(filename.replaceAll(".", "\\."))); + }, + ); + + it.each([ + [ + "top-level pre-release label", + (readme) => readme.replace("**Pre-release:**", "Status:"), + /pre-release near the top/, + ], + [ + "pre-release label only in a link target", + (readme) => + readme.replace( + "**Pre-release:**", + "[Status](https://example.invalid/pre-release)", + ), + /pre-release near the top/, + ], + [ + "chat.completions.create", + (readme) => readme.replace("chat.completions.create", "chat.create"), + /exact operation chat\.completions\.create/, + ], + [ + "responses.create", + (readme) => readme.replace("responses.create", "responses.retrieve"), + /exact operation responses\.create/, + ], + [ + "models.list", + (readme) => readme.replace("models.list", "models.retrieve"), + /exact operation models\.list/, + ], + [ + "models.list in the supported section", + (readme) => + `${readme.replace("- `models.list`\n", "")}\n## Example\n\nCall \`models.list\`.\n`, + /exact operation models\.list/, + ], + [ + "models.list only in a link target", + (readme) => + readme.replace( + "- `models.list`", + "- [Model reference](https://example.invalid/models.list)", + ), + /exact operation models\.list/, + ], + [ + "Chat Completions streaming mode", + (readme) => + readme.replace( + "`chat.completions.create`, streaming and non-streaming", + "`chat.completions.create`, non-streaming", + ), + /chat\.completions\.create as streaming and non-streaming/, + ], + [ + "Responses non-streaming mode", + (readme) => + readme.replace( + "`responses.create`, streaming and non-streaming", + "`responses.create`, streaming", + ), + /responses\.create as streaming and non-streaming/, + ], + [ + "a distinct streaming mode", + (readme) => + readme.replace( + "`responses.create`, streaming and non-streaming", + "`responses.create`, non-streaming and nonstreaming", + ), + /responses\.create as streaming and non-streaming/, + ], + ])("rejects a README without %s", (_name, mutate, message) => { + const values = fixture(); + values.releaseDocuments.readme = mutate(values.releaseDocuments.readme); + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(message); + }); + + it.each([ + ["permission grant", /Permission is hereby granted[^\n]+\n\n/], + ["warranty disclaimer", /THE SOFTWARE IS PROVIDED[^\n]+\n/], + ])("rejects an MIT license without its %s", (_name, clause) => { + const values = fixture(); + values.releaseDocuments.license = values.releaseDocuments.license.replace( + clause, + "", + ); + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/LICENSE must contain the MIT/); + }); + + it.each([ + ["architecture", "## 0.1 boundary", "## Design notes"], + ["compatibility", "## Supported protocol surface", "## Notes"], + ["conduct", "## Reporting and enforcement", "## Contact"], + ["contributing", "## Development setup", "## Workflow"], + ["releasing", "## Authorization boundary", "## Process"], + ["roadmap", "## Public Preview", "## Current work"], + ["security", "## Reporting a vulnerability", "## Contact"], + ["support", "## Getting help", "## Contact"], + ])("rejects %s without its key contract section", (field, heading, other) => { + const values = fixture(); + values.releaseDocuments[field] = values.releaseDocuments[field].replace( + heading, + other, + ); + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(new RegExp(field === "conduct" ? "CODE_OF_CONDUCT" : field, "i")); + }); + + it("rejects an empty key contract section", () => { + const values = fixture(); + values.releaseDocuments.architecture = + "# Architecture\n\nThe SDK reuses the official client.\n\n## 0.1 boundary\n"; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/ARCHITECTURE\.md.*substantive contract content/); + }); + + it("does not count README requirements hidden in HTML comments", () => { + const values = fixture(); + values.releaseDocuments.readme = `# CometAPI SDK\n\n\n`; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/pre-release near the top/); + }); + + it.each(["`", "~"])( + "does not count Architecture requirements in %s fenced code", + (character) => { + const values = fixture(); + const fence = character.repeat(3); + values.releaseDocuments.architecture = `# Architecture\n\n${fence}markdown\n## 0.1 boundary\n\nOnly the documented operations are supported.\n${fence}\n`; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/ARCHITECTURE\.md.*0\.1 boundary/); + }, + ); + + it("does not count a required section in a CR-only fenced block", () => { + const values = fixture(); + values.releaseDocuments.architecture = + "# Architecture\r\r```markdown\r## 0.1 boundary\r\rOnly the documented operations are supported.\r```\r"; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/ARCHITECTURE\.md.*0\.1 boundary/); + }); + + it("does not turn a comment-prefixed line into a heading", () => { + const values = fixture(); + values.releaseDocuments.architecture = + "# Architecture\n\nThe SDK reuses the official client.\n\n## 0.1 boundary\n\nOnly the documented operations are supported.\n"; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/ARCHITECTURE\.md.*0\.1 boundary/); + }); + + it("keeps visible text around inline and multiline comments", () => { + expect( + visibleMarkdownText( + "Before after\nStart end", + ), + ).toBe("Before after\nStart end"); + }); + + it("accepts a canonical contact supplied as a Markdown link target", () => { + const values = fixture(); + values.releaseDocuments.security = `# Security Policy\n\nNever disclose credentials publicly.\n\n## Reporting a vulnerability\n\n[Open a private security advisory](${CANONICAL_IDENTITY.securityUrl}).\n`; + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).not.toThrow(); + }); + + it.each([ + [ + "TypeScript keyword", + (manifest) => { + manifest.keywords = manifest.keywords.filter( + (keyword) => keyword !== "typescript", + ); + }, + /keywords must contain typescript/, + ], + [ + "Node.js keyword", + (manifest) => { + manifest.keywords = manifest.keywords.filter( + (keyword) => keyword !== "nodejs", + ); + }, + /keywords must contain nodejs/, + ], + [ + "Node.js runtime range", + (manifest) => { + manifest.engines.node = ">=22"; + }, + /engines\.node/, + ], + [ + "OpenAI runtime dependency range", + (manifest) => { + manifest.dependencies.openai = "^7.0.0"; + }, + /dependencies\.openai/, + ], + ])( + "rejects package metadata without the %s contract", + (_name, mutate, message) => { + const values = fixture(); + mutate(values.sourceManifest); + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(message); + }, + ); + it("rejects handoff narrative", () => { const values = fixture(); values.releaseDocuments.roadmap = @@ -470,6 +783,66 @@ describe("release metadata validation", () => { }); }); + it("does not accept a release approval hidden in a comment", () => { + const values = fixture(); + values.changelog = `## [${values.sourceManifest.version}] - 2026-07-17\n`; + values.releaseDocuments.readme = `# CometAPI SDK\n\n\n`; + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireFinalReleaseState: true, + requireReleasableDocs: true, + }), + ).toThrow(/README/); + }); + + it.each([ + [ + "README", + (values) => { + values.releaseDocuments.readme = `# CometAPI SDK\r\r\`\`\`text\r${values.sourceManifest.version} is approved for npm publication.\r\`\`\`\r`; + }, + ], + [ + "SECURITY", + (values) => { + values.releaseDocuments.security = + "# Security Policy\r\r```text\rsecurity@cometapi.com\r```\r"; + }, + ], + [ + "SUPPORT", + (values) => { + values.releaseDocuments.support = + "# Support\r\r```text\rsupport@cometapi.com\r```\r"; + }, + ], + ])("does not accept %s evidence in a CR-only code fence", (label, mutate) => { + const values = fixture(); + values.changelog = `## [${values.sourceManifest.version}] - 2026-07-17\n`; + mutate(values); + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireFinalReleaseState: true, + requireReleasableDocs: true, + }), + ).toThrow(new RegExp(label)); + }); + + it("does not turn a comment-prefixed changelog line into a heading", () => { + const values = fixture(); + values.changelog = `# Changelog\n\n## [${values.sourceManifest.version}] - 2026-07-17\n`; + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + }), + ).toThrow(/CHANGELOG\.md must contain exactly one heading/); + }); + it("keeps the releasable-document gate in the publish workflow", () => { const workflow = readFileSync( new URL("../.github/workflows/publish.yml", import.meta.url), diff --git a/tests/self-contained.test.mjs b/tests/self-contained.test.mjs new file mode 100644 index 0000000..af56b8e --- /dev/null +++ b/tests/self-contained.test.mjs @@ -0,0 +1,143 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { materializeTrackedCandidate } from "../scripts/check-self-contained.mjs"; + +function git(root, ...args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }).trim(); +} + +function withRepository(callback) { + const parent = mkdtempSync(join(tmpdir(), "cometapi-self-contained-test-")); + const root = join(parent, "repository"); + mkdirSync(root); + git(root, "init", "--initial-branch=main"); + git(root, "config", "user.email", "tests@example.invalid"); + git(root, "config", "user.name", "CometAPI tests"); + writeFileSync(join(root, "tracked.txt"), "committed\n"); + git(root, "add", "tracked.txt"); + git(root, "commit", "-m", "test fixture"); + + try { + callback({ parent, root }); + } finally { + rmSync(parent, { force: true, recursive: true }); + } +} + +describe("self-contained candidate materialization", () => { + it("copies the exact committed tree and excludes untracked files", () => { + withRepository(({ parent, root }) => { + writeFileSync(join(root, "untracked.txt"), "local only\n"); + const candidate = materializeTrackedCandidate( + root, + join(parent, "candidate-parent"), + ); + + expect(readFileSync(join(candidate, "tracked.txt"), "utf8")).toBe( + "committed\n", + ); + expect(existsSync(join(candidate, "untracked.txt"))).toBe(false); + expect(existsSync(join(candidate, ".git"))).toBe(false); + }); + }); + + it("fails closed when a tracked file differs from HEAD", () => { + withRepository(({ parent, root }) => { + writeFileSync(join(root, "tracked.txt"), "modified\n"); + expect(() => + materializeTrackedCandidate(root, join(parent, "candidate-parent")), + ).toThrow(/clean tracked worktree/); + }); + }); + + it("includes tracked files marked export-ignore", () => { + withRepository(({ parent, root }) => { + writeFileSync( + join(root, ".gitattributes"), + "omitted.txt export-ignore\n", + ); + writeFileSync(join(root, "omitted.txt"), "tracked despite attribute\n"); + git(root, "add", ".gitattributes", "omitted.txt"); + git(root, "commit", "-m", "add export-ignored fixture"); + + const candidate = materializeTrackedCandidate( + root, + join(parent, "candidate-parent"), + ); + + expect(readFileSync(join(candidate, "omitted.txt"), "utf8")).toBe( + "tracked despite attribute\n", + ); + }); + }); + + it("materializes committed blob bytes without applying smudge filters", () => { + withRepository(({ parent, root }) => { + git(root, "config", "filter.mutate.clean", "cat"); + git( + root, + "config", + "filter.mutate.smudge", + "sed s/committed/transformed/g", + ); + writeFileSync( + join(root, ".gitattributes"), + "tracked.txt filter=mutate\n", + ); + git(root, "add", ".gitattributes"); + git(root, "commit", "-m", "add checkout filter fixture"); + + const candidate = materializeTrackedCandidate( + root, + join(parent, "candidate-parent"), + ); + + expect(readFileSync(join(candidate, "tracked.txt"), "utf8")).toBe( + "committed\n", + ); + }); + }); + + it("ignores replacement objects when reading HEAD", () => { + withRepository(({ parent, root }) => { + const originalCommit = git(root, "rev-parse", "HEAD"); + writeFileSync(join(root, "tracked.txt"), "replacement\n"); + git(root, "add", "tracked.txt"); + const replacementTree = git(root, "write-tree"); + const replacementCommit = git( + root, + "commit-tree", + replacementTree, + "-m", + "replacement fixture", + ); + git(root, "reset", "--hard", originalCommit); + git(root, "replace", originalCommit, replacementCommit); + + const candidate = materializeTrackedCandidate( + root, + join(parent, "candidate-parent"), + ); + + expect(readFileSync(join(candidate, "tracked.txt"), "utf8")).toBe( + "committed\n", + ); + }); + }); +}); diff --git a/tests/standalone-content.test.mjs b/tests/standalone-content.test.mjs index 9f157a1..0c85245 100644 --- a/tests/standalone-content.test.mjs +++ b/tests/standalone-content.test.mjs @@ -1,5 +1,6 @@ import { cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -7,12 +8,20 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { URL } from "node:url"; import { describe, expect, it } from "vitest"; import { collectPublicPreviewGateViolations } from "../scripts/check-public-preview.mjs"; +import { + collectStandaloneGateViolations, + collectStandaloneHistoryViolations, + contentScanMode, + reachableStandaloneTrees, +} from "../scripts/check-standalone-content.mjs"; import { ROOT } from "../scripts/lib.mjs"; import { collectStandaloneContentViolations, @@ -28,6 +37,34 @@ function withTemporaryDirectory(callback) { } } +function git(root, ...args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }).trim(); +} + +function gitWithInput(root, input, ...args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + input, + stdio: "pipe", + }).trim(); +} + +function initializeRepository(root) { + git(root, "init", "--initial-branch=main"); + git(root, "config", "user.name", "Standalone Content Test"); + git(root, "config", "user.email", "standalone-content@example.invalid"); +} + +function commitAll(root, message) { + git(root, "add", "--all"); + git(root, "commit", "--message", message); +} + describe("standalone content", () => { it("collects independent outside-root references", () => { withTemporaryDirectory((root) => { @@ -129,4 +166,224 @@ describe("standalone content", () => { ); }); }); + + it("scans a historical tree after its outside-root reference is deleted", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const path = join(root, "removed.md"); + writeFileSync(path, "See ../private/plan.md.\n"); + commitAll(root, "add outside-root reference"); + const leakingCommit = git(root, "rev-parse", "HEAD"); + const leakingTree = git(root, "rev-parse", "HEAD^{tree}"); + rmSync(path); + commitAll(root, "remove outside-root reference"); + + expect(collectStandaloneHistoryViolations(root)).toEqual([ + expect.stringContaining( + `commit=${leakingCommit} tree=${leakingTree}: removed.md: parent-relative path escapes`, + ), + ]); + }); + }); + + it("scans a historical tree after its private workspace path is deleted", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const path = join(root, "removed.md"); + const siblingWorkspace = `${["cometapi", "python"].join("-")}/README.md`; + writeFileSync(path, `See ${siblingWorkspace}.\n`); + commitAll(root, "add sibling workspace reference"); + const leakingCommit = git(root, "rev-parse", "HEAD"); + const leakingTree = git(root, "rev-parse", "HEAD^{tree}"); + rmSync(path); + commitAll(root, "remove sibling workspace reference"); + + expect(collectStandaloneHistoryViolations(root)).toEqual([ + expect.stringContaining( + `commit=${leakingCommit} tree=${leakingTree}: removed.md: references a private workspace or sibling repository`, + ), + ]); + }); + }); + + it("materializes exact trees without honoring export-ignore attributes", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + writeFileSync(join(root, ".gitattributes"), "hidden.md export-ignore\n"); + writeFileSync(join(root, "hidden.md"), "See ../private/plan.md.\n"); + commitAll(root, "add ignored outside-root reference"); + + expect(collectStandaloneHistoryViolations(root).join("\n")).toMatch( + /hidden\.md: parent-relative path escapes/, + ); + }); + }); + + it.each([ + ["case-folded", "A.md", "a.md"], + [ + "Unicode-normalized", + ["caf", "e\u0301.md"].join(""), + ["caf", "\u00e9.md"].join(""), + ], + ])("rejects %s historical path collisions", (_name, unsafePath, safePath) => { + withTemporaryDirectory((root) => { + initializeRepository(root); + const outsideReference = ["..", "private", "plan.md"].join("/"); + const unsafeBlob = gitWithInput( + root, + `See ${outsideReference}.\n`, + "hash-object", + "-w", + "--stdin", + ); + const safeBlob = gitWithInput( + root, + "safe\n", + "hash-object", + "-w", + "--stdin", + ); + const entries = [ + { objectId: unsafeBlob, path: unsafePath }, + { objectId: safeBlob, path: safePath }, + ].sort((left, right) => + Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)), + ); + const tree = gitWithInput( + root, + entries + .map(({ objectId, path }) => `100644 blob ${objectId}\t${path}\n`) + .join(""), + "mktree", + ); + const commit = git(root, "commit-tree", tree, "-m", "collision fixture"); + git(root, "update-ref", "refs/heads/main", commit); + + expect(() => collectStandaloneHistoryViolations(root)).toThrow( + /filesystem-equivalent paths/, + ); + }); + }); + + it("includes unmerged ref trees and the detached HEAD tree once each", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + writeFileSync(join(root, "README.md"), "main\n"); + commitAll(root, "main tree"); + const mainCommit = git(root, "rev-parse", "HEAD"); + const mainTree = git(root, "rev-parse", "HEAD^{tree}"); + + writeFileSync(join(root, "README.md"), "detached\n"); + git(root, "add", "README.md"); + const detachedTree = git(root, "write-tree"); + const detachedCommit = git( + root, + "commit-tree", + detachedTree, + "-p", + mainCommit, + "-m", + "detached tree", + ); + git(root, "checkout", "--detach", detachedCommit); + + git(root, "checkout", "main"); + writeFileSync(join(root, "README.md"), "side\n"); + commitAll(root, "side tree"); + const sideCommit = git(root, "rev-parse", "HEAD"); + const sideTree = git(root, "rev-parse", "HEAD^{tree}"); + git(root, "branch", "side", sideCommit); + git(root, "reset", "--hard", mainCommit); + git(root, "checkout", "--detach", detachedCommit); + + const trees = reachableStandaloneTrees(root); + expect([...trees.keys()]).toEqual( + expect.arrayContaining([mainTree, sideTree, detachedTree]), + ); + expect([...trees.keys()].filter((tree) => tree === detachedTree)).toEqual( + [detachedTree], + ); + }); + }); + + it("does not let replacement objects hide a historical violation", () => { + withTemporaryDirectory((root) => { + initializeRepository(root); + writeFileSync(join(root, "notes.md"), "See ../private/plan.md.\n"); + commitAll(root, "add outside-root reference"); + const originalCommit = git(root, "rev-parse", "HEAD"); + const originalTree = git(root, "rev-parse", "HEAD^{tree}"); + + writeFileSync(join(root, "notes.md"), "safe\n"); + git(root, "add", "notes.md"); + const safeTree = git(root, "write-tree"); + const safeCommit = git( + root, + "commit-tree", + safeTree, + "-m", + "safe replacement", + ); + git(root, "replace", originalCommit, safeCommit); + + expect(collectStandaloneHistoryViolations(root)).toEqual([ + expect.stringContaining( + `commit=${originalCommit} tree=${originalTree}: notes.md: parent-relative path escapes`, + ), + ]); + expect(git(root, "rev-parse", "HEAD^{tree}")).toBe(safeTree); + }); + }); + + it("rejects shallow Git history", () => { + withTemporaryDirectory((parent) => { + const source = join(parent, "source"); + const shallow = join(parent, "shallow"); + mkdirSync(source); + initializeRepository(source); + writeFileSync(join(source, "README.md"), "safe\n"); + commitAll(source, "first"); + writeFileSync(join(source, "README.md"), "still safe\n"); + commitAll(source, "second"); + execFileSync("git", ["clone", "--depth=1", `file://${source}`, shallow], { + stdio: "pipe", + }); + + expect(() => collectStandaloneHistoryViolations(shallow)).toThrow( + /complete Git history; shallow repositories are rejected/, + ); + }); + }); + + it("uses file-only mode for the isolated self-containment copy", () => { + withTemporaryDirectory((root) => { + const outsideReference = ["..", "private", "plan.md"].join("/"); + writeFileSync(join(root, "notes.md"), `See ${outsideReference}.\n`); + + expect(contentScanMode({ COMETAPI_SELF_CONTAINMENT: "1" })).toBe("files"); + expect(collectStandaloneGateViolations(root, { mode: "files" })).toEqual([ + expect.stringMatching(/parent-relative path escapes/), + ]); + expect(existsSync(join(root, ".git"))).toBe(false); + }); + }); + + it("runs the command in isolated mode without requiring Git metadata", () => { + withTemporaryDirectory((root) => { + const script = new URL( + "../scripts/check-standalone-content.mjs", + import.meta.url, + ); + writeFileSync(join(root, "README.md"), "standalone safe content\n"); + const result = spawnSync(process.execPath, [script.pathname], { + cwd: root, + encoding: "utf8", + env: { ...process.env, COMETAPI_SELF_CONTAINMENT: "1" }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/isolated tracked copy/); + }); + }); }); From 550eee2219e49099bac322c6a7157e086de75c95 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Wed, 22 Jul 2026 18:01:05 +0800 Subject: [PATCH 2/2] fix CI validation dependency ordering --- .github/workflows/ci.yml | 3 ++ .github/workflows/publish.yml | 12 ++--- tests/ci-workflow.test.mjs | 82 ++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45c4b7a..4f79c08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 24.x + cache: npm + - name: Install locked dependencies + run: npm ci - name: Validate Public Preview content and identity run: npm run check:public-preview - name: Download the pinned actionlint release diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 21a3a80..538657e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -80,6 +80,13 @@ jobs: EOF echo "release-commit=${release_commit}" >> "$GITHUB_OUTPUT" + - name: Set up Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24.x + cache: npm + - name: Install validation dependencies without lifecycle scripts + run: npm ci --ignore-scripts - name: Verify release metadata and derive the npm dist-tag id: version env: @@ -93,11 +100,6 @@ jobs: --release-prerelease "$RELEASE_IS_PRERELEASE" \ --require-final \ --require-releasable-docs >> "$GITHUB_OUTPUT" - - name: Set up Node.js 24 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 24.x - cache: npm - name: Use a Trusted Publishing-capable npm CLI run: npm install --global npm@11.12.1 - name: Install locked dependencies diff --git a/tests/ci-workflow.test.mjs b/tests/ci-workflow.test.mjs index 2901215..d904bc0 100644 --- a/tests/ci-workflow.test.mjs +++ b/tests/ci-workflow.test.mjs @@ -4,9 +4,9 @@ import { URL } from "node:url"; import { describe, expect, it } from "vitest"; import { parseDocument } from "yaml"; -function readWorkflow() { +function readWorkflow(name = "ci.yml") { const source = readFileSync( - new URL("../.github/workflows/ci.yml", import.meta.url), + new URL(`../.github/workflows/${name}`, import.meta.url), "utf8", ); const document = parseDocument(source, { uniqueKeys: true }); @@ -15,6 +15,84 @@ function readWorkflow() { } describe("blocking CI workflow", () => { + it("installs dependencies before dependency-backed workflow gates", () => { + const workflowLint = readWorkflow().jobs?.["workflow-lint"]; + expect(workflowLint).toBeDefined(); + const previewInstall = workflowLint.steps.findIndex( + (step) => step.run === "npm ci", + ); + const previewGate = workflowLint.steps.findIndex( + (step) => step.run === "npm run check:public-preview", + ); + expect(workflowLint.steps[previewInstall]).toEqual({ + name: "Install locked dependencies", + run: "npm ci", + }); + expect(workflowLint.steps[previewGate]).toEqual({ + name: "Validate Public Preview content and identity", + run: "npm run check:public-preview", + }); + expect(previewGate).toBeGreaterThan(previewInstall); + + const releaseVerify = readWorkflow("publish.yml").jobs?.verify; + expect(releaseVerify).toBeDefined(); + const trustGate = releaseVerify.steps.findIndex( + (step) => step.id === "trust", + ); + const validationInstall = releaseVerify.steps.findIndex( + (step) => step.run === "npm ci --ignore-scripts", + ); + const releaseGate = releaseVerify.steps.findIndex( + (step) => + typeof step.run === "string" && + step.run.includes("node scripts/validate-release.mjs"), + ); + const fullInstall = releaseVerify.steps.findIndex( + (step, index) => index > releaseGate && step.run === "npm ci", + ); + expect(Object.keys(releaseVerify.steps[trustGate]).sort()).toEqual([ + "env", + "id", + "name", + "run", + "shell", + ]); + expect(releaseVerify.steps[trustGate]).toMatchObject({ + id: "trust", + name: "Reject an untrusted release target", + shell: "bash", + }); + expect(releaseVerify.steps[validationInstall]).toEqual({ + name: "Install validation dependencies without lifecycle scripts", + run: "npm ci --ignore-scripts", + }); + expect(validationInstall).toBeGreaterThan(trustGate); + expect(releaseVerify.steps[releaseGate]).toEqual({ + env: { + RELEASE_IS_PRERELEASE: "${{ github.event.release.prerelease }}", + RELEASE_TAG: "${{ github.event.release.tag_name }}", + }, + id: "version", + name: "Verify release metadata and derive the npm dist-tag", + run: [ + "set -euo pipefail", + "node scripts/validate-release.mjs \\", + ' --tag "$RELEASE_TAG" \\', + ' --release-prerelease "$RELEASE_IS_PRERELEASE" \\', + " --require-final \\", + ' --require-releasable-docs >> "$GITHUB_OUTPUT"', + "", + ].join("\n"), + shell: "bash", + }); + expect(releaseGate).toBeGreaterThan(validationInstall); + expect(releaseVerify.steps[fullInstall]).toEqual({ + name: "Install locked dependencies", + run: "npm ci", + }); + expect(fullInstall).toBeGreaterThan(releaseGate); + }); + it("runs the live-smoke contract in the locked Node.js 22 and 24 job", () => { const workflow = readWorkflow(); const locked = workflow.jobs?.locked;