From 9cb4d7a77f8866abc3f65fa5296960fdb41b30e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:13:28 +0000 Subject: [PATCH 01/12] fix(deps): align @vitest/coverage-v8 with vitest 4.x The dev dependency @vitest/coverage-v8 was pinned to ^1.6.0 while vitest was on ^4.1.7, producing an ERESOLVE peer-dependency conflict that broke 'npm ci' / 'npm install' without --legacy-peer-deps. Bump coverage-v8 to ^4.1.7 to match the installed vitest major. --- package-lock.json | 335 +++++++++------------------------------------- package.json | 2 +- 2 files changed, 66 insertions(+), 271 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9c2d379..fa596dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^4.1.7", "typescript": "^5.4.0", "vitest": "^4.1.7" }, @@ -21,24 +21,10 @@ "node": ">=18.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -46,9 +32,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -56,13 +42,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -72,25 +58,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@emnapi/core": { "version": "1.10.0", @@ -126,27 +115,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -522,31 +490,34 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", - "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.4", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.4", - "istanbul-reports": "^3.1.6", - "magic-string": "^0.30.5", - "magicast": "^0.3.3", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "test-exclude": "^6.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.6.1" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { @@ -672,22 +643,16 @@ "node": ">=12" } }, - "node_modules/balanced-match": { + "node_modules/ast-v8-to-istanbul": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, "node_modules/chai": { @@ -700,13 +665,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -714,24 +672,6 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -787,13 +727,6 @@ } } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -809,28 +742,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -848,25 +759,6 @@ "dev": true, "license": "MIT" }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -892,21 +784,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -922,9 +799,9 @@ } }, "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -1200,15 +1077,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -1227,26 +1104,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1277,26 +1134,6 @@ ], "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1388,9 +1225,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -1425,25 +1262,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1457,21 +1281,6 @@ "node": ">=8" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1480,9 +1289,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", "dev": true, "license": "MIT", "engines": { @@ -1713,13 +1522,6 @@ } } }, - "node_modules/vitest/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1736,13 +1538,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" } } } diff --git a/package.json b/package.json index c884ac5..bd1362e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^4.1.7", "typescript": "^5.4.0", "vitest": "^4.1.7" }, From fc98fd958b62e1a22da1c1d9d5721c5a1e284ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:14:30 +0000 Subject: [PATCH 02/12] fix(test): align Permissions-Policy tests with strict scoring + de-flake Closes #15. The strict Permissions-Policy scoring (requires camera/microphone/geolocation to be restricted for full credit, from #4) left four assertions failing against pre-strict fixtures: - checkPermissionsPolicy feature-policy fallback / precedence / case-insensitive tests used partial policies that now score 5; updated to full strict policies (still exercising the same code paths) and added an explicit test that a partial 'camera=()' policy scores 5/warning. - 'A+ at 90%' grade boundary used a partial policy; updated to the full policy. Also de-flaked 'analyze returns same result as analyzeHeaders': it compared the independently-computed analyzedAt timestamps via toEqual, which could differ by a millisecond. Now compares all other fields and asserts both timestamps are valid ISO strings. --- test/analyzer.test.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index b777526..ef92fa2 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -58,7 +58,13 @@ describe('analyze convenience function', () => { it('analyze with object returns same result as analyzeHeaders', async () => { const direct = analyzeHeaders(STRONG_HEADERS); const viaAnalyze = await analyze(STRONG_HEADERS); - expect(viaAnalyze).toEqual(direct); + // analyzedAt is wall-clock and is computed independently in each call, so + // compare everything else and assert both timestamps are valid ISO strings. + const { analyzedAt: directAt, ...directRest } = direct; + const { analyzedAt: viaAt, ...viaRest } = viaAnalyze; + expect(viaRest).toEqual(directRest); + expect(directAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(viaAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('analyze with empty object returns grade F', async () => { @@ -273,22 +279,28 @@ describe('checkPermissionsPolicy', () => { }); it('falls back to feature-policy header', () => { - const r = checkPermissionsPolicy({ 'feature-policy': 'camera *' }); + const r = checkPermissionsPolicy({ 'feature-policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); expect(r.status).toBe('good'); }); it('permissions-policy takes precedence over feature-policy', () => { const r = checkPermissionsPolicy({ - 'permissions-policy': 'camera=()', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', 'feature-policy': 'camera *', }); expect(r.score).toBe(10); - expect(r.raw).toBe('camera=()'); + expect(r.raw).toBe('camera=(), microphone=(), geolocation=()'); + }); + + it('partial policy (camera only) returns warning, not full score', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=()' }); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); }); it('case-insensitive header name matching', () => { - const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=()' }); + const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); }); }); @@ -344,7 +356,7 @@ describe('grade boundaries', () => { 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', - 'permissions-policy': 'camera=()', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', 'cross-origin-embedder-policy': 'require-corp', 'cross-origin-opener-policy': 'same-origin', 'cross-origin-resource-policy': 'same-origin', From f83f734d389eef65e73e24be5d35ac54851cfea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:15:47 +0000 Subject: [PATCH 03/12] fix(rules): inspect frame-ancestors value for clickjacking protection Closes #23. checkXFrameOptions previously awarded a full 15/15 'good' for the mere presence of a frame-ancestors directive in the CSP, so 'frame-ancestors *' or 'frame-ancestors https:' (which permit embedding by any origin and offer zero clickjacking protection) scored identically to 'frame-ancestors none'. Now the directive's source list is parsed: a wildcard (*) or bare-scheme source is treated as permissive and yields status 'warning' (8/15) with a finding, while 'none'/'self'/specific origins remain 'good' (15/15). Adds a reusable extractCspDirective helper used here and by later CSP checks. --- src/rules.ts | 47 +++++++++++++++++++++++++++++++++++++++---- test/analyzer.test.ts | 19 +++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 98b7451..d3a93ac 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -10,6 +10,30 @@ function getHeader(headers: RawHeaders, name: string): string | undefined { return undefined; } +/** + * Returns the source tokens of a CSP directive, or undefined if the directive + * is absent. e.g. extractCspDirective("default-src 'self'; frame-ancestors 'none'", 'frame-ancestors') + * => ["'none'"]. + */ +function extractCspDirective(csp: string, directive: string): string[] | undefined { + const lower = directive.toLowerCase(); + for (const part of csp.split(';')) { + const tokens = part.trim().split(/\s+/); + if (tokens.length && tokens[0].toLowerCase() === lower) { + return tokens.slice(1); + } + } + return undefined; +} + +/** + * A source token offers no host restriction if it is a bare wildcard (`*`) or a + * scheme-only source (`https:`, `http:`, `data:`, etc.) that matches any host. + */ +function isPermissiveSource(token: string): boolean { + return token === '*' || /^[a-z][a-z0-9+.-]*:$/i.test(token); +} + export function checkHSTS(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'strict-transport-security'); if (!raw) return { @@ -73,16 +97,31 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { export function checkXFrameOptions(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'x-frame-options'); const csp = getHeader(headers, 'content-security-policy'); - const cspFrameAncestors = csp && /frame-ancestors/i.test(csp); - - if (!raw && !cspFrameAncestors) return { + const frameAncestors = csp ? extractCspDirective(csp, 'frame-ancestors') : undefined; + const hasFrameAncestors = frameAncestors !== undefined; + // A frame-ancestors directive only protects if it actually restricts origins. + // `frame-ancestors *` / `frame-ancestors https:` allow embedding by any origin. + const frameAncestorsProtective = + hasFrameAncestors && frameAncestors!.length > 0 && !frameAncestors!.some(isPermissiveSource); + + if (!raw && !hasFrameAncestors) return { header: 'X-Frame-Options', score: 0, maxScore: 15, status: 'missing', findings: ['Site may be embeddable in iframes — clickjacking risk'], recommendations: ['Add X-Frame-Options: DENY or SAMEORIGIN, or use CSP frame-ancestors'], }; - if (cspFrameAncestors) { + if (frameAncestorsProtective) { return { header: 'X-Frame-Options', score: 15, maxScore: 15, status: 'good', raw: raw ?? '(set via CSP frame-ancestors)', findings: [], recommendations: [] }; } + // frame-ancestors present but permissive (e.g. `*`). Per CSP spec it takes + // precedence over X-Frame-Options, so it cannot be relied on for protection. + if (hasFrameAncestors && !frameAncestorsProtective) { + return { + header: 'X-Frame-Options', score: 8, maxScore: 15, status: 'warning', + raw: raw ?? `(CSP frame-ancestors ${frameAncestors!.join(' ') || ''})`, + findings: ['CSP frame-ancestors allows embedding by any origin — no clickjacking protection'], + recommendations: ["Restrict frame-ancestors to 'none', 'self', or specific trusted origins"], + }; + } const val = (raw ?? '').toUpperCase().trim(); const score = (val === 'DENY' || val === 'SAMEORIGIN') ? 15 : 8; return { header: 'X-Frame-Options', score, maxScore: 15, status: score === 15 ? 'good' : 'warning', raw, diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index ef92fa2..ccb934f 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -194,6 +194,25 @@ describe('checkXFrameOptions', () => { expect(r.status).toBe('good'); }); + it("CSP frame-ancestors 'self' with specific origins is protective", () => { + const r = checkXFrameOptions({ 'content-security-policy': "frame-ancestors 'self' https://trusted.example" }); + expect(r.score).toBe(15); + expect(r.status).toBe('good'); + }); + + it('CSP frame-ancestors * is not protective', () => { + const r = checkXFrameOptions({ 'content-security-policy': 'frame-ancestors *' }); + expect(r.score).toBe(8); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => /any origin/i.test(f))).toBe(true); + }); + + it('CSP frame-ancestors with bare scheme (https:) is not protective', () => { + const r = checkXFrameOptions({ 'content-security-policy': 'frame-ancestors https:' }); + expect(r.score).toBe(8); + expect(r.status).toBe('warning'); + }); + it('case-insensitive header name matching', () => { const r = checkXFrameOptions({ 'X-Frame-Options': 'DENY' }); expect(r.score).toBe(15); From 75dfa3934e5dbddfc88b05d9193dba39fbfbcbee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:16:07 +0000 Subject: [PATCH 04/12] fix(rules): suppress Permissions-Policy recommendation when status is good MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #22. checkPermissionsPolicy populated the recommendations array unconditionally, so a correctly-configured 'camera=(), microphone=(), geolocation=()' policy returned status 'good' with empty findings but a spurious 'Set Permissions- Policy to...' recommendation — telling developers to set the policy they already had. Every other rule returns recommendations: [] on its good path; this brings checkPermissionsPolicy in line. --- src/rules.ts | 2 +- test/analyzer.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/rules.ts b/src/rules.ts index d3a93ac..b146363 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -177,7 +177,7 @@ export function checkPermissionsPolicy(headers: RawHeaders): HeaderFinding { status: isGood ? "good" : "warning", raw, findings: isGood ? [] : ["Permissions-Policy does not restrict at least camera, microphone, and geolocation"], - recommendations: ["Set Permissions-Policy to camera=(), microphone=(), geolocation=(), and any other features needed by your app"] + recommendations: isGood ? [] : ["Set Permissions-Policy to camera=(), microphone=(), geolocation=(), and any other features needed by your app"] }; } diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index ccb934f..34db864 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -318,6 +318,18 @@ describe('checkPermissionsPolicy', () => { expect(r.status).toBe('warning'); }); + it('good policy emits no recommendations', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=(), microphone=(), geolocation=()' }); + expect(r.status).toBe('good'); + expect(r.findings).toEqual([]); + expect(r.recommendations).toEqual([]); + }); + + it('warning policy still emits a recommendation', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=()' }); + expect(r.recommendations.length).toBeGreaterThan(0); + }); + it('case-insensitive header name matching', () => { const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); From 8672a7467618abd904569de0f31eefca690e50fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:16:31 +0000 Subject: [PATCH 05/12] fix(rules): don't flag 'unsafe-inline' under strict-dynamic + nonce Closes #21. Per CSP3 and Google's Strict CSP guidance, 'unsafe-inline' is intentionally included alongside 'strict-dynamic' + a nonce/hash as a backwards-compat fallback; browsers that support 'strict-dynamic' ignore 'unsafe-inline' entirely. checkCSP previously deducted 5 points and emitted a finding for this recommended pattern. It now suppresses the penalty only when both 'strict-dynamic' and a nonce/hash source are present, and still penalizes a bare 'unsafe-inline' (including 'strict-dynamic' without a nonce/hash). --- src/rules.ts | 7 ++++++- test/analyzer.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/rules.ts b/src/rules.ts index b146363..542d42d 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -74,7 +74,12 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { const findings: string[] = []; const recommendations: string[] = []; - if (/'unsafe-inline'/i.test(raw)) { + // 'unsafe-inline' is ignored by browsers that support 'strict-dynamic' when a + // nonce/hash is also present — that combination is the recommended Strict CSP + // pattern (the 'unsafe-inline' is a backwards-compat fallback), so don't penalize it. + const hasStrictDynamic = /'strict-dynamic'/i.test(raw); + const hasNonceOrHash = /'nonce-[^']+'/i.test(raw) || /'sha(?:256|384|512)-[^']+'/i.test(raw); + if (/'unsafe-inline'/i.test(raw) && !(hasStrictDynamic && hasNonceOrHash)) { score -= 5; findings.push("'unsafe-inline' weakens XSS protection"); recommendations.push("Remove 'unsafe-inline'; use nonces or hashes instead"); diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 34db864..994d006 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -141,6 +141,17 @@ describe('checkCSP', () => { expect(r.findings.some(f => f.includes('Wildcard'))).toBe(true); }); + it("does not penalize 'unsafe-inline' when 'strict-dynamic' + nonce present", () => { + const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com" }); + expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(false); + expect(r.score).toBe(20); + }); + + it("still penalizes 'unsafe-inline' when 'strict-dynamic' present without nonce/hash", () => { + const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'unsafe-inline'" }); + expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(true); + }); + it('clean CSP returns score 20', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'" }); expect(r.score).toBe(20); From 2fd37a64318b7decee47b69810b6ecfbc99dbba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:17:04 +0000 Subject: [PATCH 06/12] fix(rules): recognize Content-Security-Policy-Report-Only Closes #20. checkCSP only queried the enforcing Content-Security-Policy header, so a report-only deployment (the standard incremental CSP rollout pattern) scored 0/30 'missing' with a 'CSP not present' finding. It now detects Content-Security-Policy-Report-Only when no enforcing header exists and returns partial credit (10/30, 'warning') with feedback to promote the policy to enforcing. An enforcing CSP still takes precedence. --- src/rules.ts | 21 ++++++++++++++++----- test/analyzer.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 542d42d..4c17943 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -64,11 +64,22 @@ export function checkHSTS(headers: RawHeaders): HeaderFinding { export function checkCSP(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'content-security-policy'); - if (!raw) return { - header: 'Content-Security-Policy', score: 0, maxScore: 30, status: 'missing', - findings: ['CSP header not present — XSS attacks are not mitigated'], - recommendations: ["Add a Content-Security-Policy header. Start with: default-src 'self'"], - }; + if (!raw) { + // A report-only policy is the standard incremental-rollout pattern. It does + // not enforce anything, so it can't earn full credit, but it is materially + // different from having no CSP at all and deserves targeted feedback. + const reportOnly = getHeader(headers, 'content-security-policy-report-only'); + if (reportOnly) return { + header: 'Content-Security-Policy', score: 10, maxScore: 30, status: 'warning', raw: reportOnly, + findings: ['CSP is report-only — violations are reported but not enforced, so it does not mitigate XSS'], + recommendations: ['Promote the policy to an enforcing Content-Security-Policy header once validated'], + }; + return { + header: 'Content-Security-Policy', score: 0, maxScore: 30, status: 'missing', + findings: ['CSP header not present — XSS attacks are not mitigated'], + recommendations: ["Add a Content-Security-Policy header. Start with: default-src 'self'"], + }; + } let score = 20; const findings: string[] = []; diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 994d006..4792256 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -119,6 +119,22 @@ describe('checkCSP', () => { expect(checkCSP({}).score).toBe(0); }); + it('report-only CSP gets partial credit and a warning, not missing', () => { + const r = checkCSP({ 'content-security-policy-report-only': "default-src 'self'" }); + expect(r.status).toBe('warning'); + expect(r.score).toBe(10); + expect(r.findings.some(f => /report-only/i.test(f))).toBe(true); + }); + + it('enforcing CSP takes precedence over report-only', () => { + const r = checkCSP({ + 'content-security-policy': "default-src 'self'", + 'content-security-policy-report-only': "default-src *", + }); + expect(r.score).toBe(20); + expect(r.status).toBe('good'); + }); + it('detects unsafe-inline', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'" }); expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(true); From 33b2139a8abad0215c6e99921d0da6c9fd200623 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:19:43 +0000 Subject: [PATCH 07/12] fix(rules): flag missing form-action directive in CSP Closes #19. form-action is one of the few CSP directives that does not fall back to default-src, so a policy like 'default-src \'self\'' leaves form submissions entirely unrestricted. checkCSP now deducts 3 points and emits a finding / recommendation when no form-action directive is present. Updated the README recommended policy and scoring table, and the test fixtures intended to represent a fully-hardened CSP, to include form-action 'self'. --- README.md | 4 ++-- src/rules.ts | 7 +++++++ test/analyzer.test.ts | 27 ++++++++++++++++++++------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 472f48b..d85cb02 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ import { analyzeHeaders } from '@hailbytes/security-headers'; const report = analyzeHeaders({ 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -121,7 +121,7 @@ interface HeaderFinding { | Header | Max Points | Key Checks | |---|---|---| | Strict-Transport-Security | 20 | max-age ≥ 1 year, includeSubDomains, preload | -| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards | +| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards, form-action set | | X-Frame-Options | 15 | DENY or SAMEORIGIN (or CSP frame-ancestors) | | X-Content-Type-Options | 10 | nosniff | | Referrer-Policy | 10 | strict values only | diff --git a/src/rules.ts b/src/rules.ts index 4c17943..9d2b7cf 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -105,6 +105,13 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { findings.push('Wildcard (*) in default-src or script-src allows any origin'); recommendations.push('Replace wildcards with specific trusted domains'); } + // form-action does NOT inherit from default-src, so its absence leaves form + // submissions unrestricted even under a strict default-src 'self'. + if (extractCspDirective(raw, 'form-action') === undefined) { + score -= 3; + findings.push('No form-action directive — form submissions are unrestricted (form-action does not inherit from default-src)'); + recommendations.push("Add form-action 'self' (or 'none') to restrict where forms can submit"); + } score = Math.max(5, score); // at least 5 for having any CSP return { header: 'Content-Security-Policy', score, maxScore: 30, status: findings.length === 0 ? 'good' : 'warning', raw, findings, recommendations }; diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 4792256..a85f94b 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -8,7 +8,7 @@ import { const STRONG_HEADERS = { 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', - 'content-security-policy': "default-src 'self'; img-src *", + 'content-security-policy': "default-src 'self'; img-src *; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -128,7 +128,7 @@ describe('checkCSP', () => { it('enforcing CSP takes precedence over report-only', () => { const r = checkCSP({ - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'content-security-policy-report-only': "default-src *", }); expect(r.score).toBe(20); @@ -158,7 +158,7 @@ describe('checkCSP', () => { }); it("does not penalize 'unsafe-inline' when 'strict-dynamic' + nonce present", () => { - const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com" }); + const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com; form-action 'self'" }); expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(false); expect(r.score).toBe(20); }); @@ -169,13 +169,26 @@ describe('checkCSP', () => { }); it('clean CSP returns score 20', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'" }); + expect(r.score).toBe(20); + }); + + it('flags missing form-action directive', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'" }); + expect(r.findings.some(f => /form-action/i.test(f))).toBe(true); + expect(r.status).toBe('warning'); + expect(r.score).toBe(17); + }); + + it("form-action 'none' satisfies the form-action check", () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'none'" }); + expect(r.findings.some(f => /form-action/i.test(f))).toBe(false); expect(r.score).toBe(20); }); it('CSP with both unsafe-inline and unsafe-eval scores 10', () => { // 20 - 5 - 5 = 10, which is above the floor of 5 - const r = checkCSP({ 'content-security-policy': "default-src 'unsafe-inline' 'unsafe-eval'" }); + const r = checkCSP({ 'content-security-policy': "default-src 'unsafe-inline' 'unsafe-eval'; form-action 'self'" }); expect(r.score).toBe(10); }); @@ -410,7 +423,7 @@ describe('grade boundaries', () => { it('A+ at 90%', () => { const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -429,7 +442,7 @@ describe('grade boundaries', () => { // Let's use stricter combo: missing permissions-policy too const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -445,7 +458,7 @@ describe('grade boundaries', () => { it('B at 60%', () => { const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', From fccb234a1743f904a32e89a2474b3d5c139a63ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:20:04 +0000 Subject: [PATCH 08/12] fix(rules): drop no-referrer-when-downgrade from strong Referrer-Policy values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #18. no-referrer-when-downgrade sends the full URL (path and query string) to every cross-origin HTTPS destination — it was the historical browser default specifically because it was the least restrictive option, and Chrome 85 replaced it with strict-origin-when-cross-origin for that reason. It no longer counts as a 'strong' value, so it now scores 5/'warning' instead of 10/'good', matching the README's documented 'strict values only' intent. --- src/rules.ts | 5 ++++- test/analyzer.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 9d2b7cf..5716754 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -172,7 +172,10 @@ export function checkReferrerPolicy(headers: RawHeaders): HeaderFinding { findings: ['Referrer-Policy not set — browser default may leak URLs in Referer header'], recommendations: ['Add Referrer-Policy: strict-origin-when-cross-origin'], }; - const strongValues = ['no-referrer', 'strict-origin', 'strict-origin-when-cross-origin', 'no-referrer-when-downgrade', 'same-origin']; + // no-referrer-when-downgrade is intentionally excluded: it sends the full URL + // (path + query) to every cross-origin HTTPS destination. It was the historical + // browser default precisely because it was the least restrictive option. + const strongValues = ['no-referrer', 'strict-origin', 'strict-origin-when-cross-origin', 'same-origin']; const isStrong = strongValues.includes(raw.toLowerCase().trim()); const score = isStrong ? 10 : 5; return { header: 'Referrer-Policy', score, maxScore: 10, status: isStrong ? 'good' : 'warning', raw, diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index a85f94b..4eb43b2 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -307,9 +307,10 @@ describe('checkReferrerPolicy', () => { expect(r.score).toBe(10); }); - it('no-referrer-when-downgrade is strong', () => { + it('no-referrer-when-downgrade is not strong (leaks full URL cross-origin)', () => { const r = checkReferrerPolicy({ 'referrer-policy': 'no-referrer-when-downgrade' }); - expect(r.score).toBe(10); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); }); it('unsafe-url returns score 5', () => { From d7402924682dedeb633a46263ff2c926d60129d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:20:46 +0000 Subject: [PATCH 09/12] fix(rules): treat HSTS max-age=0 as revocation, not 'good' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #17. checkHSTS awarded the includeSubDomains (+3) and preload (+2) bonuses independently of max-age, so 'max-age=0; includeSubDomains; preload' scored 15/20 'good' — even though max-age=0 is the standard HSTS revocation pattern that purges the host from the browser's HSTS cache and disables HTTPS enforcement. The directive bonuses now only apply when max-age > 0, and max-age=0 emits an explicit revocation finding, yielding 10/20 'warning'. --- src/rules.ts | 19 ++++++++++++++----- test/analyzer.test.ts | 7 +++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 5716754..8e60e05 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -50,14 +50,23 @@ export function checkHSTS(headers: RawHeaders): HeaderFinding { const maxAge = m ? parseInt(m[1], 10) : 0; if (maxAge >= 31536000) { score += 5; - } else { + } else if (maxAge > 0) { findings.push(`max-age=${maxAge} is below recommended 31536000 (1 year)`); recommendations.push('Set max-age=31536000'); - if (maxAge > 0) score += 2; + score += 2; + } else { + // max-age=0 (or absent) explicitly revokes HSTS — the browser purges the + // host from its HSTS cache and stops enforcing HTTPS. + findings.push('max-age=0 revokes HSTS — HTTPS enforcement is disabled'); + recommendations.push('Set max-age=31536000 to enforce HTTPS'); + } + // includeSubDomains / preload only add protection when HSTS is actually + // enforced; awarding their bonuses under max-age=0 would mask a revocation. + if (maxAge > 0) { + if (/includesubdomains/i.test(raw)) { score += 3; } + else { findings.push('includeSubDomains not set'); recommendations.push('Add includeSubDomains directive'); } + if (/preload/i.test(raw)) score += 2; } - if (/includesubdomains/i.test(raw)) { score += 3; } - else { findings.push('includeSubDomains not set'); recommendations.push('Add includeSubDomains directive'); } - if (/preload/i.test(raw)) score += 2; return { header: 'Strict-Transport-Security', score, maxScore: 20, status: score >= 15 ? 'good' : 'warning', raw, findings, recommendations }; } diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 4eb43b2..13e9ff6 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -107,6 +107,13 @@ describe('checkHSTS', () => { expect(r.score).toBe(20); }); + it('max-age=0 is a revocation: warning, no includeSubDomains/preload bonus', () => { + const r = checkHSTS({ 'strict-transport-security': 'max-age=0; includeSubDomains; preload' }); + expect(r.score).toBe(10); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => /revoke/i.test(f))).toBe(true); + }); + it('max-age between 1 and 31536000 gives partial credit', () => { const r = checkHSTS({ 'strict-transport-security': 'max-age=86400; includeSubDomains' }); expect(r.score).toBeGreaterThan(10); From d85bd2aeb3255df82ee751c23f2ed6e325fc5986 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:21:31 +0000 Subject: [PATCH 10/12] fix(rules): broaden CSP wildcard detection Closes #16. Wildcard detection previously matched only a '*' appearing as the first token of default-src or script-src, so 'connect-src *', 'form-action *', and mid-policy wildcards like 'default-src \'self\' *' silently passed. It now parses the source list of each sensitive directive (default-src, script-src, connect-src, form-action, frame-src, worker-src) and flags a '*' source anywhere within it. Low-risk directives (img-src, style-src, etc.) are intentionally excluded. --- src/rules.ts | 13 +++++++++++-- test/analyzer.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 8e60e05..338dd83 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -109,9 +109,18 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { findings.push("'unsafe-eval' allows eval() — potential code injection"); recommendations.push("Remove 'unsafe-eval'"); } - if (/(?:default-src|script-src)\s+\*/i.test(raw)) { + // Check a wildcard (*) source anywhere in the source list of any sensitive + // fetch/navigation directive — not just as the first token of default-src/ + // script-src. img-src/style-src/font-src/media-src are intentionally omitted + // as a wildcard there is low-risk and commonly legitimate. + const wildcardDirectives = ['default-src', 'script-src', 'connect-src', 'form-action', 'frame-src', 'worker-src']; + const wildcarded = wildcardDirectives.filter(d => { + const sources = extractCspDirective(raw, d); + return sources !== undefined && sources.includes('*'); + }); + if (wildcarded.length > 0) { score -= 5; - findings.push('Wildcard (*) in default-src or script-src allows any origin'); + findings.push(`Wildcard (*) source in ${wildcarded.join(', ')} allows any origin`); recommendations.push('Replace wildcards with specific trusted domains'); } // form-action does NOT inherit from default-src, so its absence leaves form diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 13e9ff6..c34c13a 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -175,6 +175,29 @@ describe('checkCSP', () => { expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(true); }); + it('detects wildcard in connect-src', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; connect-src *" }); + expect(r.findings.some(f => /Wildcard.*connect-src/i.test(f))).toBe(true); + expect(r.score).toBe(15); + }); + + it('detects wildcard in form-action', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action *" }); + expect(r.findings.some(f => /Wildcard.*form-action/i.test(f))).toBe(true); + }); + + it("detects mid-policy wildcard (default-src 'self' *)", () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self' *; form-action 'self'" }); + expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(true); + expect(r.score).toBe(15); + }); + + it('does not flag a wildcard in low-risk img-src', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; img-src *" }); + expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(false); + expect(r.score).toBe(20); + }); + it('clean CSP returns score 20', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'" }); expect(r.score).toBe(20); From 84a78d54dd93d0e317b99ab73dc2e71aab6521e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:22:24 +0000 Subject: [PATCH 11/12] fix(rules): score Cross-Origin policies by value, not presence Closes #8. checkCrossOriginPolicies awarded points for the mere presence of COEP/COOP/ CORP headers, so a site explicitly opting out of isolation with 'Cross-Origin-Opener-Policy: unsafe-none', 'Cross-Origin-Embedder-Policy: unsafe-none', and 'Cross-Origin-Resource-Policy: cross-origin' (two of which are browser defaults) still scored 5/5 'good'. Points are now awarded only for restrictive values (COEP require-corp/credentialless, COOP same-origin[-allow- popups], CORP same-origin/same-site); permissive values are flagged with a finding and earn no credit. --- src/rules.ts | 39 +++++++++++++++++++++++++++++---------- test/analyzer.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 338dd83..45c315e 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -229,19 +229,38 @@ export function checkCrossOriginPolicies(headers: RawHeaders): HeaderFinding { const coep = getHeader(headers, 'cross-origin-embedder-policy'); const coop = getHeader(headers, 'cross-origin-opener-policy'); const corp = getHeader(headers, 'cross-origin-resource-policy'); - const count = [coep, coop, corp].filter(Boolean).length; - const score = Math.min(count * 2, 5); - const missing = [ - !coep && 'Cross-Origin-Embedder-Policy', - !coop && 'Cross-Origin-Opener-Policy', - !corp && 'Cross-Origin-Resource-Policy', - ].filter(Boolean) as string[]; + const norm = (v?: string) => v?.toLowerCase().trim() ?? ''; + // Only restrictive values provide isolation. The defaults (unsafe-none / the + // permissive cross-origin) explicitly opt out and earn no credit. + const coepOk = ['require-corp', 'credentialless'].includes(norm(coep)); + const coopOk = ['same-origin', 'same-origin-allow-popups'].includes(norm(coop)); + const corpOk = ['same-origin', 'same-site'].includes(norm(corp)); + + const protective = [coepOk, coopOk, corpOk].filter(Boolean).length; + const score = Math.min(protective * 2, 5); + + const findings: string[] = []; + const recommendations: string[] = []; + const consider = (val: string | undefined, ok: boolean, name: string, recommended: string) => { + if (!val) { + findings.push(`${name} not set`); + recommendations.push(`Add ${name}: ${recommended}`); + } else if (!ok) { + findings.push(`${name}: '${val}' provides no cross-origin isolation`); + recommendations.push(`Set ${name}: ${recommended}`); + } + }; + consider(coep, coepOk, 'Cross-Origin-Embedder-Policy', 'require-corp'); + consider(coop, coopOk, 'Cross-Origin-Opener-Policy', 'same-origin'); + consider(corp, corpOk, 'Cross-Origin-Resource-Policy', 'same-origin'); + + const anyPresent = Boolean(coep || coop || corp); return { header: 'Cross-Origin Policies', score, maxScore: 5, - status: score >= 4 ? 'good' : score > 0 ? 'warning' : 'missing', + status: score >= 4 ? 'good' : (score > 0 || anyPresent) ? 'warning' : 'missing', raw: [coep && `COEP: ${coep}`, coop && `COOP: ${coop}`, corp && `CORP: ${corp}`].filter(Boolean).join('; ') || undefined, - findings: missing.map(h => `${h} not set`), - recommendations: missing.map(h => `Add ${h}`), + findings, + recommendations, }; } diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index c34c13a..ddf88f8 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -439,6 +439,31 @@ describe('checkCrossOriginPolicies', () => { expect(r.status).toBe('good'); }); + it('permissive values (unsafe-none / cross-origin) earn no credit', () => { + const r = checkCrossOriginPolicies({ + 'cross-origin-embedder-policy': 'unsafe-none', + 'cross-origin-opener-policy': 'unsafe-none', + 'cross-origin-resource-policy': 'cross-origin', + }); + expect(r.score).toBe(0); + expect(r.status).toBe('warning'); + expect(r.findings.every(f => /no cross-origin isolation/i.test(f))).toBe(true); + }); + + it('mixed: only protective values count', () => { + const r = checkCrossOriginPolicies({ + 'cross-origin-opener-policy': 'same-origin', // protective + 'cross-origin-resource-policy': 'cross-origin', // permissive + }); + expect(r.score).toBe(2); + expect(r.status).toBe('warning'); + }); + + it('COOP same-origin-allow-popups counts as protective', () => { + const r = checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin-allow-popups' }); + expect(r.score).toBe(2); + }); + it('includes raw values in output', () => { const r = checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin' }); expect(r.raw).toContain('COOP: same-origin'); From ae573dafea165d67594022b5183671e795def433 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:22:49 +0000 Subject: [PATCH 12/12] fix(fetch): use GET instead of HEAD to retrieve headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7. Many real-world deployments emit security headers (especially Content-Security-Policy, set by HTML-only middleware, content-type-conditional logic, CDN page rules, or edge workers) only on GET responses, not HEAD. A hard-coded HEAD request therefore systematically reported headers as 'missing' on well-configured sites, producing false D/F grades — and, because the CLI exits non-zero on D/F, breaking CI gates. fetchHeaders now issues GET (as securityheaders.com and Mozilla Observatory do) and discards the response body without reading it so no content is downloaded. --- src/fetch.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/fetch.ts b/src/fetch.ts index 240b94b..22dc35c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -7,9 +7,14 @@ export async function fetchHeaders(url: string, options?: FetchOptions): Promise const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal }); + // Use GET rather than HEAD: many sites (and CDNs/edge workers) emit security + // headers — notably Content-Security-Policy — only on full responses, so a + // HEAD request systematically under-reports them. We only need the headers, + // so the response body is discarded without being read. + const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal }); const headers: Record = {}; res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); + try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } return headers; } finally { clearTimeout(timer);