diff --git a/.github/workflows/caipe-ui-tests.yml b/.github/workflows/caipe-ui-tests.yml index 203140433b..2afc5d91cc 100644 --- a/.github/workflows/caipe-ui-tests.yml +++ b/.github/workflows/caipe-ui-tests.yml @@ -1,10 +1,11 @@ name: "[CI] CAIPE UI Tests" -description: "Run CAIPE UI Jest tests and mocked RBAC Playwright regression on pull requests" +# description: "Run CAIPE UI Jest tests and mocked RBAC Playwright regression on pull requests" on: pull_request: paths: - 'ui/**' + - 'scripts/npm/**' - '!ui/README.md' - '!ui/**/*.md' push: @@ -12,6 +13,7 @@ on: - main paths: - 'ui/**' + - 'scripts/npm/**' - '!ui/README.md' - '!ui/**/*.md' workflow_dispatch: @@ -50,6 +52,10 @@ jobs: working-directory: ui run: npm ci + - name: Test npm security backports + working-directory: ui + run: node --test ../scripts/npm/*.test.mjs + - name: Test External App examples working-directory: ui run: npm run test:external-app-examples diff --git a/.github/workflows/ci-caipe-ui.yml b/.github/workflows/ci-caipe-ui.yml index a2f56e4c11..df56b3e57e 100644 --- a/.github/workflows/ci-caipe-ui.yml +++ b/.github/workflows/ci-caipe-ui.yml @@ -1,5 +1,5 @@ name: "[CI] CAIPE UI Build and Push" -description: "Build and push CAIPE UI Docker image" +# description: "Build and push CAIPE UI Docker image" on: push: @@ -11,6 +11,7 @@ on: paths: - 'ui/**' - 'build/Dockerfile.caipe-ui' + - 'scripts/npm/**' workflow_dispatch: inputs: tag_version: @@ -58,6 +59,7 @@ jobs: ui: - 'ui/**' - 'build/Dockerfile.caipe-ui' + - 'scripts/npm/**' - name: Set Outputs id: set-outputs diff --git a/.github/workflows/docs-build-check.yml b/.github/workflows/docs-build-check.yml index 4e1735753c..a6a1ee1fe6 100644 --- a/.github/workflows/docs-build-check.yml +++ b/.github/workflows/docs-build-check.yml @@ -5,6 +5,7 @@ on: pull_request: paths: - "docs/**" + - "scripts/npm/**" permissions: contents: read @@ -41,6 +42,9 @@ jobs: run: | cd docs npm install + - name: Test npm security backports + working-directory: docs + run: node --test ../scripts/npm/*.test.mjs - name: Build Docusaurus site env: NODE_OPTIONS: "--max-old-space-size=8192" diff --git a/.github/workflows/prebuild-caipe-ui.yml b/.github/workflows/prebuild-caipe-ui.yml index 65b2789f16..60e5b4bd6e 100644 --- a/.github/workflows/prebuild-caipe-ui.yml +++ b/.github/workflows/prebuild-caipe-ui.yml @@ -1,5 +1,5 @@ name: "[Prebuild] CAIPE UI Docker Build and Push" -description: "Build and push CAIPE UI Docker image for prebuild branches" +# description: "Build and push CAIPE UI Docker image for prebuild branches" on: pull_request: @@ -10,6 +10,7 @@ on: - '.github/actions/publish-prebuild-manifest/**' - 'ui/**' - 'build/Dockerfile.caipe-ui' + - 'scripts/npm/**' workflow_call: inputs: pr_number: @@ -79,6 +80,7 @@ jobs: .github/actions/prebuild-status \ .github/actions/publish-prebuild-manifest \ ui \ + scripts/npm \ build/Dockerfile.caipe-ui | grep -q .; then echo "should_build=true" >> "$GITHUB_OUTPUT" else diff --git a/.github/workflows/prebuild-manual.yml b/.github/workflows/prebuild-manual.yml index 2d99fb4e13..7e957ea566 100644 --- a/.github/workflows/prebuild-manual.yml +++ b/.github/workflows/prebuild-manual.yml @@ -1,6 +1,5 @@ name: "[Prebuild] Manual PR Fanout" -description: >- - Manually run prebuild publishing for a prebuild/* PR head from this repository. +# Manually run prebuild publishing for a prebuild/* PR head from this repository. run-name: "Manual prebuild for PR #${{ inputs.pr_number }} (${{ inputs.target }})" @@ -142,6 +141,7 @@ jobs: ], caipe_ui: [ 'ui/**', + 'scripts/npm/**', 'build/Dockerfile.caipe-ui', ], audit_service: [ diff --git a/build/Dockerfile.caipe-ui b/build/Dockerfile.caipe-ui index 8e3c71aef3..19878bd7d8 100644 --- a/build/Dockerfile.caipe-ui +++ b/build/Dockerfile.caipe-ui @@ -13,6 +13,7 @@ WORKDIR /app # Install dependencies with cache and network resilience COPY ui/package.json ui/package-lock.json* ui/yarn.lock* ui/pnpm-lock.yaml* ./ +COPY scripts/npm /scripts/npm # Configure npm for better network resilience. The cache mount keeps downloaded # tarballs on the host between local Docker builds without baking them into the diff --git a/docs/package.json b/docs/package.json index 5fb29896ee..c3fc5ff034 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,7 +12,7 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "postinstall": "node scripts/patch-gray-matter-js-yaml.mjs", + "postinstall": "node scripts/patch-gray-matter-js-yaml.mjs && node ../scripts/npm/patch-security-dependencies.mjs", "typecheck": "tsc" }, "dependencies": { diff --git a/scripts/npm/README.md b/scripts/npm/README.md new file mode 100644 index 0000000000..0275a2d822 --- /dev/null +++ b/scripts/npm/README.md @@ -0,0 +1,28 @@ +# Temporary npm security backports + +The UI and documentation postinstall hooks run `patch-security-dependencies.mjs`. +The UI Docker dependency stage copies this script before installing packages. + +- `braces@3.0.3`: limit parser nesting to 100 brace or parenthesis blocks. This + bounds the AST consumed by recursive compilation, expansion and stringification. + Ordinary patterns remain compatible; excessive nesting throws `SyntaxError`. + Advisory: [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). + Upstream discussion: [micromatch/braces#70](https://github.com/micromatch/braces/issues/70). +- `node-forge@1.4.0`: validate the exact number of nested DigestAlgorithm elements + when verifying RSA signatures, including optional algorithm parameters. + Advisory: [GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv). + Backport source: [digitalbazaar/forge#1152](https://github.com/digitalbazaar/forge/pull/1152). + +Each installed copy listed in the npm lockfile is patched. Unexpected versions +or source contents fail installation. Repeated runs verify the existing patch. +Do not use `--ignore-scripts` for production installations. + +Run from `ui/` and `docs/` after `npm ci`: + +```sh +node --test ../scripts/npm/*.test.mjs +``` + +Remove a backport and install a verified fixed version when upstream publishes +it. Lockfile scanners continue reporting the published version; these hooks +mitigate the installed code without concealing findings or altering versions. diff --git a/scripts/npm/patch-security-dependencies.mjs b/scripts/npm/patch-security-dependencies.mjs new file mode 100644 index 0000000000..6936e2fe12 --- /dev/null +++ b/scripts/npm/patch-security-dependencies.mjs @@ -0,0 +1,52 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const braceBefore = " stack.push(block);"; +export const braceAfter = ` if (stack.length >= 101) { + throw new SyntaxError('Pattern nesting exceeds maximum depth (100)'); + } + stack.push(block);`; +export const forgeBefore = " obj.value.length !== 2) {"; +export const forgeAfter = ` obj.value.length !== 2 || + obj.value[0].value.length !== + (('parameters' in capture) ? 2 : 1)) {`; + +export function patchSource(source, before, after, expected, label) { + // Recognize the complete patched form before searching its shared suffix. + if (source.split(after).length - 1 === expected && + !source.split(after).join("").includes(before)) return source; + if (source.includes(after) || source.split(before).length - 1 !== expected) { + throw new Error(`Unexpected ${label} source; review the security backport`); + } + return source.split(before).join(after); +} + +export function patchDependencies(directory = process.cwd()) { + const lock = JSON.parse(readFileSync(resolve(directory, "package-lock.json"), "utf8")); + const patches = { + braces: { version: "3.0.3", file: "lib/parse.js", before: braceBefore, after: braceAfter, count: 2 }, + "node-forge": { version: "1.4.0", file: "lib/rsa.js", before: forgeBefore, after: forgeAfter, count: 1 }, + }; + for (const [packagePath, metadata] of Object.entries(lock.packages)) { + const name = packagePath.split("node_modules/").at(-1); + const patch = patches[name]; + if (!patch || !existsSync(resolve(directory, packagePath))) continue; + if (metadata.version !== patch.version) { + throw new Error(`Review ${name} ${metadata.version} and remove or update its security backport`); + } + const installed = JSON.parse(readFileSync(resolve(directory, packagePath, "package.json"), "utf8")); + if (installed.name !== name || installed.version !== patch.version) { + throw new Error(`Unexpected installed ${name} version`); + } + const file = resolve(directory, packagePath, patch.file); + const source = readFileSync(file, "utf8"); + const result = patchSource(source, patch.before, patch.after, patch.count, name); + if (result !== source) writeFileSync(file, result); + console.log(`Security backport verified: ${packagePath}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + patchDependencies(); +} diff --git a/scripts/npm/prebuild-eligibility.test.mjs b/scripts/npm/prebuild-eligibility.test.mjs new file mode 100644 index 0000000000..a100d0b3c1 --- /dev/null +++ b/scripts/npm/prebuild-eligibility.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(import.meta.dirname, "../.."); +const workflow = name => readFileSync(resolve(root, ".github/workflows", name), "utf8"); +const automatic = workflow("prebuild-caipe-ui.yml"); +const manual = workflow("prebuild-manual.yml"); +const block = manual.match(/^ {10}script: \|\n((?: {12}[^\n]*\n|\n)+)/m); +assert.ok(block, "manual prebuild metadata script must exist"); +const script = block[1].split("\n").map(line => line.slice(12)).join("\n"); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const resolvePr = new AsyncFunction("process", "context", "github", "core", script); + +async function manualEligibility(file, target) { + const outputs = {}; + await resolvePr( + { env: { PR_NUMBER: "1", TARGET: target } }, + { repo: { owner: "example", repo: "example" }, actor: "test-user" }, + { + rest: { + repos: { getCollaboratorPermissionLevel: async () => ({ data: { permission: "write" } }) }, + pulls: { + get: async () => ({ data: { + number: 1, state: "open", + head: { repo: { full_name: "example/example" }, ref: "prebuild/example", sha: "head" }, + base: { ref: "main", sha: "base" }, + } }), + listFiles: () => {}, + }, + }, + paginate: async () => [{ filename: file }], + }, + { setOutput: (key, value) => { outputs[key] = value; }, info: () => {}, setFailed: assert.fail }, + ); + return outputs.caipe_ui_changed; +} + +test("automatic prebuild trigger and inner eligibility include shared npm scripts", () => { + assert.match(automatic, /- 'scripts\/npm\/\*\*'/); + const comparison = automatic.match(/if git diff --name-only[^]*?\| grep -q \.; then/); + assert.ok(comparison, "prebuild changed-file comparison must exist"); + assert.match(comparison[0], /^\s+scripts\/npm\s+\\$/m); +}); + +for (const target of ["all", "docker", "caipe-ui"]) { + test(`manual ${target} prebuild selects UI for a scripts-only change`, async () => { + assert.equal(await manualEligibility("scripts/npm/example.mjs", target), "true"); + assert.equal(await manualEligibility("scripts/npm/nested/example.mjs", target), "true"); + }); +} + +for (const file of ["docs/example.md", "scripts/example.mjs", "scripts/npm-example/example.mjs"]) { + test(`manual prebuild excludes unrelated ${file} changes from UI selection`, async () => { + assert.equal(await manualEligibility(file, "all"), "false"); + }); +} + +test("UI retains the RBAC regression command alongside the security postinstall hook", () => { + const { scripts } = JSON.parse(readFileSync(resolve(root, "ui/package.json"), "utf8")); + assert.match(scripts["test:e2e:rbac-regression"], /^WORKFLOWS_ENABLED=true playwright test /); + assert.match(scripts["test:e2e:rbac-regression"], /--config=playwright\.rbac\.config\.ts$/); + assert.match(scripts.postinstall, /patch-security-dependencies\.mjs/); +}); diff --git a/scripts/npm/security-backports.test.mjs b/scripts/npm/security-backports.test.mjs new file mode 100644 index 0000000000..b08ee3521f --- /dev/null +++ b/scripts/npm/security-backports.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import test from "node:test"; +import { braceBefore, braceAfter, forgeBefore, forgeAfter, patchSource } from "./patch-security-dependencies.mjs"; + +const require = createRequire(resolve(process.cwd(), "package.json")); +const braces = require("braces"); + +test("normal brace expansion and matching remain compatible", () => { + assert.deepEqual(braces.expand("{primary,secondary}/{1..3}"), + ["primary/1", "primary/2", "primary/3", "secondary/1", "secondary/2", "secondary/3"]); + assert.match("primary/2", new RegExp(braces.compile("{primary,secondary}/{1..3}"))); + assert.deepEqual(braces.expand("\\{literal\\}"), ["{literal}"]); +}); + +for (const [open, close] of [["{", "}"], ["(", ")"], ["{(", ")}"]]) { + for (const operation of ["parse", "compile", "expand"]) { + test(`${operation} rejects excessive ${open} nesting before recursive traversal`, () => { + const pattern = open.repeat(3500) + "example" + close.repeat(3500); + // Some inputs exceed the length limit; both guards must fail with SyntaxError. + assert.throws(() => braces[operation](pattern), SyntaxError); + const bounded = open.repeat(101) + "example" + close.repeat(101); + assert.throws(() => braces[operation](bounded), /maximum depth/); + assert.doesNotThrow(() => braces[operation](open.repeat(10) + "example" + close.repeat(10))); + }); + } +} + +test("patching is idempotent and rejects partial patches or source drift", () => { + for (const [before, after, count] of [[braceBefore, braceAfter, 2], [forgeBefore, forgeAfter, 1]]) { + const original = Array(count).fill(before).join("\n"); + const patched = patchSource(original, before, after, count, "example"); + assert.equal(patchSource(patched, before, after, count, "example"), patched); + assert.throws(() => patchSource("unexpected source", before, after, count, "example")); + assert.throws(() => patchSource(patched + before, before, after, count, "example")); + } +}); + +let forge; +try { forge = require("node-forge"); } catch (error) { + if (error.code !== "MODULE_NOT_FOUND") throw error; +} + +test("RSA verifies valid signatures and rejects extra DigestAlgorithm elements", { skip: !forge }, () => { + const { publicKey, privateKey } = forge.pki.rsa.generateKeyPair({ bits: 1024, e: 65537 }); + const hash = forge.md.sha256.create().update("example message"); + const digest = hash.digest().getBytes(); + assert.equal(publicKey.verify(digest, privateKey.sign(hash)), true); + const { Class, Type, create, oidToDer, toDer } = forge.asn1; + const sequence = values => create(Class.UNIVERSAL, Type.SEQUENCE, true, values); + const oid = create(Class.UNIVERSAL, Type.OID, false, oidToDer(forge.pki.oids.sha256).getBytes()); + const nil = create(Class.UNIVERSAL, Type.NULL, false, ""); + const extra = create(Class.UNIVERSAL, Type.OCTETSTRING, false, "unexpected"); + const digestValue = create(Class.UNIVERSAL, Type.OCTETSTRING, false, digest); + for (const algorithm of [[oid], [oid, nil]]) { + const encoded = toDer(sequence([sequence(algorithm), digestValue])).getBytes(); + assert.equal(publicKey.verify(digest, privateKey.sign(encoded, "NONE")), true); + } + for (const algorithm of [[oid, nil, extra], [oid, nil, nil]]) { + const encoded = toDer(sequence([sequence(algorithm), digestValue])).getBytes(); + const signature = privateKey.sign(encoded, "NONE"); + assert.throws(() => publicKey.verify(digest, signature), /valid RSASSA-PKCS1-v1_5/); + } +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index b6d2a12347..7676b86bd4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -102,7 +102,8 @@ }, "engines": { "node": ">=24.11.0 <25" - } + }, + "hasInstallScript": true }, "node_modules/@adobe/css-tools": { "version": "4.5.0", diff --git a/ui/package.json b/ui/package.json index 0f7fdb8dd3..0a30308fb0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,7 +27,8 @@ "test:e2e:rbac-live-rich-keep": "RUN_RBAC_E2E=1 RBAC_RICH_KEEP_FIXTURE=1 playwright test e2e/rbac/rbac-rich-local-regression-live.spec.ts --config=playwright.rbac.config.ts", "test:e2e:rbac-live-rich-cleanup": "RUN_RBAC_E2E=1 playwright test e2e/rbac/rbac-rich-local-regression-live.spec.ts --config=playwright.rbac.config.ts", "test:e2e:rbac-live-full": "RUN_RBAC_E2E=1 playwright test e2e/rbac/openfga-live.spec.ts e2e/rbac/mcp-server-create-live.spec.ts e2e/rbac/resource-lifecycle-live.spec.ts e2e/rbac/credential-team-sharing.spec.ts e2e/rbac/rbac-rich-local-regression-live.spec.ts --config=playwright.rbac.config.ts", - "test:e2e:token-refresh": "RUN_RBAC_E2E=1 playwright test e2e/rbac/token-refresh.spec.ts --config=playwright.rbac.config.ts" + "test:e2e:token-refresh": "RUN_RBAC_E2E=1 playwright test e2e/rbac/token-refresh.spec.ts --config=playwright.rbac.config.ts", + "postinstall": "node ../scripts/npm/patch-security-dependencies.mjs" }, "dependencies": { "@aws-sdk/client-kms": "3.1119.0",