diff --git a/SECURITY.md b/SECURITY.md index d910e5e..06ddaea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ Security fixes are applied to the latest published minor release. Older releases ## Reporting a vulnerability -Use GitHub's private vulnerability reporting for `cucuwang/geoptimize` when available. If the repository does not show a private reporting option, open a minimal issue asking for a private contact channel; do not include exploit details, credentials, private URLs, or user data in a public issue. +Private vulnerability reporting is enabled for `cucuwang/geoptimize`. Submit a report through [GitHub's private vulnerability reporting form](https://github.com/cucuwang/geoptimize/security/advisories/new). Do not include credentials or unnecessary personal data. Include sensitive reproduction details only in that private report. Include the affected version, environment, reproduction preconditions, impact, and the smallest safe proof of concept. You should receive an acknowledgement within seven days. A remediation timeline depends on severity and reproducibility. diff --git a/docs/code-scanning-remediation.md b/docs/code-scanning-remediation.md new file mode 100644 index 0000000..4606c12 --- /dev/null +++ b/docs/code-scanning-remediation.md @@ -0,0 +1,177 @@ +# Code scanning remediation plan + +Assessment date: 2026-09-11. Repository: `cucuwang/geoptimize`. Baseline commit: +`e4580129e746110cdacf3b4644757c9575c56a93`. The latest readback still reports all +13 alerts open on `main`. This file records the work required to make each result +reviewable; it does not claim that an alert is closed. + +## Evidence boundary + +The public Scorecard result for this commit was produced at 2026-09-11 06:28:05Z +with Scorecard v5.3.0 and score 6.5. The pinned +[`ossf/scorecard-action` v2.4.3 `go.mod`](https://github.com/ossf/scorecard-action/blob/v2.4.3/go.mod) +requires Scorecard v5.3.0. The current Scorecard result is available from the +[official result API](https://api.securityscorecards.dev/projects/github.com/cucuwang/geoptimize). + +The CodeQL 2.27.0 baseline reproduced four findings at the original locations: +two `js/polynomial-redos` findings in `src/core/ai-prompt.ts:3` and +`src/core/site-audit.ts:93`, and two `js/bad-tag-filter` findings in +`src/core/__tests__/visual-report.test.ts:35` and `:97`. Local worker changes are +not public alert closure. A workflow success, local scan, or agent review does not +replace the final GitHub code-scanning readback. + +Status in this plan uses these meanings: + +- `Local planned / worker`: a local change or verification is assigned in the + current worktree. It has not become public evidence. +- `Remote awaiting authorization`: applying a GitHub setting, creating external + attestations, or rerunning a historical workflow needs explicit remote + authorization. Read-only result and alert readback remains available for review. +- `Observed`: a value read from the current public result or the repository + administration evidence. It is retained as a baseline, not upgraded to a fix. + +## Per-alert plan + +| # | Alert and observed baseline | Local status / owner | Exact acceptance and readback | Rollback | +| --- | --- | --- | --- | --- | +| 1 | **Branch-Protection**: Scorecard 4. Warnings are stale-review dismissal off, zero required approvers, CODEOWNERS review off, and last-push approval off on `main`. A fresh release readback shows all 10 current GitHub Releases target `main`, so the current Scorecard default/release set is `main`. | Local plan by Luna. No GitHub mutation. | After reviewer identities are confirmed, change only the four pull-request review fields on `main-required-checks` and read every current default/release target. The proposed full target is the rule field set below, followed by a Scorecard v5.3.0 result of 10 with no Branch-Protection warnings. Re-evaluate if a future release targets a branch. | Save pre-change ruleset/branch JSON. Restore only the previous field values if the rule causes a merge or release problem. Keep `protect-main`, its maintenance-branch coverage, and protected release tags. | +| 2 | **Pinned-Dependencies**: `npmCommand` warning at `scripts/verify-release-candidate.sh:94`; current check score 7. | Implemented and locally verified. Pinned-Dependencies 10; Node 22/24 release gates passed. | The clean consumer must install the verified tarball from a generated lockfile, retain SHA-256/SHA-512 and package-contract checks, and pass `npm run release:check`. Local and next main Scorecard v5.3.0 results must show no `npmCommand not pinned by hash` warning for this path. | Revert only the helper and its named call site using the worker diff; retain the verified tarball and checksums. | +| 3 | **Pinned-Dependencies**: `npmCommand` warning at `scripts/verify-release-v0.6.sh:128`; current check score 7. | Implemented and locally verified. Pinned-Dependencies 10; Node 22/24 release gates passed. | Run the v0.6 verifier against its existing expected artifact path and check the final Scorecard result for zero unpinned npm commands. Do not weaken the version, digest, or public-package checks. | Restore only the v0.6 call-site/helper pair and keep its prior verification gates. | +| 4 | **Pinned-Dependencies**: `npmCommand` warning at `scripts/verify-release-v0.8.sh:128`; current check score 7. | Implemented and locally verified. Pinned-Dependencies 10; Node 22/24 release gates passed. | Run the v0.8 verifier against its existing expected artifact path and check the final Scorecard result for zero unpinned npm commands. | Restore only the v0.8 call-site/helper pair and keep its prior verification gates. | +| 5 | **Code-Review**: Scorecard found `0/19` approved changesets. | Remote awaiting authorization and a real independent human reviewer. Root/Luna review cannot manufacture the historical approvals. | For each sampled human changeset, require an actual GitHub `APPROVED` review by a human whose login differs from the author. Scorecard also recognizes a non-author merger in its detector, but that source behavior does not fabricate the requested historical approvals. Read the review records and then a new Scorecard v5.3.0 result. A branch rule alone cannot rewrite the historical 0/19 window. | Never delete or synthesize review records. If a branch rule is staged and causes deadlock, restore its prior approval count while retaining the review audit. | +| 6 | **Security-Policy**: Scorecard 4 because `SECURITY.md` was detected but had no linked content. | Implemented and locally verified. Security-Policy 10; post-merge readback pending. | The file now states that private vulnerability reporting is enabled and links directly to `https://github.com/cucuwang/geoptimize/security/advisories/new`; scope and seven-day acknowledgement target remain. The fresh repository API readback is `enabled: true`. After merge, read a Scorecard result containing `Found linked content` and no Security-Policy warning. | Restore the previous text only through a reviewed file diff if required. Do not create a public exploit-report fallback or change the remote reporting setting here. | +| 7 | **CII-Best-Practices**: Scorecard 0, no badge detected. | Local planned / gap document updated. External registration and attestations are remote awaiting authorization. | A maintainer registers the canonical URL `https://github.com/cucuwang/geoptimize`, completes the actual Passing questionnaire, and supplies human attestations for `know_secure_design` and `know_common_errors`. v5.3.0 maps InProgress to 2, Passing to 5, Silver to 7, and Gold to 10. This workflow supplies no custom policy, so the pinned v2.4.3 default policy's per-check threshold is 5. Read the effective SARIF policy, confirm a public Passing-or-higher project status, and confirm no `CIIBestPracticesID` result for the same commit before adding a badge. | Keep the badge absent while registration or review is incomplete. If a public badge is later found to be unearned or stale, remove only that badge and correct the questionnaire evidence; do not fabricate a status. | +| 8 | **SAST**: Scorecard 7; 8 of 23 sampled PR heads had an accepted SAST check. | Local planned / existing CodeQL workflow retained unchanged. No complex arbitrary-code workflow. | For every sampled merged PR, read check runs for the exact PR `HeadSHA`; accept only completed runs with conclusion `success` or `neutral` and app slug `github-code-scanning` or `github-advanced-security`. A new Scorecard v5.3.0 result must report all sampled commits checked. Main-only rescans do not backfill old PR heads. Historical backfill is valid only when GitHub can rerun that exact retained PR head and the resulting check run is read back. Otherwise allow the normal future window to converge. | Keep the current workflow and action pins. If a workflow edit is later authorized and fails, restore the last known-good workflow without changing unrelated workflows. | +| 9 | **Fuzzing**: Scorecard 0, no fuzzer integration detected. | Implemented and locally verified. Fuzzing 10; standard and extended property tests passed. | The TypeScript test imports `fast-check` and exercises bounded parser/report properties. Run `npm test`, `npm run test:fuzz`, and `npm run build`; local Scorecard v5.3.0 must detect the TypeScript property-based integration, then a later main result must report `project is fuzzed`. Property tests alone do not prove the remote result. | Revert only the worker's test and dev-dependency changes if they fail review; preserve unrelated tests and source fixes. | +| 10 | **CodeQL `js/polynomial-redos`** at `src/core/ai-prompt.ts:3`; input was the HTML tag-stripping regexp. | Implemented and locally verified. Final CodeQL scan returned zero findings. | The final code must use linear tag scanning with the existing 8,000-character excerpt limit and equivalent prompt output. Targeted tests and CodeQL 2.27.0 on the final commit must show no finding for this query/path. | Restore only the `ai-prompt.ts` change from its precise diff if behavior regresses. | +| 11 | **CodeQL `js/polynomial-redos`** at `src/core/site-audit.ts:93`; input was the robots comment regexp. | Implemented and locally verified. Final CodeQL scan returned zero findings. | The final parser must retain directive and sitemap semantics while avoiding the flagged regexp. Targeted robots tests and CodeQL 2.27.0 on the final commit must show no finding for this query/path. | Restore only the `site-audit.ts` change from its precise diff if behavior regresses. | +| 12 | **CodeQL `js/bad-tag-filter`** at `src/core/__tests__/visual-report.test.ts:35`; assertion parsed generated HTML with a case-sensitive tag regexp. | Implemented and locally verified. Final CodeQL scan returned zero findings. | Parse the rendered report as a DOM and assert text and executable-node invariants through Cheerio. The visual-report tests must pass and CodeQL 2.27.0 must return no finding at the final path. | Restore only the affected assertion block from its precise diff; retain the production renderer and unrelated tests. | +| 13 | **CodeQL `js/bad-tag-filter`** at `src/core/__tests__/visual-report.test.ts:97`; same assertion risk in interactive details. | Implemented and locally verified. Final CodeQL scan returned zero findings. | Apply the same DOM-based assertion discipline to the interactive report case, then read back a clean CodeQL result for the final path. | Restore only the affected assertion block from its precise diff. | + +## Branch-Protection full-score target + +The v5.3.0 evaluator uses a tiered score. One approving reviewer reaches only the +review prerequisite; the full score requires at least two approving reviewers, +review from code owners, last-push approval, stale-review dismissal, administrator +enforcement, up-to-date branches, required status checks, pull requests for code +changes, and deletion/force-push protection. See the official +[branch-protection evaluator](https://github.com/ossf/scorecard/blob/v5.3.0/checks/evaluation/branch_protection.go) +and [raw branch collection](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/branch_protection.go). + +The current `main-required-checks` ruleset (ID `22637595`) is Active, applies to the +default branch, has zero bypass actors, requires pull requests, resolved conversations +and up-to-date branches, and currently has required approving reviews `0`, stale-review +dismissal `false`, CODEOWNERS review `false`, and last-push approval `false`. Its eight +required contexts are: + +```text +test-and-build (22) +test-and-build (24) +release-candidate-reproducibility +lint-readme-commands +action-contract +dependency-review +CodeQL (javascript-typescript) +CodeQL (actions) +``` + +The exact remote proposal is: + +| Rule field | Full-score target | Preserve | +| --- | --- | --- | +| Required approving reviews | `2` | First identify two independent human reviewer accounts. A temporary value of `1` is a practical risk step only after one reviewer is available; it is not the full-score target. | +| Dismiss stale approvals when new commits are pushed | Enabled | Existing pull-request and conversation-resolution requirements. | +| Require review from Code Owners | Enabled | The existing `.github/CODEOWNERS` file; confirm an eligible human owner before enabling. | +| Require approval of the most recent reviewable push | Enabled | Existing branch freshness requirement. | +| Require status checks | Enabled, strict/up to date | All eight contexts above, with their current names and matrix values. | +| Require pull request before changes | Enabled | Existing no-direct-change protection. | +| Bypass actors / administrator bypass | Empty / disabled | Existing no-bypass posture. Do not add an emergency bypass to solve a maintainer deadlock. | + +Scorecard v5.3.0 evaluates the default branch and release branches discovered from +repository releases. The current readback enumerated all 10 release targets as `main`, +so changing `main-required-checks` on `main` covers the present Scorecard target set. +The legacy `protect-main` ruleset (ID `22617494`) separately covers +`maintenance/aeoptimize-0.7` and `maintenance/geoptimize-0.8` for deletion and +non-fast-forward protection; preserve that coverage unchanged. Do not extend the +eight main CI contexts to those maintenance branches because the workflows target +`main` and such an extension could deadlock their maintenance work. Re-enumerate +release targets whenever a future release branch is introduced. `protect-release-tags` +for `v*.*.*` also remains unchanged. + +There is no reviewer identity in the current evidence. Confirm the people who can +review and the code-owner account first. This avoids putting a single maintainer +behind an approval count or CODEOWNERS requirement that nobody else can satisfy. +If a second independent reviewer cannot be confirmed, keep the full-score target +pending and report the practical one-reviewer posture as partial. + +## Detection sources and remote gates + +The official v5.3.0 sources define the acceptance conditions used above: + +- [Branch-Protection evaluation](https://github.com/ossf/scorecard/blob/v5.3.0/checks/evaluation/branch_protection.go) + and [raw branch discovery](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/branch_protection.go) + cover default and release branches and the tiered 10-point target. +- [Code-Review raw check](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/code_review.go) + and the [`codeApproved` probe](https://github.com/ossf/scorecard/blob/v5.3.0/probes/codeApproved/impl.go) + group recent commits into changesets and require an approval by a login different + from the author; bot-authored changesets are skipped. The GitHub adapter also + records a non-author merger as an approval, which is detector behavior rather than + a substitute for the requested independent review evidence. +- [Security-Policy parsing](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/security_policy.go) + and [evaluation](https://github.com/ossf/scorecard/blob/v5.3.0/checks/evaluation/security_policy.go) + award points for a policy file, reporting text and a URL or email link. +- [CII raw lookup](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/cii_best_practices.go) + asks the Best Practices API for the repository URI; [evaluation](https://github.com/ossf/scorecard/blob/v5.3.0/checks/evaluation/cii_best_practices.go) + maps InProgress/Passing/Silver/Gold to 2/5/7/10. The pinned action's + [default policy](https://github.com/ossf/scorecard-action/blob/v2.4.3/policies/template.yml) + sets the Passing threshold to 5. +- [SAST raw detection](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/sast.go) + calls `ListCheckRunsForRef(pr.HeadSHA)`, accepts completed `success` or `neutral` + runs and recognizes the relevant CodeQL app slugs. [SAST evaluation](https://github.com/ossf/scorecard/blob/v5.3.0/checks/evaluation/sast.go) + scores the sampled proportion and gives the all-commits result only at full coverage. +- [Fuzzing detection](https://github.com/ossf/scorecard/blob/v5.3.0/checks/raw/fuzzing.go) + recognizes the TypeScript `fast-check` import pattern; the [fuzzed probe definition](https://github.com/ossf/scorecard/blob/v5.3.0/probes/fuzzed/def.yml) + is separate from the SAST check and does not replace this Fuzzing acceptance. +- CodeQL's official help for [`js/polynomial-redos`](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/) + and [`js/bad-tag-filter`](https://codeql.github.com/codeql-query-help/javascript/js-bad-tag-filter/) + describes the two query classes addressed by the local workers. + +Remote operations remain outside this local plan: changing rulesets, assigning or +inviting reviewers, registering or answering the Best Practices questionnaire, +rerunning retained historical PR workflows, and declaring or dismissing alerts. +After any authorized remote step, read back the exact setting or public result and +retain its timestamp, commit SHA and relevant run or project identifier. + +## Local verification and remaining blockers + +Code and tests were verified at implementation commit `e555f752da92e29c3e7d3276589bbc8f0098c0b2`. +The scanners used the same versions as the baseline GitHub analyses. + +| Check | Baseline | Revised local result | +| --- | --- | --- | +| CodeQL 2.27.0, JavaScript queries 2.4.5 | 4 findings | 0 findings across 87 rules; 47 source files analyzed; no execution errors or warnings | +| Scorecard v5.3.0 Pinned-Dependencies | 7 | 10; all 6 npm commands pinned | +| Scorecard v5.3.0 Security-Policy | 4 | 10; reporting link detected | +| Scorecard v5.3.0 Fuzzing | 0 | 10; TypeScript property tests detected | +| Fresh `npm run check` without pre-existing `dist` | Not recorded for this change | 263 tests across 19 files and TypeScript build passed | +| Extended property tests | Not recorded for this change | 3 properties, 1,000 cases each, seed `1192026`, passed | +| Clean Node 22.23.2 / npm 10.9.8 release gate | Not recorded for this change | Passed | +| Clean Node 24.11.1 / npm 11.19.0 release gate | Not recorded for this change | Passed | + +Both release gates ran the complete tests, TypeScript build, Action contract, +zero-vulnerability npm audit, real consumer `npm ci`, all three CLI aliases and +report smoke checks. The resulting tarballs were byte-identical, with 104 +files and SHA-256 `6564bbaa28cdf086a179610dfcd95555fe4f24f6a87d7762fe310f2e319128f4`. +The two public verifier scripts are byte-identical; their shared behavior is covered +by the verifier tests, including rejected hashes, failed downloads and symlinked +`TMPDIR`. No public release was created by these checks. + +Alerts #2, #3, #4, #6, #9, #10, #11, #12 and #13 have verified local remediation. +Their GitHub closure requires the reviewed changes to reach `main` and the next +CodeQL/Scorecard analyses to be read back. Push, PR creation, merge and repository +setting changes remain separate remote actions. + +Alerts #1, #5, #7 and #8 retain the human review, external questionnaire and +historical coverage requirements described above. No reviewer identity has been +selected, no Best Practices claim has been submitted, and repository rules remain +unchanged. Read-only GitHub verification remains authorized. diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 0000000..9f2dcbe --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,23 @@ +# Property-based security checks + +`src/core/__tests__/security-fuzz.test.ts` uses the exact `fast-check` `4.10.0` development dependency to exercise the public parsing, prompt, and report-rendering APIs with bounded generated inputs. The checks cover robots comment handling and hash tails, HTML-to-prompt normalization and the 8,000-character content excerpt limit, and hostile report fields rendered through Cheerio to verify that they do not create additional executable DOM nodes. + +The default run is deterministic and bounded at 200 cases per property with seed `20260911`. The file is included by the normal `npm test` command. To run only these checks, use: + +```sh +npm run test:fuzz +``` + +When fast-check reports a failing case, replay it with the reported seed and path while selecting the failing property by its exact test name. This keeps a path from one property from being applied to the other properties: + +```sh +FUZZ_SEED=20260911 FUZZ_PATH='0:1:2' npm run test:fuzz -- -t 'keeps robots comments inert and never retains a hash comment tail in parsed rules' +``` + +The path is the value printed by fast-check for the failing property. Replace the seed, path, and test name with the values from the failure report. For a longer local run, increase the case budget without changing the default CI workload: + +```sh +FUZZ_NUM_RUNS=1000 npm run test:fuzz +``` + +These are deterministic property-based tests. They are not coverage-guided fuzzing, do not make network requests, and do not replace the repository's static analysis or browser-level verification. diff --git a/docs/openssf-best-practices.md b/docs/openssf-best-practices.md index 2cdaaa4..c82af02 100644 --- a/docs/openssf-best-practices.md +++ b/docs/openssf-best-practices.md @@ -1,7 +1,9 @@ # OpenSSF Best Practices: Passing gap analysis -Assessment date: 2026-09-09; release evidence reviewed 2026-09-11. This is evidence preparation, not a certification or -completed questionnaire. [Official Passing criteria](https://www.bestpractices.dev/en/criteria/0?details=true&rationale=true) +Assessment date: 2026-09-11; release evidence reviewed 2026-09-11. The repository +state under review is `e4580129e746110cdacf3b4644757c9575c56a93`. This is evidence +preparation, not a certification or completed questionnaire. [Official Passing +criteria](https://www.bestpractices.dev/en/criteria/0?details=true&rationale=true) remain authoritative; review every applicable MUST/MUST NOT and justify SHOULD items when registering. Silver and Gold are outside scope. @@ -25,19 +27,30 @@ transitive dependency or the maintainer's historical response performance. | Work | Status / evidence needed | | --- | --- | -| Static analysis for application and workflow sources | CodeQL and Scorecard workflows pass on current main; ongoing finding triage remains | +| Static analysis for application and workflow sources | CodeQL and Scorecard workflow runs for `e458012` completed successfully. Scorecard v5.3.0 still reports open findings, including SAST 7 with 8 of 23 sampled PR heads checked; the four CodeQL baseline findings were reproduced locally with CodeQL 2.27.0. A successful workflow or local reproduction is not alert closure. | | Automated dependency review and updates | Dependency Review passed; Dependabot alerts, security updates and weekly version updates are enabled | | Verifiable release integrity | v0.10.0 is public with npm OIDC provenance, an SSH-signed tag, checksums, an SPDX SBOM and a GitHub attestation; future releases must repeat the same gates | -| Document operational trust boundaries | Release/settings/Action decision documents added | +| Document operational trust boundaries | Release/settings/Action decision documents added; the direct private-report link is a local working-tree change in this remediation and is not public evidence until merged | +| OpenSSF CII-Best-Practices check | Scorecard v5.3.0 reports score 0 because no badge is detected. Its evaluation maps InProgress to 2, Passing to 5, Silver to 7 and Gold to 10. This workflow has no custom policy input; the pinned action v2.4.3 template uses 5 as the default Passing threshold. Read the effective SARIF policy and the per-commit result before declaring an alert cleared. | | Release notes for security fixes | Changelog process exists; describe actual fixes and identifiers when applicable | +The current repository administration readback reports private vulnerability +reporting `enabled: true`. The working-tree `SECURITY.md` now links the direct +[private report form](https://github.com/cucuwang/geoptimize/security/advisories/new); +the link becomes public evidence only after this change is merged and the next +Scorecard result is read back. + ## Maintainer/manual verification required -- Register the project and answer all Passing criteria with public evidence links. -- `know_secure_design` / `know_common_errors`: identify a primary developer who can - substantiate secure-design and common-error knowledge. A workflow cannot prove this. -- Private reporting is enabled; monitor acknowledgement/remediation performance rather - than inferring historical responsiveness from a written target. +- Register the canonical project URL `https://github.com/cucuwang/geoptimize` at + [bestpractices.dev](https://www.bestpractices.dev/) and answer every applicable + Passing criterion with public evidence links. The external questionnaire and + its service-assigned project identity are required for the CII check. +- `know_secure_design` / `know_common_errors`: identify a primary developer who + can substantiate both areas and complete the service's human attestations. A + workflow, agent review or test count cannot provide these attestations. +- Private reporting is enabled; monitor acknowledgement/remediation performance + rather than inferring historical responsiveness from a written target. - Review outstanding static-analysis, dependency and reported vulnerability findings; document severity, disposition and timely fixes. Audit success is time-bound. - Confirm test-policy enforcement for new functionality and fixes, and that important @@ -47,8 +60,9 @@ transitive dependency or the maintainer's historical response performance. requirements where relevant, and absence of known unpatched exploitable vulnerabilities. - Confirm English/public documentation and issue/release accessibility, user support expectations, and release-note practice against the complete live questionnaire. -- Only insert the Best Practices badge after the service awards Passing. Scorecard is - a separate automated assessment and does not award this badge. +- Only insert the Best Practices badge after the service awards Passing or higher. + Read back the public project status and the [Scorecard API result](https://api.securityscorecards.dev/projects/github.com/cucuwang/geoptimize). + Scorecard is a separate automated assessment and does not award this badge. ## Not applicable (bounded, requires maintainer confirmation) @@ -59,3 +73,6 @@ transitive dependency or the maintainer's historical response performance. this does not exempt dependencies, browser interaction or input validation. - No specific N/A questionnaire answer is preselected. Use N/A only where the actual criterion allows it and the current code review supports the explanation. + +The per-alert implementation, acceptance readback, rollback notes and remote gates +are tracked in [docs/code-scanning-remediation.md](code-scanning-remediation.md). diff --git a/package-lock.json b/package-lock.json index 757b9e6..5bb106f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "fast-check": "4.10.0", "typescript": "^5.4.0", "vitest": "^4.1.10" }, @@ -849,6 +850,29 @@ "node": ">=0.10.0" } }, + "node_modules/fast-check": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz", + "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1457,6 +1481,23 @@ "node": ">=22.12.0" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", diff --git a/package.json b/package.json index e309f30..0997170 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "scripts/verify-release-candidate.sh", "scripts/verify-publish-source.sh", "scripts/verify-release-v0.6.sh", + "scripts/prepare-release-consumer.mjs", ".claude-plugin/", "docs/methodology.md", "docs/release-v0.6.md", @@ -51,6 +52,7 @@ "release:check": "bash scripts/verify-release-candidate.sh", "dev": "tsc --watch", "test": "vitest run", + "test:fuzz": "vitest run src/core/__tests__/security-fuzz.test.ts", "test:watch": "vitest", "prepublishOnly": "npm run release:check && bash scripts/verify-publish-source.sh" }, @@ -87,6 +89,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "fast-check": "4.10.0", "typescript": "^5.4.0", "vitest": "^4.1.10" } diff --git a/scripts/prepare-release-consumer.mjs b/scripts/prepare-release-consumer.mjs new file mode 100644 index 0000000..8dcde6f --- /dev/null +++ b/scripts/prepare-release-consumer.mjs @@ -0,0 +1,321 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const PACKAGE_NAME = 'geoptimize'; +const LOCKFILE_VERSION = 3; +const DEPENDENCY_CONTRACT_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'bundleDependencies', + 'bundledDependencies', +]; +const PACKAGE_METADATA_FIELDS = [ + 'name', + 'license', + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'bin', + 'engines', + 'os', + 'cpu', + 'libc', + 'funding', + 'deprecated', +]; + +function fail(message) { + throw new Error(message); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (!isRecord(value)) return value; + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, stableValue(value[key])]), + ); +} + +function sameValue(left, right) { + return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right)); +} + +function readJson(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`Unable to read ${label} ${path}: ${detail}`); + } +} + +function assertSha256(value) { + if (!/^[0-9a-f]{64}$/.test(value)) { + fail('expected SHA-256 must be a lowercase 64-character hexadecimal digest'); + } +} + +function digest(path, algorithm, encoding) { + return createHash(algorithm).update(readFileSync(path)).digest(encoding); +} + +function readPackedManifest(tarballPath) { + let manifestText; + try { + manifestText = execFileSync( + 'tar', + ['-xOf', tarballPath, 'package/package.json'], + { encoding: 'utf8', maxBuffer: 1024 * 1024 }, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`Unable to read package/package.json from ${tarballPath}: ${detail}`); + } + + try { + return JSON.parse(manifestText); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`Packed package/package.json is not valid JSON: ${detail}`); + } +} + +function dependencyContract(value, label) { + if (!isRecord(value)) fail(`${label} must be a JSON object`); + + return Object.fromEntries( + DEPENDENCY_CONTRACT_FIELDS + .filter((field) => Object.hasOwn(value, field)) + .map((field) => { + const fieldValue = value[field]; + if (field === 'bundleDependencies' || field === 'bundledDependencies') { + if (!Array.isArray(fieldValue)) fail(`${label}.${field} must be a JSON array`); + } else if (!isRecord(fieldValue)) { + fail(`${label}.${field} must be a JSON object`); + } + return [field, fieldValue]; + }), + ); +} + +function validateManifest(manifest, sourceRoot) { + if (!isRecord(manifest)) fail('Packed package manifest must be a JSON object'); + if (!isRecord(sourceRoot)) fail('Canonical lock root package must be a JSON object'); + if (sourceRoot.name !== PACKAGE_NAME) { + fail(`Canonical lock root package name is ${sourceRoot.name ?? 'missing'}; expected ${PACKAGE_NAME}`); + } + if (typeof sourceRoot.version !== 'string' || sourceRoot.version.length === 0) { + fail('Canonical lock root package version is missing'); + } + if (manifest.name !== sourceRoot.name) { + fail(`Packed package name is ${manifest.name ?? 'missing'}; expected ${sourceRoot.name}`); + } + if (manifest.version !== sourceRoot.version) { + fail(`Packed package version is ${manifest.version ?? 'missing'}; expected ${sourceRoot.version}`); + } + + const packedContract = dependencyContract(manifest, 'Packed package manifest'); + const lockContract = dependencyContract(sourceRoot, 'Canonical lock root package'); + if (!sameValue(packedContract, lockContract)) { + fail('Packed package dependency contract does not match the canonical lock root package'); + } +} + +function isIntegrity(value) { + if (typeof value !== 'string') return false; + const match = /^(sha1|sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/.exec(value); + if (!match) return false; + + const expectedLength = { + sha1: 28, + sha256: 44, + sha384: 64, + sha512: 88, + }[match[1]]; + const encoded = match[2]; + if (encoded.length !== expectedLength) return false; + return Buffer.from(encoded, 'base64').toString('base64') === encoded; +} + +function cloneWithoutDevelopmentMetadata(value) { + const clone = JSON.parse(JSON.stringify(value)); + delete clone.dev; + delete clone.devOptional; + delete clone.devPeers; + return clone; +} + +function productionPackages(sourcePackages) { + const packages = {}; + for (const [location, entry] of Object.entries(sourcePackages)) { + if (location === '') continue; + if (!location.startsWith('node_modules/')) { + fail(`Canonical lock contains an unsupported package location: ${location}`); + } + if (!isRecord(entry)) fail(`Canonical lock entry ${location} must be a JSON object`); + if (entry.dev === true) continue; + if (entry.link === true) { + fail(`Canonical lock production entry ${location} is a link`); + } + if (typeof entry.version !== 'string' || entry.version.length === 0) { + fail(`Canonical lock production entry ${location} has no version`); + } + if (typeof entry.resolved !== 'string' || entry.resolved.length === 0) { + fail(`Canonical lock production entry ${location} has no resolved source`); + } + if (!isIntegrity(entry.integrity)) { + fail(`Canonical lock production entry ${location} has no valid integrity`); + } + packages[location] = cloneWithoutDevelopmentMetadata(entry); + } + return packages; +} + +function validateCanonicalLock(lock, lockPath) { + if (!isRecord(lock)) fail(`Canonical lock ${lockPath} must be a JSON object`); + if (lock.lockfileVersion !== LOCKFILE_VERSION) { + fail(`Canonical lock ${lockPath} must use lockfileVersion ${LOCKFILE_VERSION}`); + } + if (!isRecord(lock.packages)) fail(`Canonical lock ${lockPath} has no packages map`); + if (!Object.hasOwn(lock.packages, '')) fail(`Canonical lock ${lockPath} has no root package entry`); + + const root = lock.packages['']; + if (!isRecord(root)) fail(`Canonical lock ${lockPath} root package entry must be a JSON object`); + if (root.name !== PACKAGE_NAME) { + fail(`Canonical lock root package name is ${root.name ?? 'missing'}; expected ${PACKAGE_NAME}`); + } + if (typeof root.version !== 'string' || root.version.length === 0) { + fail(`Canonical lock root package version is missing`); + } + if (!isRecord(root.dependencies)) { + fail('Canonical lock root package has no dependencies contract'); + } + + return { root, packages: productionPackages(lock.packages) }; +} + +function ensureCleanConsumer(consumerPath) { + if (!existsSync(consumerPath)) { + mkdirSync(consumerPath, { recursive: true }); + } + + const stats = lstatSync(consumerPath); + if (!stats.isDirectory()) fail(`Consumer path is not a directory: ${consumerPath}`); + if (readdirSync(consumerPath).length !== 0) { + fail(`Consumer directory must be empty: ${consumerPath}`); + } + return realpathSync(consumerPath); +} + +function realFilePath(path, label) { + let physicalPath; + try { + physicalPath = realpathSync(path); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`Unable to resolve ${label} ${path}: ${detail}`); + } + if (!lstatSync(physicalPath).isFile()) fail(`${label} is not a regular file: ${physicalPath}`); + return physicalPath; +} + +function consumerMetadata(tarballPath, manifest, sha512) { + const entry = { + version: manifest.version, + resolved: `file:${tarballPath}`, + integrity: `sha512-${sha512}`, + }; + + for (const field of PACKAGE_METADATA_FIELDS) { + if (Object.hasOwn(manifest, field)) entry[field] = manifest[field]; + } + return entry; +} + +export function prepareReleaseConsumer(tarballArg, consumerArg, sourceLockArg, expectedSha256) { + if (!tarballArg || !consumerArg || !sourceLockArg || !expectedSha256) { + fail('usage: node scripts/prepare-release-consumer.mjs '); + } + assertSha256(expectedSha256); + + const tarballPath = realFilePath(resolve(tarballArg), 'Tarball path'); + const requestedConsumerPath = resolve(consumerArg); + const sourceLockPath = realFilePath(resolve(sourceLockArg), 'Canonical source lock path'); + + const actualSha256 = digest(tarballPath, 'sha256', 'hex'); + if (actualSha256 !== expectedSha256) { + fail(`Tarball SHA-256 is ${actualSha256}; expected ${expectedSha256}`); + } + + const sourceLock = readJson(sourceLockPath, 'canonical source lock'); + const { root: sourceRoot, packages } = validateCanonicalLock(sourceLock, sourceLockPath); + const manifest = readPackedManifest(tarballPath); + validateManifest(manifest, sourceRoot); + + const consumerPath = ensureCleanConsumer(requestedConsumerPath); + const consumerPackage = { + name: 'geoptimize-release-consumer', + version: '0.0.0', + private: true, + dependencies: { [PACKAGE_NAME]: `file:${tarballPath}` }, + }; + const lockPackages = { + '': { + name: consumerPackage.name, + version: consumerPackage.version, + private: true, + dependencies: consumerPackage.dependencies, + }, + ...packages, + 'node_modules/geoptimize': consumerMetadata( + tarballPath, + manifest, + digest(tarballPath, 'sha512', 'base64'), + ), + }; + const consumerLock = { + name: consumerPackage.name, + version: consumerPackage.version, + lockfileVersion: LOCKFILE_VERSION, + requires: true, + packages: lockPackages, + }; + + writeFileSync(`${consumerPath}/package.json`, `${JSON.stringify(consumerPackage, null, 2)}\n`, 'utf8'); + writeFileSync(`${consumerPath}/package-lock.json`, `${JSON.stringify(consumerLock, null, 2)}\n`, 'utf8'); + return { tarballPath, consumerPath, sourceLockPath, sha256: actualSha256, sha512: digest(tarballPath, 'sha512', 'base64') }; +} + +function main() { + try { + const result = prepareReleaseConsumer(...process.argv.slice(2)); + console.log(`Prepared clean consumer metadata at ${result.consumerPath}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main(); diff --git a/scripts/verify-release-candidate.sh b/scripts/verify-release-candidate.sh index 281bb68..91d3c97 100755 --- a/scripts/verify-release-candidate.sh +++ b/scripts/verify-release-candidate.sh @@ -18,9 +18,14 @@ fi VERIFY_BASE=${TMPDIR:-/tmp} VERIFY_BASE=${VERIFY_BASE%/} +if ! VERIFY_BASE=$(cd -- "$VERIFY_BASE" 2>/dev/null && pwd -P); then + echo "temporary directory is unavailable: ${TMPDIR:-/tmp}" >&2 + exit 2 +fi VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/geoptimize-release-candidate.XXXXXX") PACK_ROOT="$VERIFY_ROOT/pack" CONSUMER_ROOT="$VERIFY_ROOT/consumer" +SOURCE_LOCK="$REPO_ROOT/package-lock.json" PACK_JSON="$VERIFY_ROOT/pack.json" NORMALIZED_PACK_JSON="$VERIFY_ROOT/pack-normalized.json" @@ -82,7 +87,8 @@ jq -e ' (.[0].files | map(.path) | index("fixtures/v0.6/rule-corpus.ts")) != null and (.[0].files | map(.path) | index("examples/github-action-sample/.github/workflows/geoptimize.yml")) != null and (.[0].files | map(.path) | index("scripts/verify-release-candidate.sh")) != null and - (.[0].files | map(.path) | index("scripts/verify-release-v0.8.sh")) != null + (.[0].files | map(.path) | index("scripts/verify-release-v0.8.sh")) != null and + (.[0].files | map(.path) | index("scripts/prepare-release-consumer.mjs")) != null ' "$PACK_JSON" >/dev/null if ! tar -xOf "$PACKAGE_TARBALL" package/README.md > "$VERIFY_ROOT/README.md" || \ @@ -91,9 +97,16 @@ if ! tar -xOf "$PACKAGE_TARBALL" package/README.md > "$VERIFY_ROOT/README.md" || exit 1 fi -npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" install \ +if [ ! -f "$SOURCE_LOCK" ]; then + echo "canonical source package-lock.json is required for the clean consumer" >&2 + exit 1 +fi + +node "$REPO_ROOT/scripts/prepare-release-consumer.mjs" \ + "$PACKAGE_TARBALL" "$CONSUMER_ROOT" "$SOURCE_LOCK" "$PACKAGE_SHA256" +npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" ci \ --ignore-scripts --no-audit --no-fund \ - --prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null + --prefix "$CONSUMER_ROOT" for binary in geoptimize geo geo-cli; do BINARY_VERSION=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version) diff --git a/scripts/verify-release-v0.6.sh b/scripts/verify-release-v0.6.sh index d463061..ca18cdc 100755 --- a/scripts/verify-release-v0.6.sh +++ b/scripts/verify-release-v0.6.sh @@ -34,7 +34,7 @@ if ! [[ "$EXPECTED_PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ]]; then exit 2 fi -for command_name in awk curl jq npm git mktemp; do +for command_name in awk curl jq npm git mktemp tar; do if ! command -v "$command_name" >/dev/null 2>&1; then echo "missing required command: $command_name" >&2 exit 2 @@ -43,11 +43,16 @@ done VERIFY_BASE=${TMPDIR:-/tmp} VERIFY_BASE=${VERIFY_BASE%/} +if ! VERIFY_BASE=$(cd -- "$VERIFY_BASE" 2>/dev/null && pwd -P); then + echo "temporary directory is unavailable: ${TMPDIR:-/tmp}" >&2 + exit 2 +fi VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/geoptimize-release-verify.XXXXXX") REGISTRY_JSON="$VERIFY_ROOT/registry.json" RELEASE_JSON="$VERIFY_ROOT/release.json" PACKAGE_TARBALL="$VERIFY_ROOT/$PACKAGE_NAME-$EXPECTED_VERSION.tgz" CONSUMER_ROOT="$VERIFY_ROOT/consumer" +SOURCE_LOCK="$SCRIPT_DIR/../package-lock.json" FAILURES=0 cleanup() { @@ -113,31 +118,47 @@ if curl -fsS "https://registry.npmjs.org/$PACKAGE_NAME" > "$REGISTRY_JSON"; then fail "npm repository identity does not match $REPOSITORY" fi + tarball_verified=false tarball_url=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].dist.tarball // empty' "$REGISTRY_JSON") if [ -n "$tarball_url" ] && curl -fLsS "$tarball_url" -o "$PACKAGE_TARBALL"; then - package_sha256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');const path=process.argv[1];console.log(crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'))" "$PACKAGE_TARBALL") - if [ "$package_sha256" = "$EXPECTED_PACKAGE_SHA256" ]; then - pass "npm tarball SHA-256 matches the verified candidate" + if package_sha256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');const path=process.argv[1];console.log(crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'))" "$PACKAGE_TARBALL"); then + if [ "$package_sha256" = "$EXPECTED_PACKAGE_SHA256" ]; then + pass "npm tarball SHA-256 matches the verified candidate" + tarball_verified=true + else + fail "npm tarball SHA-256 is ${package_sha256:-missing}; expected $EXPECTED_PACKAGE_SHA256" + fi else - fail "npm tarball SHA-256 is ${package_sha256:-missing}; expected $EXPECTED_PACKAGE_SHA256" + fail "npm tarball SHA-256 could not be computed" fi - else + elif [ -n "$tarball_url" ]; then fail "npm tarball could not be downloaded for SHA-256 verification" + else + fail "npm metadata did not expose a tarball URL for $EXPECTED_VERSION" fi - if npm --cache "$VERIFY_ROOT/npm-cache" install \ - --ignore-scripts --no-audit --no-fund \ - --prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null; then - for binary in geoptimize geo geo-cli; do - binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true) - if [ "$binary_version" = "$EXPECTED_VERSION" ]; then - pass "$binary resolves to $EXPECTED_VERSION from the public package" + if [ "$tarball_verified" = true ]; then + if [ ! -f "$SOURCE_LOCK" ]; then + fail "canonical source package-lock.json is required for the clean consumer" + elif node "$SCRIPT_DIR/prepare-release-consumer.mjs" \ + "$PACKAGE_TARBALL" "$CONSUMER_ROOT" "$SOURCE_LOCK" "$EXPECTED_PACKAGE_SHA256"; then + if npm --cache "$VERIFY_ROOT/npm-cache" ci \ + --ignore-scripts --no-audit --no-fund \ + --prefix "$CONSUMER_ROOT"; then + for binary in geoptimize geo geo-cli; do + binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true) + if [ "$binary_version" = "$EXPECTED_VERSION" ]; then + pass "$binary resolves to $EXPECTED_VERSION from the public package" + else + fail "$binary returned ${binary_version:-no version}; expected $EXPECTED_VERSION" + fi + done else - fail "$binary returned ${binary_version:-no version}; expected $EXPECTED_VERSION" + fail "clean consumer installation failed for the verified package tarball" fi - done - else - fail "clean consumer installation failed for the verified package tarball" + else + fail "clean consumer metadata could not be prepared from the verified package tarball" + fi fi else fail "npm does not contain exact version $EXPECTED_VERSION" diff --git a/scripts/verify-release-v0.8.sh b/scripts/verify-release-v0.8.sh index d463061..ca18cdc 100644 --- a/scripts/verify-release-v0.8.sh +++ b/scripts/verify-release-v0.8.sh @@ -34,7 +34,7 @@ if ! [[ "$EXPECTED_PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ]]; then exit 2 fi -for command_name in awk curl jq npm git mktemp; do +for command_name in awk curl jq npm git mktemp tar; do if ! command -v "$command_name" >/dev/null 2>&1; then echo "missing required command: $command_name" >&2 exit 2 @@ -43,11 +43,16 @@ done VERIFY_BASE=${TMPDIR:-/tmp} VERIFY_BASE=${VERIFY_BASE%/} +if ! VERIFY_BASE=$(cd -- "$VERIFY_BASE" 2>/dev/null && pwd -P); then + echo "temporary directory is unavailable: ${TMPDIR:-/tmp}" >&2 + exit 2 +fi VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/geoptimize-release-verify.XXXXXX") REGISTRY_JSON="$VERIFY_ROOT/registry.json" RELEASE_JSON="$VERIFY_ROOT/release.json" PACKAGE_TARBALL="$VERIFY_ROOT/$PACKAGE_NAME-$EXPECTED_VERSION.tgz" CONSUMER_ROOT="$VERIFY_ROOT/consumer" +SOURCE_LOCK="$SCRIPT_DIR/../package-lock.json" FAILURES=0 cleanup() { @@ -113,31 +118,47 @@ if curl -fsS "https://registry.npmjs.org/$PACKAGE_NAME" > "$REGISTRY_JSON"; then fail "npm repository identity does not match $REPOSITORY" fi + tarball_verified=false tarball_url=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].dist.tarball // empty' "$REGISTRY_JSON") if [ -n "$tarball_url" ] && curl -fLsS "$tarball_url" -o "$PACKAGE_TARBALL"; then - package_sha256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');const path=process.argv[1];console.log(crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'))" "$PACKAGE_TARBALL") - if [ "$package_sha256" = "$EXPECTED_PACKAGE_SHA256" ]; then - pass "npm tarball SHA-256 matches the verified candidate" + if package_sha256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');const path=process.argv[1];console.log(crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'))" "$PACKAGE_TARBALL"); then + if [ "$package_sha256" = "$EXPECTED_PACKAGE_SHA256" ]; then + pass "npm tarball SHA-256 matches the verified candidate" + tarball_verified=true + else + fail "npm tarball SHA-256 is ${package_sha256:-missing}; expected $EXPECTED_PACKAGE_SHA256" + fi else - fail "npm tarball SHA-256 is ${package_sha256:-missing}; expected $EXPECTED_PACKAGE_SHA256" + fail "npm tarball SHA-256 could not be computed" fi - else + elif [ -n "$tarball_url" ]; then fail "npm tarball could not be downloaded for SHA-256 verification" + else + fail "npm metadata did not expose a tarball URL for $EXPECTED_VERSION" fi - if npm --cache "$VERIFY_ROOT/npm-cache" install \ - --ignore-scripts --no-audit --no-fund \ - --prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null; then - for binary in geoptimize geo geo-cli; do - binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true) - if [ "$binary_version" = "$EXPECTED_VERSION" ]; then - pass "$binary resolves to $EXPECTED_VERSION from the public package" + if [ "$tarball_verified" = true ]; then + if [ ! -f "$SOURCE_LOCK" ]; then + fail "canonical source package-lock.json is required for the clean consumer" + elif node "$SCRIPT_DIR/prepare-release-consumer.mjs" \ + "$PACKAGE_TARBALL" "$CONSUMER_ROOT" "$SOURCE_LOCK" "$EXPECTED_PACKAGE_SHA256"; then + if npm --cache "$VERIFY_ROOT/npm-cache" ci \ + --ignore-scripts --no-audit --no-fund \ + --prefix "$CONSUMER_ROOT"; then + for binary in geoptimize geo geo-cli; do + binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true) + if [ "$binary_version" = "$EXPECTED_VERSION" ]; then + pass "$binary resolves to $EXPECTED_VERSION from the public package" + else + fail "$binary returned ${binary_version:-no version}; expected $EXPECTED_VERSION" + fi + done else - fail "$binary returned ${binary_version:-no version}; expected $EXPECTED_VERSION" + fail "clean consumer installation failed for the verified package tarball" fi - done - else - fail "clean consumer installation failed for the verified package tarball" + else + fail "clean consumer metadata could not be prepared from the verified package tarball" + fi fi else fail "npm does not contain exact version $EXPECTED_VERSION" diff --git a/src/core/__tests__/ai-prompt.test.ts b/src/core/__tests__/ai-prompt.test.ts index 1e05ec4..011483a 100644 --- a/src/core/__tests__/ai-prompt.test.ts +++ b/src/core/__tests__/ai-prompt.test.ts @@ -15,6 +15,15 @@ describe('buildScoringPrompt', () => { const prompt = buildScoringPrompt(longHtml, 'test'); expect(prompt.length).toBeLessThan(longHtml.length); }); + + it('preserves unmatched markup tails while stripping matched tags', () => { + const manyOpeners = '<'.repeat(6000); + const content = `before bold after ${manyOpeners} tail`; + const prompt = buildScoringPrompt(content, 'test'); + const excerpt = prompt.split('---BEGIN UNTRUSTED PAGE CONTENT---\n')[1].split('\n---END UNTRUSTED PAGE CONTENT---')[0]; + + expect(excerpt).toBe(`before bold after ${manyOpeners} tail`); + }); }); describe('parseAiResponse', () => { diff --git a/src/core/__tests__/release-consumer.test.ts b/src/core/__tests__/release-consumer.test.ts new file mode 100644 index 0000000..b598a22 --- /dev/null +++ b/src/core/__tests__/release-consumer.test.ts @@ -0,0 +1,199 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(testDirectory, '../../..'); +const helper = join(repositoryRoot, 'scripts/prepare-release-consumer.mjs'); +const sourceLockPath = join(repositoryRoot, 'package-lock.json'); +const packageManifest = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as Record; +const packageVersion = packageManifest.version as string; + +interface CommandResult { + code: number | null; + stdout: string; + stderr: string; +} + +function run(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = {}): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(command, args, { cwd, env: { ...process.env, ...env } }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolveResult({ code, stdout, stderr })); + }); +} + +async function makeTarball(testRoot: string, manifest = packageManifest): Promise { + const packageRoot = join(testRoot, 'package'); + await mkdir(packageRoot); + await writeFile(join(packageRoot, 'package.json'), `${JSON.stringify(manifest)}\n`, 'utf8'); + if (manifest.bin) { + await writeFile( + join(packageRoot, 'cli.js'), + `#!/usr/bin/env node\nprocess.stdout.write(process.argv.includes('--version') ? '${packageVersion}\\n' : 'fixture\\n');\n`, + { encoding: 'utf8', mode: 0o755 }, + ); + } + const tarballPath = join(testRoot, `geoptimize-${packageVersion}.tgz`); + const result = spawnSync('tar', ['-czf', tarballPath, 'package'], { + cwd: testRoot, + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(`Could not create tarball fixture: ${result.stderr}`); + return tarballPath; +} + +async function makeFixtureLock(testRoot: string, manifest: Record): Promise { + const lockPath = join(testRoot, 'package-lock.json'); + const lock = { + name: manifest.name, + version: manifest.version, + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: manifest.name, + version: manifest.version, + license: manifest.license, + dependencies: manifest.dependencies, + bin: manifest.bin, + }, + }, + }; + await writeFile(lockPath, `${JSON.stringify(lock)}\n`, 'utf8'); + return lockPath; +} + +function sha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function sha512(path: string): string { + return createHash('sha512').update(readFileSync(path)).digest('base64'); +} + +function productionEntry(lock: Record): string { + const entry = Object.keys(lock.packages).find((location) => location.startsWith('node_modules/') && lock.packages[location].dev !== true); + if (!entry) throw new Error('Canonical lock has no production package entry'); + return entry; +} + +describe('release consumer preparation', () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = realpathSync(await mkdtemp(join(tmpdir(), 'geoptimize-release-consumer-test-'))); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it('prepares a v3 lock that npm ci installs from a packed tarball', async () => { + const fixtureManifest = { + name: 'geoptimize', + version: packageVersion, + license: 'MIT', + bin: { geoptimize: 'cli.js', geo: 'cli.js', 'geo-cli': 'cli.js' }, + dependencies: {}, + }; + const tarballPath = await makeTarball(testRoot, fixtureManifest); + const fixtureLockPath = await makeFixtureLock(testRoot, fixtureManifest); + const consumerPath = join(testRoot, 'consumer'); + const expectedHash = sha256(tarballPath); + const prepareResult = await run( + process.execPath, + [helper, tarballPath, consumerPath, fixtureLockPath, expectedHash], + testRoot, + ); + expect(prepareResult.code, prepareResult.stderr).toBe(0); + + const consumerPackage = JSON.parse(await readFile(join(consumerPath, 'package.json'), 'utf8')) as Record; + const consumerLock = JSON.parse(await readFile(join(consumerPath, 'package-lock.json'), 'utf8')) as Record; + expect(consumerLock.lockfileVersion).toBe(3); + expect(consumerPackage.dependencies.geoptimize).toBe(`file:${tarballPath}`); + expect(consumerLock.packages[''].dependencies.geoptimize).toBe(`file:${tarballPath}`); + expect(consumerLock.packages['node_modules/geoptimize'].integrity).toBe(`sha512-${sha512(tarballPath)}`); + for (const [location, entry] of Object.entries(consumerLock.packages)) { + if (location === '') continue; + expect(entry).not.toHaveProperty('dev'); + expect(entry).toHaveProperty('integrity'); + expect(entry).toHaveProperty('resolved'); + } + const installResult = await run( + 'npm', + ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--cache', join(testRoot, 'npm-cache'), '--prefix', consumerPath], + testRoot, + ); + expect(installResult.code, installResult.stderr).toBe(0); + for (const binary of ['geoptimize', 'geo', 'geo-cli']) { + const versionResult = spawnSync(join(consumerPath, 'node_modules/.bin', binary), ['--version'], { encoding: 'utf8' }); + expect(versionResult.status, versionResult.stderr).toBe(0); + expect(versionResult.stdout.trim()).toBe(packageVersion); + } + }); + + it('rejects a mismatched SHA-256 before creating consumer metadata', async () => { + const tarballPath = join(testRoot, 'candidate.tgz'); + await writeFile(tarballPath, 'not a tarball', 'utf8'); + const consumerPath = join(testRoot, 'consumer'); + const result = await run( + process.execPath, + [helper, tarballPath, consumerPath, sourceLockPath, '0'.repeat(64)], + repositoryRoot, + ); + + expect(result.code).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain('SHA-256'); + expect(existsSync(consumerPath)).toBe(false); + }); + + it('rejects packed dependency drift against the canonical lock root', async () => { + const driftedManifest = JSON.parse(JSON.stringify(packageManifest)) as Record; + driftedManifest.dependencies.chalk = '^5.2.0'; + const tarballPath = await makeTarball(testRoot, driftedManifest); + const consumerPath = join(testRoot, 'consumer'); + const result = await run( + process.execPath, + [helper, tarballPath, consumerPath, sourceLockPath, sha256(tarballPath)], + repositoryRoot, + ); + + expect(result.code).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain('dependency contract'); + expect(existsSync(consumerPath)).toBe(false); + }); + + it('rejects a canonical production lock entry without integrity', async () => { + const lock = JSON.parse(await readFile(sourceLockPath, 'utf8')) as Record; + delete lock.packages[productionEntry(lock)].integrity; + const invalidLockPath = join(testRoot, 'package-lock.json'); + await writeFile(invalidLockPath, `${JSON.stringify(lock)}\n`, 'utf8'); + const tarballPath = await makeTarball(testRoot); + const consumerPath = join(testRoot, 'consumer'); + const result = await run( + process.execPath, + [helper, tarballPath, consumerPath, invalidLockPath, sha256(tarballPath)], + repositoryRoot, + ); + + expect(result.code).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain('integrity'); + expect(existsSync(consumerPath)).toBe(false); + }); +}); diff --git a/src/core/__tests__/release-verifier.test.ts b/src/core/__tests__/release-verifier.test.ts index 5c019cd..98405c4 100644 --- a/src/core/__tests__/release-verifier.test.ts +++ b/src/core/__tests__/release-verifier.test.ts @@ -1,6 +1,7 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { existsSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,10 +10,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const testDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = join(testDirectory, '../../..'); const verifier = join(repositoryRoot, 'scripts/verify-release-v0.8.sh'); -const expectedVersion = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')).version as string; +const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as Record; +const expectedVersion = packageJson.version as string; const expectedCommit = '0123456789abcdef0123456789abcdef01234567'; -const tarballContent = `verified geoptimize v${expectedVersion} candidate`; -const expectedTarballHash = createHash('sha256').update(tarballContent).digest('hex'); interface CommandResult { code: number | null; @@ -22,6 +22,7 @@ interface CommandResult { function runVerifier( mockBin: string, + tarballPath: string, packageHash: string, overrides: Record = {}, ): Promise { @@ -34,7 +35,7 @@ function runVerifier( MOCK_NPM_GIT_HEAD: expectedCommit, MOCK_REPOSITORY_URL: 'git+https://github.com/cucuwang/geoptimize.git', MOCK_TAG_COMMIT: expectedCommit, - MOCK_TARBALL_CONTENT: tarballContent, + MOCK_TARBALL_PATH: tarballPath, MOCK_RELEASE_DRAFT: 'false', MOCK_RELEASE_PRERELEASE: 'false', MOCK_MISSING_BINARY: '', @@ -60,11 +61,20 @@ async function writeExecutable(path: string, contents: string): Promise { describe('public release verifier', () => { let testRoot: string; let mockBin: string; + let tarballPath: string; + let expectedTarballHash: string; beforeEach(async () => { testRoot = await mkdtemp(join(tmpdir(), 'geoptimize-release-verifier-test-')); mockBin = join(testRoot, 'bin'); await mkdir(mockBin); + tarballPath = join(testRoot, `geoptimize-${expectedVersion}.tgz`); + const packageRoot = join(testRoot, 'package'); + await mkdir(packageRoot); + await writeFile(join(packageRoot, 'package.json'), JSON.stringify(packageJson), 'utf8'); + const tarResult = spawnSync('tar', ['-czf', tarballPath, 'package'], { cwd: testRoot, encoding: 'utf8' }); + if (tarResult.status !== 0) throw new Error(`Could not create tarball fixture: ${tarResult.stderr}`); + expectedTarballHash = createHash('sha256').update(await readFile(tarballPath)).digest('hex'); await writeExecutable(join(mockBin, 'curl'), `#!/usr/bin/env bash set -euo pipefail @@ -84,7 +94,8 @@ case "$url" in printf '{"dist-tags":{"latest":"%s"},"versions":{"${expectedVersion}":{"gitHead":"%s","repository":{"url":"%s"},"homepage":"https://github.com/cucuwang/geoptimize","bugs":{"url":"https://github.com/cucuwang/geoptimize/issues"},"dist":{"tarball":"https://registry.npmjs.org/geoptimize/-/geoptimize-${expectedVersion}.tgz"}}}}' "$MOCK_LATEST" "$MOCK_NPM_GIT_HEAD" "$MOCK_REPOSITORY_URL" ;; https://registry.npmjs.org/geoptimize/-/geoptimize-${expectedVersion}.tgz) - printf '%s' "$MOCK_TARBALL_CONTENT" > "$output_file" + if [ "\${MOCK_TARBALL_DOWNLOAD_FAIL:-0}" = "1" ]; then exit 22; fi + cp "$MOCK_TARBALL_PATH" "$output_file" ;; https://api.github.com/repos/cucuwang/geoptimize/releases/tags/v${expectedVersion}) printf '{"tag_name":"v${expectedVersion}","draft":%s,"prerelease":%s}' "$MOCK_RELEASE_DRAFT" "$MOCK_RELEASE_PRERELEASE" > "$output_file" @@ -112,6 +123,10 @@ while [ "$#" -gt 0 ]; do *) shift ;; esac done +if [ -n "\${MOCK_REJECT_PREFIX:-}" ] && [[ "$prefix" == "\${MOCK_REJECT_PREFIX}"/* ]]; then + printf 'mock npm rejected symlink prefix: %s\n' "$prefix" >&2 + exit 64 +fi mkdir -p "$prefix/node_modules/.bin" for binary in geoptimize geo geo-cli; do if [ "$binary" = "$MOCK_MISSING_BINARY" ]; then @@ -128,7 +143,7 @@ done }); it('passes only when npm metadata, tarball, aliases, tag, and Release match', async () => { - const result = await runVerifier(mockBin, expectedTarballHash); + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash); const npmArgs = await readFile(join(testRoot, 'npm-args.txt'), 'utf8'); expect(result.code).toBe(0); @@ -136,21 +151,55 @@ done expect(result.stdout).toContain('PASS: npm gitHead matches'); expect(result.stdout).toContain('PASS: npm tarball SHA-256 matches the verified candidate'); expect(result.stdout).toContain('All public release checks passed.'); - expect(npmArgs).toContain(`geoptimize-${expectedVersion}.tgz`); - expect(npmArgs).not.toContain(`geoptimize@${expectedVersion}`); + expect(npmArgs).toContain(' ci '); + expect(npmArgs).toContain('--ignore-scripts'); + expect(npmArgs).toContain('--no-audit'); + expect(npmArgs).toContain('--no-fund'); + expect(npmArgs).not.toContain(' install '); + expect(npmArgs).not.toContain(`geoptimize-${expectedVersion}.tgz`); + }); + + it('uses a physical verification path when TMPDIR is a symlink', async () => { + const physicalTmp = join(testRoot, 'physical-tmp'); + const tmpAlias = join(testRoot, 'tmp-alias'); + await mkdir(physicalTmp); + await symlink(physicalTmp, tmpAlias); + + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { + TMPDIR: tmpAlias, + MOCK_REJECT_PREFIX: tmpAlias, + }); + const npmArgs = await readFile(join(testRoot, 'npm-args.txt'), 'utf8'); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('All public release checks passed.'); + expect(npmArgs).toContain(realpathSync(physicalTmp)); + expect(npmArgs).not.toContain(tmpAlias); }); it('fails closed when npm serves a different tarball', async () => { const differentHash = createHash('sha256').update('different candidate').digest('hex'); - const result = await runVerifier(mockBin, differentHash); + const result = await runVerifier(mockBin, tarballPath, differentHash); expect(result.code).toBe(1); expect(result.stderr).toContain('FAIL: npm tarball SHA-256 is'); expect(result.stdout).not.toContain('All public release checks passed.'); + expect(existsSync(join(testRoot, 'npm-args.txt'))).toBe(false); + }); + + it('does not install when the npm tarball download fails', async () => { + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { + MOCK_TARBALL_DOWNLOAD_FAIL: '1', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('FAIL: npm tarball could not be downloaded'); + expect(existsSync(join(testRoot, 'npm-args.txt'))).toBe(false); }); it('accepts a missing optional gitHead when the tarball identity matches', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_NPM_GIT_HEAD: '' }); + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_NPM_GIT_HEAD: '' }); expect(result.code).toBe(0); expect(result.stdout).toContain('INFO: npm does not expose gitHead'); @@ -159,21 +208,21 @@ done it('fails closed when npm exposes a different gitHead', async () => { const differentCommit = 'fedcba9876543210fedcba9876543210fedcba98'; - const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_NPM_GIT_HEAD: differentCommit }); + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_NPM_GIT_HEAD: differentCommit }); expect(result.code).toBe(1); expect(result.stderr).toContain(`FAIL: npm gitHead is ${differentCommit}`); }); it('fails closed when an alias is missing from the verified tarball', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_MISSING_BINARY: 'geo-cli' }); + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_MISSING_BINARY: 'geo-cli' }); expect(result.code).toBe(1); expect(result.stderr).toContain('FAIL: geo-cli returned no version'); }); it('fails closed when the Git tag points to a different commit', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, { + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_TAG_COMMIT: 'fedcba9876543210fedcba9876543210fedcba98', }); @@ -182,14 +231,14 @@ done }); it('fails closed when the GitHub Release is a draft', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_RELEASE_DRAFT: 'true' }); + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_RELEASE_DRAFT: 'true' }); expect(result.code).toBe(1); expect(result.stderr).toContain('FAIL: GitHub Release is not a published non-prerelease'); }); it('fails closed when public repository identity changes', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, { + const result = await runVerifier(mockBin, tarballPath, expectedTarballHash, { MOCK_REPOSITORY_URL: 'git+https://github.com/example/other.git', }); diff --git a/src/core/__tests__/security-fuzz.test.ts b/src/core/__tests__/security-fuzz.test.ts new file mode 100644 index 0000000..8ed9c29 --- /dev/null +++ b/src/core/__tests__/security-fuzz.test.ts @@ -0,0 +1,215 @@ +import * as cheerio from 'cheerio'; +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildScoringPrompt } from '../ai-prompt.js'; +import { parseRobotsTxt } from '../site-audit.js'; +import { parseHtml, scanDocument, SCORING_VERSION } from '../scanner.js'; +import { renderVisualReport } from '../visual-report.js'; +import type { ScanReport } from '../types.js'; + +const DEFAULT_FUZZ_RUNS = 200; +const MAX_FUZZ_RUNS = 5_000; +const DEFAULT_FUZZ_SEED = 20260911; +const PROMPT_CONTENT_LIMIT = 8_000; +const PROMPT_CONTENT_START = '---BEGIN UNTRUSTED PAGE CONTENT---\n'; +const PROMPT_CONTENT_END = '\n---END UNTRUSTED PAGE CONTENT---'; + +function envInteger(name: string, fallback: number, minimum: number, maximum: number): number { + const value = Number.parseInt(process.env[name] ?? '', 10); + if (!Number.isInteger(value)) return fallback; + return Math.max(minimum, Math.min(maximum, value)); +} + +const fuzzRuns = envInteger('FUZZ_NUM_RUNS', DEFAULT_FUZZ_RUNS, 1, MAX_FUZZ_RUNS); +const fuzzSeed = envInteger('FUZZ_SEED', DEFAULT_FUZZ_SEED, -2_147_483_648, 2_147_483_647); +const fuzzPath = process.env.FUZZ_PATH?.trim(); + +function fuzzParameters(): fc.Parameters { + return { + numRuns: fuzzRuns, + seed: fuzzSeed, + ...(fuzzPath ? { path: fuzzPath } : {}), + }; +} + +const commentCharacter = fc.constantFrom('a', 'Z', '0', ' ', '#', '/', '!', '?', ':', '.', '_', '\u2028', '\u2029'); +const commentText = fc.array(commentCharacter, { minLength: 0, maxLength: 80 }).map((characters) => characters.join('')); + +const robotsPattern = fc.record({ + segments: fc.array(fc.constantFrom('private', 'preview', 'public', 'draft', 'api', 'docs'), { + minLength: 1, + maxLength: 3, + }), + wildcard: fc.boolean(), + anchored: fc.boolean(), +}).map(({ segments, wildcard, anchored }) => `/${segments.join('/')}${wildcard ? '*' : ''}${anchored ? '$' : ''}`); + +const robotsRule = fc.record({ + directive: fc.constantFrom('Allow', 'Disallow'), + pattern: robotsPattern, +}); + +const robotsCase = fc.record({ + rules: fc.array(robotsRule, { minLength: 1, maxLength: 16 }), + sitemaps: fc.array(fc.constantFrom('/sitemap.xml', '/sitemap-index.xml', '/pages.xml'), { + maxLength: 5, + }), + comments: fc.array(commentText, { minLength: 1, maxLength: 16 }), + lineSeparator: fc.constantFrom('\n', '\r\n'), +}); + +function renderRobotsDocument(input: { + rules: Array<{ directive: 'Allow' | 'Disallow'; pattern: string }>; + sitemaps: string[]; + comments: string[]; + lineSeparator: string; +}, withComments: boolean): string { + const lines = ['User-agent: *']; + input.rules.forEach((rule, index) => { + const suffix = withComments ? ` # ${input.comments[index % input.comments.length]}` : ''; + lines.push(`${rule.directive}: ${rule.pattern}${suffix}`); + }); + input.sitemaps.forEach((sitemap, index) => { + const suffix = withComments ? ` # ${input.comments[(index + input.rules.length) % input.comments.length]}` : ''; + lines.push(`Sitemap: https://example.test${sitemap}${suffix}`); + }); + if (withComments) { + lines.push(...input.comments.map((comment) => `# standalone ${comment}`)); + } + return lines.join(input.lineSeparator); +} + +const htmlTextCharacter = fc.constantFrom( + 'a', 'Z', '0', ' ', '\n', '\t', '&', '/', '\\', "'", '"', '?', '!', '=', ':', ';', '[', ']', '{', '}', +); +const htmlText = fc.array(htmlTextCharacter, { minLength: 0, maxLength: 240 }).map((characters) => characters.join('')); +const htmlBlock = fc.record({ + tag: fc.constantFrom('article', 'section', 'h1', 'h2', 'p', 'strong', 'a'), + text: htmlText, + attribute: fc.array(fc.constantFrom('a', 'b', '0', '1', '-', '_'), { minLength: 0, maxLength: 20 }) + .map((characters) => characters.join('')), +}); + +const htmlCase = fc.record({ + blocks: fc.array(htmlBlock, { minLength: 1, maxLength: 12 }), + oversizedSuffix: fc.constantFrom('', 'x'.repeat(PROMPT_CONTENT_LIMIT + 1)), +}); + +function normalizeExpectedContent(input: { + blocks: Array<{ tag: string; text: string; attribute: string }>; + oversizedSuffix: string; +}): string { + return [...input.blocks.map((block) => block.text), input.oversizedSuffix] + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function renderHtmlDocument(input: { + blocks: Array<{ tag: string; text: string; attribute: string }>; + oversizedSuffix: string; +}): string { + const blocks = input.blocks.map((block) => + `<${block.tag} data-fuzz="${block.attribute}">${block.text}`, + ).join('\n'); + return `${blocks}${input.oversizedSuffix}`; +} + +const promptUrl = fc.record({ + path: fc.array(fc.constantFrom('a', 'b', '0', '1', '-', '_', '.', '~'), { minLength: 0, maxLength: 32 }) + .map((characters) => characters.join('')), +}).map(({ path }) => `https://example.test/${path}`); + +const hostileFragment = fc.constantFrom( + '', + '', + '', + '', + '', + 'click', +); +const hostileText = fc.record({ + prefix: fc.array(fc.constantFrom('x', 'title', 'issue', ' ', '&', '"', "'"), { minLength: 0, maxLength: 24 }) + .map((characters) => characters.join('')), + fragment: hostileFragment, + suffix: fc.array(fc.constantFrom('y', 'detail', ' ', '&', '"', "'"), { minLength: 0, maxLength: 24 }) + .map((characters) => characters.join('')), +}).map(({ prefix, fragment, suffix }) => `${prefix}${fragment}${suffix}`); + +function fuzzReport(): ScanReport { + const page = scanDocument(parseHtml( + 'Safe fixture

