Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/caipe-ui-tests.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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:
branches:
- main
paths:
- 'ui/**'
- 'scripts/npm/**'
- '!ui/README.md'
- '!ui/**/*.md'
workflow_dispatch:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/ci-caipe-ui.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -11,6 +11,7 @@ on:
paths:
- 'ui/**'
- 'build/Dockerfile.caipe-ui'
- 'scripts/npm/**'
workflow_dispatch:
inputs:
tag_version:
Expand Down Expand Up @@ -58,6 +59,7 @@ jobs:
ui:
- 'ui/**'
- 'build/Dockerfile.caipe-ui'
- 'scripts/npm/**'

- name: Set Outputs
id: set-outputs
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docs-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
pull_request:
paths:
- "docs/**"
- "scripts/npm/**"

permissions:
contents: read
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/prebuild-caipe-ui.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -10,6 +10,7 @@ on:
- '.github/actions/publish-prebuild-manifest/**'
- 'ui/**'
- 'build/Dockerfile.caipe-ui'
- 'scripts/npm/**'
Comment thread
sriaradhyula marked this conversation as resolved.
Resolved
workflow_call:
inputs:
pr_number:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/prebuild-manual.yml
Original file line number Diff line number Diff line change
@@ -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 }})"

Expand Down Expand Up @@ -142,6 +141,7 @@ jobs:
],
caipe_ui: [
'ui/**',
'scripts/npm/**',
'build/Dockerfile.caipe-ui',
],
audit_service: [
Expand Down
1 change: 1 addition & 0 deletions build/Dockerfile.caipe-ui
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
28 changes: 28 additions & 0 deletions scripts/npm/README.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions scripts/npm/patch-security-dependencies.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
65 changes: 65 additions & 0 deletions scripts/npm/prebuild-eligibility.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
65 changes: 65 additions & 0 deletions scripts/npm/security-backports.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
}
});
3 changes: 2 additions & 1 deletion ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
sriaradhyula marked this conversation as resolved.
Resolved
"postinstall": "node ../scripts/npm/patch-security-dependencies.mjs"
},
"dependencies": {
"@aws-sdk/client-kms": "3.1119.0",
Expand Down
Loading