diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0149f6a..d233934 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -29,6 +29,8 @@ jobs: with: node-version: 20 cache: npm + - name: Check package.json file references + run: node scripts/check-referenced-files.mjs - run: npm ci - run: npm run lint - run: npm run typecheck diff --git a/README.md b/README.md index edfc7bd..993ae99 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,13 @@ could reach. Pin both with SRI so a bad CDN day fails closed instead of open: ```html ``` diff --git a/dist/fortify.cjs.js b/dist/fortify.cjs.js index e044502..151edb8 100644 --- a/dist/fortify.cjs.js +++ b/dist/fortify.cjs.js @@ -1,4 +1,4 @@ -/*! DOMFortify 0.5.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ +/*! DOMFortify 1.0.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); @@ -82,7 +82,7 @@ function urlMatches(pattern, url) { * - Fails closed: no sanitizer means sinks throw, never leak. * - Only covers Trusted Types sinks; inline handlers / style / URL props stay open. */ -const VERSION = '0.5.0'; +const VERSION = '1.0.0'; // Natives captured up front, so later prototype pollution or clobbering can't swap them out. const root = typeof globalThis !== 'undefined' ? globalThis : window; const doc = typeof document !== 'undefined' ? document : undefined; diff --git a/dist/fortify.es.mjs b/dist/fortify.es.mjs index 30eae02..be24e7f 100644 --- a/dist/fortify.es.mjs +++ b/dist/fortify.es.mjs @@ -1,4 +1,4 @@ -/*! DOMFortify 0.5.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ +/*! DOMFortify 1.0.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ // Cached up front so later prototype pollution or clobbering can't swap hasOwnProperty out. const hasOwn = Object.prototype.hasOwnProperty; /** True only for an own (non-inherited) property, so a polluted prototype is never consulted. */ @@ -78,7 +78,7 @@ function urlMatches(pattern, url) { * - Fails closed: no sanitizer means sinks throw, never leak. * - Only covers Trusted Types sinks; inline handlers / style / URL props stay open. */ -const VERSION = '0.5.0'; +const VERSION = '1.0.0'; // Natives captured up front, so later prototype pollution or clobbering can't swap them out. const root = typeof globalThis !== 'undefined' ? globalThis : window; const doc = typeof document !== 'undefined' ? document : undefined; diff --git a/dist/fortify.js b/dist/fortify.js index 6aaff18..9c0522b 100644 --- a/dist/fortify.js +++ b/dist/fortify.js @@ -1,4 +1,4 @@ -/*! DOMFortify 0.5.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ +/*! DOMFortify 1.0.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ (function () { 'use strict'; @@ -81,7 +81,7 @@ * - Fails closed: no sanitizer means sinks throw, never leak. * - Only covers Trusted Types sinks; inline handlers / style / URL props stay open. */ - const VERSION = '0.5.0'; + const VERSION = '1.0.0'; // Natives captured up front, so later prototype pollution or clobbering can't swap them out. const root = typeof globalThis !== 'undefined' ? globalThis : window; const doc = typeof document !== 'undefined' ? document : undefined; diff --git a/dist/fortify.min.js b/dist/fortify.min.js index e8800b8..fd10706 100644 --- a/dist/fortify.min.js +++ b/dist/fortify.min.js @@ -1,3 +1,3 @@ -/*! DOMFortify 0.5.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ -!function(){"use strict";const t=Object.prototype.hasOwnProperty;function e(e,n){return null!=e&&t.call(e,n)}function n(t,n){return e(t,n)?t[n]:void 0}function r(t){return String(t).slice(0,80)}function i(t){try{return String(t?.message)}catch{return"unknown error"}}function o(t,e){if(null==t)return!1;const n=Array.isArray(t)?t:[t];for(let t=0;t{if(!n)return r("sanitizer-unavailable",{sink:"createHTML"}),null;if(o)return c;try{return o=!0,t.sanitize(c,e)}catch(t){return r("sanitize-threw",{error:i(t)}),null}finally{o=!1}}}function p(t,e,n){return o=>{if(e){let r;try{r=e(o)}catch(e){return n("script-hook-threw",{sink:t,error:i(e)}),null}if("string"==typeof r)return n("script-sink-allowed",{sink:t}),r}return n("script-sink-refused",{sink:t,sample:r(o)}),null}}const h=Object.freeze({init:function(r={}){if(s)return f;s=!0;const h=n(r,"ON_VIOLATION"),O="function"==typeof h?(t,e)=>{try{h(t,e)}catch{}}:()=>{},v={version:"0.5.0",ttSupported:!!l,enforcementActive:!1,defaultPolicyOwned:!1,sanitizerReady:!1,excluded:!1,metaInjected:!1,protected:!1,reason:""},m=(t,e)=>(v.protected=v.defaultPolicyOwned&&v.enforcementActive&&v.sanitizerReady,v.reason=t,f=Object.freeze({...v}),e&&O(e,f),f);try{const s=a&&void 0!==a.href?String(a.href):"";if(o(n(r,"EXCLUDE"),s))return v.excluded=!0,m("URL matched EXCLUDE; DOMFortify is intentionally inactive on this page.","excluded-by-url");const f=n(r,"INCLUDE");if(null!=f&&!o(f,s))return v.excluded=!0,m("URL is outside INCLUDE scope; DOMFortify is intentionally inactive on this page.","outside-include-scope");if(!l||"function"!=typeof l.createPolicy)return m("Trusted Types not supported; library is inert. Sinks are NOT routed.","tt-unsupported");const h=function(t,e){const r=n(t,"URL_CONFIG");if(!Array.isArray(r))return null;for(let t=0;th&&e(h,t)?h[t]:n(r,t);if(!0===n(r,"INJECT_META")){const t=(T=n(r,"META_DIRECTIVE"),w="function"==typeof g("SANITIZER"),"string"==typeof T&&T?T:`require-trusted-types-for 'script'; trusted-types ${w?"default":"default dompurify"};`);v.metaInjected=function(t){if(!u)return!1;const e=u,n='\r\n]/g,"")+'">';if("loading"===e.readyState&&"function"==typeof e.write)try{return e.write(n),!0}catch{}try{const n=e.createElement("meta");n.setAttribute("http-equiv","Content-Security-Policy"),n.setAttribute("content",t),(e.head||e.documentElement).appendChild(n)}catch{}return!1}(t),O("meta-injection-attempted",{directive:t,written:v.metaInjected})}v.enforcementActive=function(){try{return u.createElement("div").innerHTML="x",!1}catch{return!0}}();let b=g("SANITIZER");void 0===b&&(b=c.DOMPurify);const L=d(b),A=g("SANITIZER_CONFIG"),I=A&&"object"==typeof A?function(e){const n={};for(const r in e)t.call(e,r)&&"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(n[r]=e[r]);return n}(A):void 0,E=g("ALLOW_SCRIPT"),R=g("ALLOW_SCRIPT_URL"),k="function"==typeof E?E:null,P="function"==typeof R?R:null;let S=!1;if(L){const t=function(t,e){try{return"string"==typeof t.sanitize("x",e)?{ready:!0,error:null}:{ready:!1,error:"sanitize() did not return a string"}}catch(t){return{ready:!1,error:i(t)}}}(L,I);S=t.ready,t.ready||O("sanitizer-smoketest-failed",{error:t.error})}v.sanitizerReady=S;const z={createHTML:y(L,I,S,O),createScript:p("createScript",k,O),createScriptURL:p("createScriptURL",P,O)};if(l.defaultPolicy)return m("A default Trusted Types policy already exists; DOMFortify did NOT install and cannot vouch for it. Load DOMFortify first, inline in .","preexisting-default-policy");let D;try{D=l.createPolicy("default",z)}catch(t){return m(`createPolicy("default") threw (${i(t)}); another default policy won the race.`,"default-policy-lost")}return l.defaultPolicy&&l.defaultPolicy!==D?m('Our policy was created but is not the active default (allow-duplicates race lost). Remove "allow-duplicates" from the trusted-types directive.',"default-policy-not-active"):(v.defaultPolicyOwned=!0,v.enforcementActive?S?m(`Active: HTML sinks sanitized, script sinks ${k||P?"partly allowed by hooks":"refused"}.`):m("Enforcement active and slot locked, but the sanitizer is unavailable - HTML sinks will THROW (failing closed). Bundle DOMPurify and load it before DOMFortify.","failing-closed"):m("Default policy installed and slot locked, but TT enforcement is NOT active - sinks are not routed. Deliver require-trusted-types-for (header preferred).","enforcement-inactive"))}catch(t){return m(`init() hit an unexpected error (${i(t)}); failing closed.`,"failing-closed")}var T,w},status:function(){return f}});"undefined"!=typeof window&&(window.DOMFortify=h,h.init(window.DOMFortifyConfig||{}))}(); +/*! DOMFortify 1.0.0 | (c) Cure53 and contributors | (MPL-2.0 OR Apache-2.0) */ +!function(){"use strict";const t=Object.prototype.hasOwnProperty;function e(e,n){return null!=e&&t.call(e,n)}function n(t,n){return e(t,n)?t[n]:void 0}function r(t){return String(t).slice(0,80)}function i(t){try{return String(t?.message)}catch{return"unknown error"}}function o(t,e){if(null==t)return!1;const n=Array.isArray(t)?t:[t];for(let t=0;t{if(!n)return r("sanitizer-unavailable",{sink:"createHTML"}),null;if(o)return c;try{return o=!0,t.sanitize(c,e)}catch(t){return r("sanitize-threw",{error:i(t)}),null}finally{o=!1}}}function p(t,e,n){return o=>{if(e){let r;try{r=e(o)}catch(e){return n("script-hook-threw",{sink:t,error:i(e)}),null}if("string"==typeof r)return n("script-sink-allowed",{sink:t}),r}return n("script-sink-refused",{sink:t,sample:r(o)}),null}}const h=Object.freeze({init:function(r={}){if(s)return f;s=!0;const h=n(r,"ON_VIOLATION"),O="function"==typeof h?(t,e)=>{try{h(t,e)}catch{}}:()=>{},v={version:"1.0.0",ttSupported:!!l,enforcementActive:!1,defaultPolicyOwned:!1,sanitizerReady:!1,excluded:!1,metaInjected:!1,protected:!1,reason:""},m=(t,e)=>(v.protected=v.defaultPolicyOwned&&v.enforcementActive&&v.sanitizerReady,v.reason=t,f=Object.freeze({...v}),e&&O(e,f),f);try{const s=a&&void 0!==a.href?String(a.href):"";if(o(n(r,"EXCLUDE"),s))return v.excluded=!0,m("URL matched EXCLUDE; DOMFortify is intentionally inactive on this page.","excluded-by-url");const f=n(r,"INCLUDE");if(null!=f&&!o(f,s))return v.excluded=!0,m("URL is outside INCLUDE scope; DOMFortify is intentionally inactive on this page.","outside-include-scope");if(!l||"function"!=typeof l.createPolicy)return m("Trusted Types not supported; library is inert. Sinks are NOT routed.","tt-unsupported");const h=function(t,e){const r=n(t,"URL_CONFIG");if(!Array.isArray(r))return null;for(let t=0;th&&e(h,t)?h[t]:n(r,t);if(!0===n(r,"INJECT_META")){const t=(T=n(r,"META_DIRECTIVE"),w="function"==typeof g("SANITIZER"),"string"==typeof T&&T?T:`require-trusted-types-for 'script'; trusted-types ${w?"default":"default dompurify"};`);v.metaInjected=function(t){if(!u)return!1;const e=u,n='\r\n]/g,"")+'">';if("loading"===e.readyState&&"function"==typeof e.write)try{return e.write(n),!0}catch{}try{const n=e.createElement("meta");n.setAttribute("http-equiv","Content-Security-Policy"),n.setAttribute("content",t),(e.head||e.documentElement).appendChild(n)}catch{}return!1}(t),O("meta-injection-attempted",{directive:t,written:v.metaInjected})}v.enforcementActive=function(){try{return u.createElement("div").innerHTML="x",!1}catch{return!0}}();let b=g("SANITIZER");void 0===b&&(b=c.DOMPurify);const L=d(b),A=g("SANITIZER_CONFIG"),I=A&&"object"==typeof A?function(e){const n={};for(const r in e)t.call(e,r)&&"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(n[r]=e[r]);return n}(A):void 0,E=g("ALLOW_SCRIPT"),R=g("ALLOW_SCRIPT_URL"),k="function"==typeof E?E:null,P="function"==typeof R?R:null;let S=!1;if(L){const t=function(t,e){try{return"string"==typeof t.sanitize("x",e)?{ready:!0,error:null}:{ready:!1,error:"sanitize() did not return a string"}}catch(t){return{ready:!1,error:i(t)}}}(L,I);S=t.ready,t.ready||O("sanitizer-smoketest-failed",{error:t.error})}v.sanitizerReady=S;const z={createHTML:y(L,I,S,O),createScript:p("createScript",k,O),createScriptURL:p("createScriptURL",P,O)};if(l.defaultPolicy)return m("A default Trusted Types policy already exists; DOMFortify did NOT install and cannot vouch for it. Load DOMFortify first, inline in .","preexisting-default-policy");let D;try{D=l.createPolicy("default",z)}catch(t){return m(`createPolicy("default") threw (${i(t)}); another default policy won the race.`,"default-policy-lost")}return l.defaultPolicy&&l.defaultPolicy!==D?m('Our policy was created but is not the active default (allow-duplicates race lost). Remove "allow-duplicates" from the trusted-types directive.',"default-policy-not-active"):(v.defaultPolicyOwned=!0,v.enforcementActive?S?m(`Active: HTML sinks sanitized, script sinks ${k||P?"partly allowed by hooks":"refused"}.`):m("Enforcement active and slot locked, but the sanitizer is unavailable - HTML sinks will THROW (failing closed). Bundle DOMPurify and load it before DOMFortify.","failing-closed"):m("Default policy installed and slot locked, but TT enforcement is NOT active - sinks are not routed. Deliver require-trusted-types-for (header preferred).","enforcement-inactive"))}catch(t){return m(`init() hit an unexpected error (${i(t)}); failing closed.`,"failing-closed")}var T,w},status:function(){return f}});"undefined"!=typeof window&&(window.DOMFortify=h,h.init(window.DOMFortifyConfig||{}))}(); //# sourceMappingURL=fortify.min.js.map diff --git a/docs/maintaining.md b/docs/maintaining.md index 30a22e6..d9d9164 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -17,8 +17,8 @@ Covers the Branch-Protection and Code-Review checks: ## Secrets -- `NPM_TOKEN` - a granular npm automation token scoped to publish this package only. Keep 2FA on the - npm account. Publishing runs from `publish.yml` via OIDC with `--provenance`. +- npm publishing is done **manually** by a maintainer (`npm publish` from the release tag); there is + no publish workflow and no `NPM_TOKEN` repo secret. Keep 2FA on the npm account. - `SCORECARD_TOKEN` - a fine-grained PAT so the Scorecard workflow can read branch-protection status on a public repo. See the [scorecard-action docs](https://github.com/ossf/scorecard-action#authentication-with-fine-grained-pat-optional). @@ -33,6 +33,16 @@ Covers the Branch-Protection and Code-Review checks: ## Releasing -1. Bump the version in `package.json` (the build injects it in place of `__VERSION__`). -2. Create a GitHub Release / tag. `publish.yml` builds, tests, and publishes with provenance. -3. Publish the SRI hashes for `dist/fortify.min.js` in the release notes so integrators can pin them. +1. Bump the version in `package.json` (the build injects it in place of `__VERSION__`), sync the + lockfile (`npm install --package-lock-only`), run `npm run build`, and commit the rebuilt `dist` + - CI (`Verify committed dist is in sync with src`) fails otherwise. If DOMPurify shipped, bump + the `dompurify` devDependency and refresh the pinned version and SRI hash in the README first. +2. Land that via PR, then create a GitHub Release / tag on the release commit. On publish, + `sign-release.yml` checks out the tag, rebuilds, and attaches Sigstore bundles, and + `slsa-provenance.yml` attests build provenance for the same bytes. +3. Publish manually from a clean checkout of the tag: `npm ci && npm publish` + (`prepublishOnly` rebuilds `dist`; the build is reproducible, so the published bytes match the + committed and attested ones). +4. Publish the SRI hashes for `dist/fortify.min.js` in the release notes so integrators can pin + them (`openssl dgst -sha384 -binary dist/fortify.min.js | openssl base64 -A`), and verify the + hash in the README's CDN snippet matches the published artifact. diff --git a/package-lock.json b/package-lock.json index 0e7c32e..5c8abd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "domfortify", - "version": "0.5.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "domfortify", - "version": "0.5.0", + "version": "1.0.0", "license": "(MPL-2.0 OR Apache-2.0)", "devDependencies": { "@playwright/test": "^1.49.0", @@ -14,7 +14,7 @@ "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.1", "angular": "1.8.3", - "dompurify": "^3.4.11", + "dompurify": "^3.4.12", "fast-check": "^4.8.0", "jquery": "3.4.1", "nyc": "^18.0.0", @@ -1206,9 +1206,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "dev": true, "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { diff --git a/package.json b/package.json index 0374122..81614c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "domfortify", - "version": "0.5.0", + "version": "1.0.0", "description": "Retrofit Trusted Types onto a legacy page: claim the realm's default policy so old DOM-XSS sinks get sanitized without touching the code.", "license": "(MPL-2.0 OR Apache-2.0)", "homepage": "https://github.com/cure53/DOMFortify", @@ -59,6 +59,7 @@ "prebuild": "rimraf dist", "build": "rollup -c config/rollup.config.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", + "check:files": "node scripts/check-referenced-files.mjs", "lint": "prettier --check \"{src,test,config,scripts}/**/*.{ts,js,mjs}\"", "format": "prettier --write \"{src,test,config,scripts}/**/*.{ts,js,mjs}\" \"**/*.md\"", "test:node": "node test/node-runner.mjs", @@ -75,7 +76,7 @@ "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.1", "angular": "1.8.3", - "dompurify": "^3.4.11", + "dompurify": "^3.4.12", "fast-check": "^4.8.0", "jquery": "3.4.1", "nyc": "^18.0.0", diff --git a/scripts/check-referenced-files.mjs b/scripts/check-referenced-files.mjs new file mode 100644 index 0000000..40f3575 --- /dev/null +++ b/scripts/check-referenced-files.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Guard: every in-repo file referenced by a package.json script must exist in the tree. + * + * Catches the 0.5.0 slip where build:cov and coverage referenced config/rollup.coverage.config.mjs + * and scripts/coverage.mjs, but those two files were never committed - so a clean checkout (CI + * included) had a broken `npm run coverage`. Pure Node built-ins, no dependencies. + * + * Narrow by design. It inspects script command strings only, and flags a token as a must-exist + * reference when it looks like a concrete source file: a relative path with a source extension, + * not a glob, and not under a built/output directory. That covers entry points passed to `node`, + * `rollup -c`, `tsc -p`, `playwright --config`, and the like, while deliberately ignoring rimraf + * targets (dist/, coverage/, .nyc_output), prettier globs, and flags. It does not parse `files`, + * `exports`, `bin`, or other packaging fields - those point at built output and have a better home + * in `publint` / `npm pack`, without this guard's build-order traps. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + +const SOURCE_EXT = /\.(?:mjs|cjs|js|ts|json)$/; +const GLOB = /[*?{}[\]]/; +const BUILT_OR_OUTPUT = /^(?:dist|coverage|node_modules|\.nyc_output)\//; + +const misses = []; +for (const [name, command] of Object.entries(pkg.scripts ?? {})) { + for (let token of command.split(/\s+/)) { + // --flag=path keeps the path side; bare flags are dropped. + if (token.startsWith('-')) { + const eq = token.indexOf('='); + if (eq === -1) continue; + token = token.slice(eq + 1); + } + token = token.replace(/^['"]|['"]$/g, ''); + + if (!token || token.includes('://') || token.startsWith('/')) continue; + if (GLOB.test(token) || BUILT_OR_OUTPUT.test(token) || !SOURCE_EXT.test(token)) continue; + + if (!existsSync(join(root, token))) misses.push({ name, token }); + } +} + +if (misses.length > 0) { + console.error('package.json references files that are not in the tree:'); + for (const { name, token } of misses) console.error(` scripts.${name} -> ${token}`); + console.error('\nCommit the missing file(s), or remove the reference.'); + process.exit(1); +} + +console.log('check-referenced-files: all script-referenced source files exist.'); diff --git a/test/e2e/deployment.spec.ts b/test/e2e/deployment.spec.ts index 0d6c058..9aba0fc 100644 --- a/test/e2e/deployment.spec.ts +++ b/test/e2e/deployment.spec.ts @@ -155,7 +155,7 @@ for (const v of VECTORS) { }); } -// --- Legacy-library regression: 0.4.0 must not break when heavy libraries are on the page --------- +// --- Legacy-library regression: DOMFortify must not break when heavy libraries are on the page ---- // jQuery and AngularJS run internal innerHTML (and AngularJS Function/eval) as they load under // enforcement, AFTER DOMFortify has claimed the default policy. The libraries themselves may not fully // initialise under Trusted Types - AngularJS needs Function/eval, which DOMFortify refuses by design,