diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a611a2..6f469e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,8 +210,9 @@ jobs: python scripts/check_supply_chain_hardening.py - name: Frontend Audit (npm) run: | - # Audit only production dependencies, ignore dev - npm audit --production + # Development tooling is part of the build/test trust boundary. + npm ci + npm audit --audit-level=high - name: Install backend deps run: | python -m pip install --upgrade pip diff --git a/docs/release/ci_regression_policy.md b/docs/release/ci_regression_policy.md index e9f2956..5a6d9f9 100644 --- a/docs/release/ci_regression_policy.md +++ b/docs/release/ci_regression_policy.md @@ -9,7 +9,7 @@ All pull requests must pass the repository SOP gate before merge. | Secret detection | `pre-commit run detect-secrets --all-files` | Prevent secret leakage | | Pre-commit hooks | `pre-commit run --all-files --show-diff-on-failure` | Enforce formatting and static checks | | Production dependency boundary | `python scripts/verify_production_dependencies.py` | Parse tracked production imports without importing modules; block ownership, direction, cycle, and dynamic-import drift | -| Frontend dependency audit | `npm audit --production` | Fail on production dependency vulnerabilities in the shipped Node dependency surface | +| Frontend dependency audit | `npm ci` then `npm audit --audit-level=high` | Reconcile the lockfile and fail on high/critical vulnerabilities across production and development dependencies | | Backend dependency audit | `pip-audit -r requirements.txt` | Audit declared Python project dependencies without scanning unrelated CI runner/toolchain packages | | GitHub CodeQL analysis | `.github/workflows/codeql.yml` | Run repository-native static security analysis for Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, and weekly schedule | | Coverage governance | `python scripts/verify_quality_governance.py` | Fail closed on coverage-policy, mutation-threshold, SOP-guidance, and survivor-allowlist drift | @@ -40,7 +40,8 @@ If a change intentionally modifies contract behavior: - Coverage governance is part of the standard gate, not an optional reporting step. - Dependency-audit governance is part of CI parity: - - Node audit should continue to target production dependencies only. + - Node audit must cover production and development dependencies because build and test tooling is part of the acceptance trust boundary. + - A separate production-only audit may be retained as a runtime-boundary readback, but it is not a substitute for the full blocking audit. - Python audit must stay scoped to `requirements.txt`; env-wide bare `pip-audit` is out of contract because it can fail on tool-only transient packages that are not part of the repo dependency surface. - GitHub Actions workflow files are part of the security boundary: - workflows using `GITHUB_TOKEN` must declare explicit least-privilege `permissions:` instead of relying on repository defaults diff --git a/package-lock.json b/package-lock.json index b683675..5a08a08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1207,9 +1207,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1314,9 +1314,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -1334,7 +1334,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1803,9 +1803,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/scripts/pre_push_checks.sh b/scripts/pre_push_checks.sh index 2aa2131..5db07ab 100644 --- a/scripts/pre_push_checks.sh +++ b/scripts/pre_push_checks.sh @@ -359,6 +359,10 @@ fi echo "[pre-push] Node version: $(node -v)" echo "[pre-push] 0/10 supply-chain hardening check" "$VENV_PY" scripts/check_supply_chain_hardening.py +echo "[pre-push] 0.25/10 frontend dependency install and audit" +# IMPORTANT: never accept a warmed or manually changed node_modules tree. +npm ci +npm audit --audit-level=high echo "[pre-push] 0/7 R120 dependency preflight" "$VENV_PY" scripts/preflight_check.py --strict echo "[pre-push] 1/7 detect-secrets" diff --git a/scripts/run_full_tests_linux.sh b/scripts/run_full_tests_linux.sh index 563ca81..0c075cd 100644 --- a/scripts/run_full_tests_linux.sh +++ b/scripts/run_full_tests_linux.sh @@ -87,10 +87,9 @@ require_cmd node require_cmd npm ensure_npm_deps() { - if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then - return 0 - fi - echo "[tests] Installing frontend dependencies via npm ci ..." + # IMPORTANT: acceptance must reconcile the complete lockfile; file-presence + # shortcuts can silently reuse an invalid or stale development dependency tree. + echo "[tests] Reconciling frontend dependencies via npm ci ..." npm ci } @@ -200,6 +199,9 @@ echo "[tests] 0/11 supply-chain hardening check" ensure_npm_deps +echo "[tests] 0.25/11 frontend dependency audit" +npm audit --audit-level=high + echo "[tests] 0.5/11 static analysis policy" "$VENV_PY" scripts/verify_static_analysis_policy.py diff --git a/scripts/run_full_tests_windows.ps1 b/scripts/run_full_tests_windows.ps1 index 36a0161..363eaee 100644 --- a/scripts/run_full_tests_windows.ps1 +++ b/scripts/run_full_tests_windows.ps1 @@ -58,12 +58,9 @@ Require-Cmd node Require-Cmd npm function Ensure-NpmDeps { - $playwrightPkg = Join-Path $root "node_modules\@playwright\test\package.json" - if (Test-Path $playwrightPkg) { - return - } - - Write-Host "[tests] Installing frontend dependencies via npm ci ..." + # IMPORTANT: acceptance must reconcile the complete lockfile; file-presence + # shortcuts can silently reuse an invalid or stale development dependency tree. + Write-Host "[tests] Reconciling frontend dependencies via npm ci ..." Invoke-Checked "npm ci" { npm ci } } @@ -276,6 +273,8 @@ Invoke-Checked "supply-chain hardening check" { & $venvPython scripts\check_supply_chain_hardening.py } Ensure-NpmDeps +Write-Host "[tests] 0.25/11 frontend dependency audit" +Invoke-Checked "npm audit" { npm audit --audit-level=high } Write-Host "[tests] 0.5/11 static analysis policy" Invoke-Checked "static analysis policy" { diff --git a/tests/TEST_SOP.md b/tests/TEST_SOP.md index ce2a029..d5eff67 100644 --- a/tests/TEST_SOP.md +++ b/tests/TEST_SOP.md @@ -249,7 +249,10 @@ Then every `git push` will run: bash scripts/pre_push_checks.sh ``` -`scripts/pre_push_checks.sh` is the CI-parity guard and must include all 9 stages: +`scripts/pre_push_checks.sh` is the CI-parity guard and must reconcile the exact +frontend lockfile with `npm ci`, then block high-severity findings across production +and development dependencies with `npm audit --audit-level=high`, before the 9 +behavioral and governance stages: 1) `detect-secrets` 2) all `pre-commit` hooks @@ -393,6 +396,10 @@ node -v # node -v # # IMPORTANT: run `npm ci` with the same Node version you use for `npm test`. +npm ci + +# Development tooling is part of the acceptance trust boundary. +npm audit --audit-level=high # One-time browser install (recommended) npx playwright install chromium diff --git a/tests/test_s102_frontend_dependency_hardening.py b/tests/test_s102_frontend_dependency_hardening.py new file mode 100644 index 0000000..d249f11 --- /dev/null +++ b/tests/test_s102_frontend_dependency_hardening.py @@ -0,0 +1,116 @@ +import json +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_JSON = ROOT / "package.json" +PACKAGE_LOCK = ROOT / "package-lock.json" +WINDOWS_GATE = ROOT / "scripts" / "run_full_tests_windows.ps1" +LINUX_GATE = ROOT / "scripts" / "run_full_tests_linux.sh" +PRE_PUSH_GATE = ROOT / "scripts" / "pre_push_checks.sh" +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +TEST_SOP = ROOT / "tests" / "TEST_SOP.md" +CI_POLICY = ROOT / "docs" / "release" / "ci_regression_policy.md" + + +def _version_tuple(value: str) -> tuple[int, int, int]: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", value) + if match is None: + raise AssertionError(f"expected a stable semantic version, got {value!r}") + return tuple(int(part) for part in match.groups()) + + +def _assert_ordered(test: unittest.TestCase, content: str, *needles: str) -> None: + positions = [content.index(needle) for needle in needles] + test.assertEqual( + positions, + sorted(positions), + f"expected ordered commands: {' -> '.join(needles)}", + ) + + +class TestFrontendDependencyHardening(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.package = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) + cls.lock = json.loads(PACKAGE_LOCK.read_text(encoding="utf-8")) + + def test_lock_resolves_patched_transitive_dependencies(self): + packages = self.lock["packages"] + self.assertGreaterEqual( + _version_tuple(packages["node_modules/ws"]["version"]), + (8, 21, 0), + ) + self.assertGreaterEqual( + _version_tuple(packages["node_modules/postcss"]["version"]), + (8, 5, 18), + ) + + def test_root_manifest_does_not_claim_transitive_packages(self): + root_dependencies = { + name + for section in ("dependencies", "devDependencies", "optionalDependencies") + for name in self.package.get(section, {}) + } + self.assertTrue({"ws", "postcss", "nanoid"}.isdisjoint(root_dependencies)) + self.assertEqual(self.lock["lockfileVersion"], 3) + + def test_windows_full_gate_reconciles_then_audits_frontend_tree(self): + content = WINDOWS_GATE.read_text(encoding="utf-8") + self.assertNotIn("$playwrightPkg", content) + self.assertNotIn("Test-Path $playwrightPkg", content) + self.assertEqual(content.count('Invoke-Checked "npm ci" { npm ci }'), 1) + _assert_ordered( + self, + content, + 'Invoke-Checked "npm ci" { npm ci }', + 'Invoke-Checked "npm audit" { npm audit --audit-level=high }', + 'Invoke-Checked "frontend E2E" { npm test }', + ) + + def test_linux_full_gate_reconciles_then_audits_frontend_tree(self): + content = LINUX_GATE.read_text(encoding="utf-8") + self.assertNotIn("node_modules/@playwright/test/package.json", content) + self.assertEqual(content.splitlines().count(" npm ci"), 1) + _assert_ordered( + self, + content, + "npm ci", + "npm audit --audit-level=high", + "npm test", + ) + + def test_pre_push_reconciles_then_audits_frontend_tree(self): + content = PRE_PUSH_GATE.read_text(encoding="utf-8") + self.assertEqual(content.count("npm ci"), 1) + _assert_ordered( + self, + content, + "npm ci", + "npm audit --audit-level=high", + "npm test", + ) + + def test_ci_security_job_blocks_full_frontend_tree_findings(self): + content = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("npm ci", content) + self.assertIn("npm audit --audit-level=high", content) + self.assertNotIn("npm audit --production", content) + _assert_ordered( + self, + content, + "npm ci", + "npm audit --audit-level=high", + ) + + def test_public_acceptance_docs_require_full_frontend_audit(self): + required_command = "npm audit --audit-level=high" + self.assertIn(required_command, TEST_SOP.read_text(encoding="utf-8")) + policy = CI_POLICY.read_text(encoding="utf-8") + self.assertIn(required_command, policy) + self.assertIn("development dependencies", policy.lower()) + + +if __name__ == "__main__": + unittest.main()