fix(deps): harden frontend dependency validation

This commit is contained in:
rookiestar28
2026-07-31 16:00:18 +08:00
parent 975843ac1a
commit 816231c49f
8 changed files with 155 additions and 25 deletions
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+10 -10
View File
@@ -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": {
+4
View File
@@ -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"
+6 -4
View File
@@ -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
+5 -6
View File
@@ -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" {
+8 -1
View File
@@ -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
@@ -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()