Safe fixture

A bounded property test fixture.

', + 'https://example.test/', + ), { details: true }); + return { + pages: [page], + overall: page.scores, + timestamp: '2026-09-11T00:00:00Z', + scoringVersion: SCORING_VERSION, + summary: 'Property-based security fixture', + }; +} + +describe('bounded security properties', () => { + it('keeps robots comments inert and never retains a hash comment tail in parsed rules', () => { + fc.assert(fc.property(robotsCase, (input) => { + const plain = parseRobotsTxt(renderRobotsDocument(input, false)); + const commented = parseRobotsTxt(renderRobotsDocument(input, true)); + + expect(commented).toEqual(plain); + expect(commented.rules.every((rule) => !rule.pattern.includes('#'))).toBe(true); + expect(commented.sitemapUrls.every((url) => !url.includes('#'))).toBe(true); + }), fuzzParameters()); + }); + + it('keeps arbitrary structured HTML prompt content normalized and bounded', () => { + fc.assert(fc.property(htmlCase, promptUrl, (input, url) => { + const html = renderHtmlDocument(input); + const prompt = buildScoringPrompt(html, url); + const start = prompt.indexOf(PROMPT_CONTENT_START); + const end = prompt.indexOf(PROMPT_CONTENT_END, start + PROMPT_CONTENT_START.length); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const excerpt = prompt.slice(start + PROMPT_CONTENT_START.length, end); + const expected = normalizeExpectedContent(input); + + expect(excerpt.length).toBeLessThanOrEqual(PROMPT_CONTENT_LIMIT); + expect(excerpt).toBe(expected.slice(0, PROMPT_CONTENT_LIMIT)); + expect(prompt).toContain(url); + }), fuzzParameters()); + }); + + it('escapes hostile report fields without adding executable DOM nodes', () => { + fc.assert(fc.property(hostileText, (attack) => { + const baseline = cheerio.load(renderVisualReport(fuzzReport())); + const report = fuzzReport(); + const page = report.pages[0]; + page.title = attack; + page.url = attack; + page.issues.push({ + dimension: 'structure', + severity: 'warning', + message: attack, + selector: attack, + }); + page.suggestions.push({ + dimension: 'structure', + action: attack, + detail: attack, + impact: 'high', + }); + + const $ = cheerio.load(renderVisualReport(report)); + const executableElements = 'script, img, svg, iframe, object, embed, applet, frame, frameset, math'; + expect($(executableElements)).toHaveLength(baseline(executableElements).length); + expect($('script[src]')).toHaveLength(baseline('script[src]').length); + expect($('script').first().text()).not.toContain(attack); + expect($('#page-rows small').first().text()).toBe(attack); + expect($('summary strong').filter((_, element) => $(element).text() === attack)).toHaveLength(1); + expect($('body *').filter((_, element) => + Object.keys(element.attribs ?? {}).some((name) => name.toLowerCase().startsWith('on')), + )).toHaveLength(0); + }), fuzzParameters()); + }); +}); diff --git a/src/core/__tests__/site-audit.test.ts b/src/core/__tests__/site-audit.test.ts index 23ae363..67cd164 100644 --- a/src/core/__tests__/site-audit.test.ts +++ b/src/core/__tests__/site-audit.test.ts @@ -47,6 +47,26 @@ describe('robots policy', () => { expect(robotsAllows('https://example.com/private', policy)).toBe(false); }); + + it('strips long comments including Unicode separators without creating directives', () => { + const commentTail = '#'.repeat(20_000) + '\u2028Allow: /comment-allow\u2029Disallow: /comment-disallow'; + const policy = parseRobotsTxt( + `User-agent: *${commentTail}\n` + + `Disallow: /private${commentTail}\n` + + `Allow: /private/public$${commentTail}\n` + + `Sitemap: https://example.com/sitemap.xml${commentTail}`, + ); + + expect(policy.sitemapUrls).toEqual(['https://example.com/sitemap.xml']); + expect(policy.rules).toEqual([ + { directive: 'disallow', pattern: '/private' }, + { directive: 'allow', pattern: '/private/public$' }, + ]); + expect(robotsAllows('https://example.com/private', policy)).toBe(false); + expect(robotsAllows('https://example.com/private/public', policy)).toBe(true); + expect(robotsAllows('https://example.com/private/public/child', policy)).toBe(false); + expect(robotsAllows('https://example.com/comment-disallow', policy)).toBe(true); + }); }); describe('sitemap parsing', () => { diff --git a/src/core/__tests__/visual-report.test.ts b/src/core/__tests__/visual-report.test.ts index bbbae21..6f1f927 100644 --- a/src/core/__tests__/visual-report.test.ts +++ b/src/core/__tests__/visual-report.test.ts @@ -1,3 +1,4 @@ +import * as cheerio from 'cheerio'; import { describe, expect, it } from 'vitest'; import { parseScanReport, renderVisualReport } from '../visual-report.js'; import { parseHtml, scanDocument, scan, SCORING_VERSION } from '../scanner.js'; @@ -8,6 +9,10 @@ function fixture(html = '

Example

A definition is an explanation of a return { pages: [page], overall: page.scores, timestamp: '2026-09-09T00:00:00Z', summary: 'Fixture' }; } +function parseReportHtml(html: string): cheerio.CheerioAPI { + return cheerio.load(html); +} + describe('visual report', () => { it('preserves every readiness dimension and the exact original total', () => { const report = fixture(); @@ -16,7 +21,7 @@ describe('visual report', () => { for (const label of ['Structure', 'Citability', 'Schema', 'AI Metadata', 'Content Density']) { expect(html).toContain(`aria-label="${label}"`); } - expect(html.match(/ { it('escapes source titles, issues, URLs and remediation without executing source markup', () => { const report = fixture(); - const attack = ''; + const attack = ''; report.pages[0].title = attack; report.pages[0].url = attack; report.pages[0].issues.push({ message: attack, selector: attack, severity: 'warning', dimension: 'structure' }); report.pages[0].suggestions.push({ action: attack, detail: attack, dimension: 'structure', impact: 'high' }); const html = renderVisualReport(report); + const $ = parseReportHtml(html); expect(html).not.toContain(attack); - expect(html).toContain('</script><img'); - expect(html.match(/