fix(governance): enforce frontend dependency audits

This commit is contained in:
rcourtman
2026-08-08 03:26:36 +01:00
parent d1fa7f38c7
commit d1f687c0ea
5 changed files with 136 additions and 2 deletions
+8
View File
@@ -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.
@@ -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
@@ -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",
@@ -0,0 +1,87 @@
// @vitest-environment node
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
interface PackageManifest {
dependencies: Record<string, string>;
}
interface PackageLock {
packages: Record<string, { version?: string }>;
}
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);
}
});
});
@@ -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 {