From d1f687c0ea8ea6cfd91f6fc942a82f67b25652ab Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 8 Aug 2026 03:26:36 +0100 Subject: [PATCH] fix(governance): enforce frontend dependency audits --- .github/workflows/build-and-test.yml | 8 ++ .../subsystems/deployment-installability.md | 14 +++ .../v6/internal/subsystems/registry.json | 19 +++- .../__tests__/dependencySecurity.test.ts | 87 +++++++++++++++++++ .../installtests/build_release_assets_test.go | 10 +++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/src/security/__tests__/dependencySecurity.test.ts diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6c2f8a0dc..26a3fbf3c 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -122,6 +122,14 @@ jobs: working-directory: frontend-modern run: npm ci + - name: Audit complete frontend dependency graph + working-directory: frontend-modern + run: npm audit + + - name: Audit production frontend dependencies + working-directory: frontend-modern + run: npm audit --omit=dev + # Whole-tree, not staged-only: the pre-commit formatter only ever sees # staged files, so drift in untouched files is invisible to it. This is # the backstop that keeps `make format` a no-op on a clean tree. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fd8361a86..d7b11cef1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -51,6 +51,7 @@ TLS floor in the dynamic config. 16. `internal/cloudcp/docker/labels.go` 17. `internal/cloudcp/tenant_runtime_rollout.go` 13. `.github/workflows/build-release-candidate.yml` +14. `.github/workflows/build-and-test.yml` 14. `.github/workflows/create-release.yml` 14. `.github/workflows/deploy-demo-server.yml` 15. `.github/workflows/helm-pages.yml` @@ -2377,6 +2378,19 @@ are part of the same governed bootstrap input even when the package manifest range already permits the newer version; the lockfile must identify the resolved package version and integrity that the release build will actually consume. +Frontend dependency-security changes use their own proof route rather than +borrowing the local dev-runtime orchestration tests. The canonical +`.github/workflows/build-and-test.yml` frontend job must run both the complete +`npm audit` and the production-only `npm audit --omit=dev` after a clean +install. `frontend-modern/src/security/__tests__/dependencySecurity.test.ts` +pins the known safe floors for advisories remediated by commit `6ba85a185`, +including DOMPurify `GHSA-55q2-fjhq-7xh7`, brace-expansion +`GHSA-mh99-v99m-4gvg` and `GHSA-rgw5-rvv9-x895`, and nanoid +`GHSA-2v37-7h3g-55p8`, while +`scripts/installtests/build_release_assets_test.go` prevents either CI audit +gate from being removed silently. A later advisory must advance these floors +and its sanitizer or dependency-specific regression proof together; audit +suppression is not a valid closure. Security-driven Go module graph bumps follow the same rule: `go.mod` and `go.sum` must move together when a reachable vulnerability is remediated, and the slice must carry direct vulnerability or dependency-floor proof so the diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index ed9b078bf..fb9b5acb5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4115,6 +4115,7 @@ ".github/scripts/check-demo-reachability.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", + ".github/workflows/build-and-test.yml", ".github/workflows/build-release-candidate.yml", ".github/workflows/create-release.yml", ".github/workflows/deploy-demo-server.yml", @@ -4515,13 +4516,27 @@ "scripts/installtests/provider_msp_deploy_test.go" ] }, + { + "id": "frontend-dependency-security", + "label": "frontend dependency security proof", + "match_prefixes": [], + "match_files": [ + ".github/workflows/build-and-test.yml", + "frontend-modern/package-lock.json", + "frontend-modern/package.json" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/security/__tests__/dependencySecurity.test.ts", + "scripts/installtests/build_release_assets_test.go" + ] + }, { "id": "dev-runtime-orchestration", "label": "dev runtime orchestration proof", "match_prefixes": [], "match_files": [ - "frontend-modern/package-lock.json", - "frontend-modern/package.json", "frontend-modern/vite.config.ts", "go.mod", "go.sum", diff --git a/frontend-modern/src/security/__tests__/dependencySecurity.test.ts b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts new file mode 100644 index 000000000..3800bc94f --- /dev/null +++ b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + dependencies: Record; +} + +interface PackageLock { + packages: Record; +} + +const manifest = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), +) as PackageManifest; +const lock = JSON.parse( + readFileSync(new URL('../../../package-lock.json', import.meta.url), 'utf8'), +) as PackageLock; + +const parseVersion = (version: string): [number, number, number] => { + const [major = 0, minor = 0, patch = 0] = version + .split('-', 1)[0] + .split('.') + .map((part) => Number.parseInt(part, 10)); + return [major, minor, patch]; +}; + +const atLeast = (version: string, floor: [number, number, number]): boolean => { + const current = parseVersion(version); + for (let index = 0; index < current.length; index += 1) { + if (current[index] !== floor[index]) return current[index] > floor[index]; + } + return true; +}; + +const lockedVersions = (packageName: string): string[] => + Object.entries(lock.packages) + .filter( + ([path]) => + path === `node_modules/${packageName}` || path.endsWith(`/node_modules/${packageName}`), + ) + .map(([, entry]) => entry.version) + .filter((version): version is string => Boolean(version)); + +const braceExpansionIsPatched = (version: string): boolean => { + const [major] = parseVersion(version); + if (major === 1) return atLeast(version, [1, 1, 18]); + if (major === 2) return atLeast(version, [2, 1, 4]); + if (major === 3) return atLeast(version, [3, 0, 6]); + return major >= 5 && atLeast(version, [5, 0, 9]); +}; + +const nanoidIsPatched = (version: string): boolean => { + const [major] = parseVersion(version); + if (major === 3) return atLeast(version, [3, 3, 17]); + return major >= 5 && atLeast(version, [5, 1, 6]); +}; + +describe('frontend dependency security floors', () => { + it('keeps DOMPurify above the hook-detachment XSS floor', () => { + expect(manifest.dependencies.dompurify).toBe('^3.4.13'); + const versions = lockedVersions('dompurify'); + expect(versions).not.toHaveLength(0); + for (const version of versions) { + expect(atLeast(version, [3, 4, 13]), `dompurify ${version} is vulnerable`).toBe(true); + } + }); + + it('keeps every brace-expansion line above both denial-of-service advisory floors', () => { + const versions = lockedVersions('brace-expansion'); + expect(versions).not.toHaveLength(0); + for (const version of versions) { + expect(braceExpansionIsPatched(version), `brace-expansion ${version} is vulnerable`).toBe( + true, + ); + } + }); + + it('keeps nanoid custom generators above the zero-size loop floor', () => { + const versions = lockedVersions('nanoid'); + expect(versions).not.toHaveLength(0); + for (const version of versions) { + expect(nanoidIsPatched(version), `nanoid ${version} is vulnerable`).toBe(true); + } + }); +}); diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index d0fcad213..b9bcf14b4 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2112,6 +2112,16 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } } +func TestFrontendDependencySecurityAuditsAreRequired(t *testing.T) { + workflowPath := repoFile(".github", "workflows", "build-and-test.yml") + assertFileContainsAll(t, workflowPath, + `- name: Audit complete frontend dependency graph`, + `run: npm audit`, + `- name: Audit production frontend dependencies`, + `run: npm audit --omit=dev`, + ) +} + func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(t *testing.T) { content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) if err != nil {