diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4066b7..24c1803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install import deps run: | python -m pip install --upgrade pip @@ -60,6 +63,9 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install preflight deps run: | python -m pip install --upgrade pip @@ -69,7 +75,7 @@ jobs: python scripts/preflight_check.py --strict - name: Install Node deps run: | - npm install + npm ci - name: Install Playwright browsers run: | npx playwright install chromium @@ -89,6 +95,9 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install test deps run: | python -m pip install --upgrade pip @@ -131,6 +140,9 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install test deps run: | python -m pip install --upgrade pip @@ -163,6 +175,9 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install test deps run: | python -m pip install --upgrade pip @@ -183,14 +198,16 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Frontend Audit (npm) run: | # Audit only production dependencies, ignore dev npm audit --production - - - uses: actions/setup-python@v6 - with: - python-version: '3.10' - name: Install backend deps run: | python -m pip install --upgrade pip @@ -214,6 +231,9 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.10' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install test deps run: | python -m pip install --upgrade pip @@ -246,6 +266,9 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.10' + - name: Supply-chain hardening check + run: | + python scripts/check_supply_chain_hardening.py - name: Install test deps run: | python -m pip install --upgrade pip diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2ed3e5e --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,26 @@ +name: Dependency Review + +on: + pull_request: + paths: + - "package.json" + - "package-lock.json" + - "requirements.txt" + - "pyproject.toml" + - ".github/workflows/dependency-review.yml" + +permissions: + contents: read + pull-requests: read + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: always diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 756fd51..5e7ec40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,7 @@ on: - "pyproject.toml" permissions: + contents: read issues: write jobs: @@ -32,6 +33,6 @@ jobs: echo "Skipping registry publish because pyproject version is unchanged." - name: Publish Custom Node if: steps.publish_guard.outputs.should_publish == 'true' - uses: Comfy-Org/publish-node-action@main + uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 with: personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} diff --git a/scripts/check_supply_chain_hardening.py b/scripts/check_supply_chain_hardening.py new file mode 100644 index 0000000..094665e --- /dev/null +++ b/scripts/check_supply_chain_hardening.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Repo-local supply-chain hardening checks. + +This checker is intentionally stdlib-only and read-only so it can run before +package installation. It detects known Mini Shai-Hulud package-family and +persistence indicators without executing code from dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + + +AFFECTED_NPM_PREFIXES = ( + "@tanstack/", + "@uipath/", + "@mistralai/", + "@opensearch-project/", + "@squawk/", + "@tallyui/", + "@draftauth/", + "@draftlab/", + "@taskflow-corp/", + "@tolka/", + "@beproduct/", + "@dirigible-ai/", + "@ml-toolkit-ts/", + "@supersurkhet/", + "@mesadev/", +) + +AFFECTED_NPM_NAMES = { + "safe-action", + "agentwork-cli", + "cmux-agent-mcp", + "cross-stitch", + "git-branch-selector", + "git-git-git", + "ml-toolkit-ts", + "nextmove-mcp", + "ts-dna", + "wot-api", + "intercom-client", +} + +AFFECTED_PYPI_NAMES = { + "mistralai", + "guardrails-ai", + "lightning", + "pytorch-lightning", + "intercom-client", +} + +IOC_FILENAMES = { + "router_init.js", + "tanstack_runner.js", + "opensearch_init.js", + "setup.mjs", + "setup_bun.js", + "transformers.pyz", + "shai-hulud-workflow.yml", + "shai-hulud-workflow.yaml", +} + +IOC_STRINGS = { + "@tanstack/setup", + "git-tanstack", + "83.142.209.194", + "IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner", + "Shai-Hulud", + "shai-hulud", + "Session Protocol", + "transformers.pyz", +} + +# Current repo baseline. A new package lifecycle script must be reviewed. +ALLOWED_INSTALL_SCRIPT_PACKAGES = { + "esbuild", + "fsevents", + "vite/node_modules/fsevents", +} + +TEXT_SCAN_PATHS = ( + ".github", + ".vscode", + "package.json", + "package-lock.json", + "requirements.txt", + "pyproject.toml", +) + +SKIP_DIR_NAMES = { + ".git", + ".planning", + ".pytest_cache", + ".tmp", + ".venv", + ".venv-wsl", + "reference", + "REFERENCE", + "__pycache__", +} + + +@dataclass(frozen=True) +class Finding: + code: str + path: str + detail: str + + +def _normalize_package_name(name: str) -> str: + return name.strip().lower().replace("_", "-") + + +def _load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _iter_lock_packages(lock_path: Path) -> Iterable[tuple[str, str, dict[str, Any]]]: + lock = _load_json(lock_path) + packages = lock.get("packages") or {} + for package_path, metadata in packages.items(): + if not package_path.startswith("node_modules/"): + continue + name = package_path.removeprefix("node_modules/") + version = str((metadata or {}).get("version") or "") + yield name, version, metadata or {} + + +def check_npm_lock(lock_path: Path) -> list[Finding]: + findings: list[Finding] = [] + if not lock_path.exists(): + return findings + + for name, version, metadata in _iter_lock_packages(lock_path): + normalized = _normalize_package_name(name) + if normalized in AFFECTED_NPM_NAMES or normalized.startswith( + AFFECTED_NPM_PREFIXES + ): + findings.append( + Finding( + "mini-shai-hulud-npm-package", + str(lock_path), + f"{name}@{version} matches a known affected package family", + ) + ) + if metadata.get("hasInstallScript") is True: + if name not in ALLOWED_INSTALL_SCRIPT_PACKAGES: + findings.append( + Finding( + "unexpected-npm-install-script", + str(lock_path), + f"{name}@{version} declares hasInstallScript=true and is not allowlisted", + ) + ) + return findings + + +_REQ_NAME_RE = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*(?:\[.*?\])?\s*(?:[<>=!~]=|==|~=|>|<|$)") +_TOML_DEP_RE = re.compile(r"""["']([A-Za-z0-9_.-]+)(?:\[.*?\])?\s*(?:[<>=!~]=|==|~=|>|<|["'])""") + + +def check_python_manifest(path: Path) -> list[Finding]: + findings: list[Finding] = [] + if not path.exists(): + return findings + + text = path.read_text(encoding="utf-8", errors="replace") + candidates: set[str] = set() + if path.name == "requirements.txt": + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + match = _REQ_NAME_RE.match(stripped) + if match: + candidates.add(_normalize_package_name(match.group(1))) + else: + for match in _TOML_DEP_RE.finditer(text): + candidates.add(_normalize_package_name(match.group(1))) + + for name in sorted(candidates & AFFECTED_PYPI_NAMES): + findings.append( + Finding( + "mini-shai-hulud-pypi-package", + str(path), + f"{name} matches a known affected PyPI package family", + ) + ) + return findings + + +def _is_skipped_dir(path: Path, root: Path) -> bool: + try: + rel_parts = path.relative_to(root).parts + except ValueError: + return True + return any(part in SKIP_DIR_NAMES for part in rel_parts) + + +def check_ioc_filenames(root: Path) -> list[Finding]: + findings: list[Finding] = [] + # IMPORTANT: prune skipped dirs before stat; Windows cannot stat WSL venv links reliably. + for current_dir, dir_names, file_names in os.walk(root): + current_path = Path(current_dir) + dir_names[:] = [ + name for name in dir_names if not _is_skipped_dir(current_path / name, root) + ] + for file_name in file_names: + if file_name in IOC_FILENAMES: + path = current_path / file_name + findings.append( + Finding( + "mini-shai-hulud-ioc-file", + str(path.relative_to(root)), + f"matched suspicious filename {file_name}", + ) + ) + return findings + + +def _iter_text_scan_files(root: Path) -> Iterable[Path]: + for rel in TEXT_SCAN_PATHS: + path = root / rel + if path.is_file(): + yield path + elif path.is_dir(): + for child in path.rglob("*"): + if child.is_file() and not _is_skipped_dir(child, root): + yield child + + +def check_ioc_strings(root: Path) -> list[Finding]: + findings: list[Finding] = [] + for path in _iter_text_scan_files(root): + try: + if path.stat().st_size > 2_000_000: + continue + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for needle in sorted(IOC_STRINGS): + if needle in text: + findings.append( + Finding( + "mini-shai-hulud-ioc-string", + str(path.relative_to(root)), + f"matched suspicious string {needle!r}", + ) + ) + return findings + + +def run_checks(root: Path) -> list[Finding]: + root = root.resolve() + findings: list[Finding] = [] + findings.extend(check_npm_lock(root / "package-lock.json")) + findings.extend(check_npm_lock(root / "node_modules" / ".package-lock.json")) + findings.extend(check_python_manifest(root / "requirements.txt")) + findings.extend(check_python_manifest(root / "pyproject.toml")) + findings.extend(check_ioc_filenames(root)) + findings.extend(check_ioc_strings(root)) + return findings + + +def _print_findings(findings: Iterable[Finding]) -> None: + for finding in findings: + print(f"{finding.code}: {finding.path}: {finding.detail}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".", help="Repository root to scan") + parser.add_argument("--json", action="store_true", help="Emit JSON output") + args = parser.parse_args(argv) + + findings = run_checks(Path(args.root)) + if args.json: + print(json.dumps([finding.__dict__ for finding in findings], indent=2)) + elif findings: + _print_findings(findings) + else: + print("supply-chain hardening check passed") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pre_push_checks.sh b/scripts/pre_push_checks.sh index 8076617..2aa2131 100644 --- a/scripts/pre_push_checks.sh +++ b/scripts/pre_push_checks.sh @@ -357,6 +357,8 @@ else 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/7 R120 dependency preflight" "$VENV_PY" scripts/preflight_check.py --strict echo "[pre-push] 1/7 detect-secrets" diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index f286006..8f051bd 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -38,7 +38,7 @@ function runPlaywright(args, { label }) { const cli = resolvePlaywrightCli(); if (!cli) { console.error( - `[OpenClaw] Failed to run ${label}: Playwright CLI not found. Did you run 'npm install'?`, + `[OpenClaw] Failed to run ${label}: Playwright CLI not found. Did you run 'npm ci'?`, ); process.exit(1); } diff --git a/scripts/run_full_tests_linux.sh b/scripts/run_full_tests_linux.sh index 99a7a3c..b95eda8 100644 --- a/scripts/run_full_tests_linux.sh +++ b/scripts/run_full_tests_linux.sh @@ -90,8 +90,8 @@ ensure_npm_deps() { if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then return 0 fi - echo "[tests] Installing frontend dependencies via npm install ..." - npm install + echo "[tests] Installing frontend dependencies via npm ci ..." + npm ci } # Always use project-local venv to avoid global interpreter / tool drift. @@ -189,6 +189,9 @@ fi echo "[tests] Node version: $(node -v)" +echo "[tests] 0/11 supply-chain hardening check" +"$VENV_PY" scripts/check_supply_chain_hardening.py + ensure_npm_deps echo "[tests] 0/9 R120 dependency preflight" diff --git a/scripts/run_full_tests_windows.ps1 b/scripts/run_full_tests_windows.ps1 index 8e7547b..10da4b6 100644 --- a/scripts/run_full_tests_windows.ps1 +++ b/scripts/run_full_tests_windows.ps1 @@ -63,8 +63,8 @@ function Ensure-NpmDeps { return } - Write-Host "[tests] Installing frontend dependencies via npm install ..." - Invoke-Checked "npm install" { npm install } + Write-Host "[tests] Installing frontend dependencies via npm ci ..." + Invoke-Checked "npm ci" { npm ci } } # Prefer project-local virtualenv to avoid global PATH / cache conflicts on Windows. @@ -260,6 +260,11 @@ else { } Write-Host "[tests] Node version: $(node -v)" + +Write-Host "[tests] 0/11 supply-chain hardening check" +Invoke-Checked "supply-chain hardening check" { + & $venvPython scripts\check_supply_chain_hardening.py +} Ensure-NpmDeps Write-Host "[tests] 0/8 R120 dependency preflight" diff --git a/tests/E2E_TESTING_SOP.md b/tests/E2E_TESTING_SOP.md index 5571cd2..04644ad 100644 --- a/tests/E2E_TESTING_SOP.md +++ b/tests/E2E_TESTING_SOP.md @@ -34,7 +34,7 @@ node -v npm -v python --version -npm install +npm ci npx playwright install chromium npm test @@ -59,7 +59,7 @@ python3 --version mkdir -p .tmp/bin ln -sf "$(command -v python3)" .tmp/bin/python -npm install +npm ci npx playwright install chromium # Run with safe temp directory (WSL /mnt/*) @@ -145,7 +145,7 @@ node -v npm -v python --version -npm install +npm ci npx playwright install chromium npm test ``` @@ -161,7 +161,7 @@ python3 --version mkdir -p .tmp/bin ln -sf "$(command -v python3)" .tmp/bin/python -npm install +npm ci npx playwright install chromium mkdir -p .tmp/playwright @@ -174,7 +174,7 @@ TMPDIR=.tmp/playwright TMP=.tmp/playwright TEMP=.tmp/playwright \ - `python: command not found` on WSL: create `.tmp/bin/python` as a shim to `python3`. - Port bind failure: use the repo-documented E2E port override or stop the conflicting process. - Browser missing: run `npx playwright install chromium`. -- Dependency drift: remove `node_modules` and rerun `npm install`. +- Dependency drift: remove `node_modules` and rerun `npm ci`. ### Non-applicable E2E diff --git a/tests/TEST_SOP.md b/tests/TEST_SOP.md index 9b7ec66..2dbe0ea 100644 --- a/tests/TEST_SOP.md +++ b/tests/TEST_SOP.md @@ -59,7 +59,7 @@ Required guardrails: - Node.js 18+ (CI uses 20) - `pre-commit` installed: `python -m pip install pre-commit` - Backend test deps available in the same interpreter (`numpy`, `pillow`, `aiohttp`) -- Frontend deps installed: `npm install` +- Frontend deps installed: `npm ci` ## Environment Sanity (Required Guardrails) @@ -390,7 +390,7 @@ node -v # Then re-check: # node -v # -# IMPORTANT: run `npm install` with the same Node version you use for `npm test`. +# IMPORTANT: run `npm ci` with the same Node version you use for `npm test`. # One-time browser install (recommended) npx playwright install chromium diff --git a/tests/e2e/README.md b/tests/e2e/README.md index e3cfc5b..53a6309 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -5,7 +5,7 @@ Playwright-based end-to-end tests for the OpenClaw sidebar UI. ## Setup ```bash -npm install +npm ci npx playwright install chromium ``` diff --git a/tests/security/test_github_workflow_permissions.py b/tests/security/test_github_workflow_permissions.py index 3c9ab0b..08eef00 100644 --- a/tests/security/test_github_workflow_permissions.py +++ b/tests/security/test_github_workflow_permissions.py @@ -11,7 +11,11 @@ EXPECTED_PERMISSION_BLOCKS = { " contents: read\n" " security-events: write\n" ), + "dependency-review.yml": ( + "permissions:\n" " contents: read\n" " pull-requests: read\n" + ), "pre-commit.yml": "permissions:\n contents: read\n", + "publish.yml": "permissions:\n contents: read\n issues: write\n", "secret-scan.yml": "permissions:\n contents: read\n", } diff --git a/tests/security/test_supply_chain_workflow_policy.py b/tests/security/test_supply_chain_workflow_policy.py new file mode 100644 index 0000000..50f8dde --- /dev/null +++ b/tests/security/test_supply_chain_workflow_policy.py @@ -0,0 +1,70 @@ +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_ROOT = REPO_ROOT / ".github" / "workflows" + +FULL_SHA_RE = re.compile(r"^[a-f0-9]{40}$") +USES_RE = re.compile(r"^\s*uses:\s*([^@\s]+)@([^\s#]+)", re.MULTILINE) + +VERSION_TAG_ACTIONS = { + "actions/checkout", + "actions/dependency-review-action", + "actions/setup-node", + "actions/setup-python", + "actions/upload-artifact", + "github/codeql-action/init", + "github/codeql-action/analyze", +} + + +class TestSupplyChainWorkflowPolicy(unittest.TestCase): + def test_workflows_do_not_use_privileged_pr_or_oidc_boundaries(self): + for workflow_path in sorted(WORKFLOW_ROOT.glob("*.yml")): + text = workflow_path.read_text(encoding="utf-8") + with self.subTest(workflow=workflow_path.name): + self.assertNotIn("pull_request_target:", text) + self.assertNotIn("id-token: write", text) + self.assertNotIn("uses: actions/cache@", text) + + def test_third_party_actions_use_immutable_sha_refs(self): + for workflow_path in sorted(WORKFLOW_ROOT.glob("*.yml")): + text = workflow_path.read_text(encoding="utf-8") + for action, ref in USES_RE.findall(text): + if action.startswith("./"): + continue + with self.subTest(workflow=workflow_path.name, action=action): + if action in VERSION_TAG_ACTIONS: + self.assertRegex(ref, r"^v\d+(?:\.\d+){0,2}$") + else: + self.assertRegex(ref, FULL_SHA_RE) + + def test_publish_workflow_keeps_narrow_release_boundary(self): + text = (WORKFLOW_ROOT / "publish.yml").read_text(encoding="utf-8") + self.assertIn("workflow_dispatch:", text) + self.assertIn("push:", text) + self.assertNotIn("pull_request:", text) + self.assertNotIn("pull_request_target:", text) + self.assertNotIn("id-token: write", text) + self.assertNotIn("uses: actions/cache@", text) + self.assertRegex( + text, + re.compile(r"uses: Comfy-Org/publish-node-action@[a-f0-9]{40}\b"), + ) + self.assertIn("personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}", text) + + def test_dependency_review_gate_is_present_for_dependency_diffs(self): + text = (WORKFLOW_ROOT / "dependency-review.yml").read_text(encoding="utf-8") + self.assertIn("pull_request:", text) + self.assertIn("package-lock.json", text) + self.assertIn("requirements.txt", text) + self.assertIn("pyproject.toml", text) + self.assertIn("permissions:\n contents: read\n pull-requests: read\n", text) + self.assertIn("uses: actions/dependency-review-action@v4", text) + self.assertIn("fail-on-severity: high", text) + self.assertNotIn("secrets.", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_full_test_script_playwright_bootstrap.py b/tests/test_full_test_script_playwright_bootstrap.py index eff04a0..b87b2a4 100644 --- a/tests/test_full_test_script_playwright_bootstrap.py +++ b/tests/test_full_test_script_playwright_bootstrap.py @@ -10,14 +10,14 @@ class TestFullTestScriptPlaywrightBootstrap(unittest.TestCase): def test_linux_full_gate_bootstraps_frontend_deps_and_browsers(self): content = LINUX_SCRIPT.read_text(encoding="utf-8") self.assertIn("ensure_npm_deps", content) - self.assertIn("npm install", content) + self.assertIn("npm ci", content) self.assertIn("OPENCLAW_PLAYWRIGHT_INSTALL=1", content) self.assertIn("OPENCLAW_PLAYWRIGHT_BROWSERS=chromium", content) def test_windows_full_gate_bootstraps_frontend_deps_and_browsers(self): content = WINDOWS_SCRIPT.read_text(encoding="utf-8") self.assertIn("Ensure-NpmDeps", content) - self.assertIn("npm install", content) + self.assertIn("npm ci", content) self.assertIn('$env:OPENCLAW_PLAYWRIGHT_INSTALL = "1"', content) self.assertIn('$env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"', content) diff --git a/tests/test_github_actions_runtime_contract.py b/tests/test_github_actions_runtime_contract.py index 27ed55b..7334d9f 100644 --- a/tests/test_github_actions_runtime_contract.py +++ b/tests/test_github_actions_runtime_contract.py @@ -13,6 +13,9 @@ DEPRECATED_ACTION_PATTERNS = { "Comfy-Org/publish-node-action@v1": re.compile( r"\bComfy-Org/publish-node-action@v1\b" ), + "Comfy-Org/publish-node-action@main": re.compile( + r"\bComfy-Org/publish-node-action@main\b" + ), } EXPECTED_ACTION_PATTERNS = { @@ -65,9 +68,9 @@ class GitHubActionsRuntimeContractTests(unittest.TestCase): def test_publish_workflow_uses_official_publish_action(self): text = (WORKFLOW_DIR / "publish.yml").read_text(encoding="utf-8") - self.assertIn( - "uses: Comfy-Org/publish-node-action@main", + self.assertRegex( text, + re.compile(r"uses: Comfy-Org/publish-node-action@[a-f0-9]{40}\b"), ) self.assertIn( "personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}", diff --git a/tests/test_s96_supply_chain_hardening.py b/tests/test_s96_supply_chain_hardening.py new file mode 100644 index 0000000..77506d9 --- /dev/null +++ b/tests/test_s96_supply_chain_hardening.py @@ -0,0 +1,87 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from scripts import check_supply_chain_hardening as hardening + + +def write_lock(path: Path, packages: dict[str, dict[str, object]]) -> None: + path.write_text( + json.dumps({"lockfileVersion": 3, "packages": packages}), + encoding="utf-8", + ) + + +class TestSupplyChainHardening(unittest.TestCase): + def test_clean_lockfile_has_no_findings(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_lock( + root / "package-lock.json", + { + "": {"name": "example"}, + "node_modules/@playwright/test": {"version": "1.0.0"}, + "node_modules/esbuild": { + "version": "0.25.0", + "hasInstallScript": True, + }, + }, + ) + + self.assertEqual(hardening.run_checks(root), []) + + def test_affected_npm_package_family_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_lock( + root / "package-lock.json", + { + "node_modules/@tanstack/router-core": {"version": "9.9.9"}, + }, + ) + + findings = hardening.run_checks(root) + + self.assertEqual(findings[0].code, "mini-shai-hulud-npm-package") + + def test_unexpected_npm_install_script_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_lock( + root / "package-lock.json", + { + "node_modules/unreviewed-package": { + "version": "1.2.3", + "hasInstallScript": True, + }, + }, + ) + + findings = hardening.run_checks(root) + + self.assertEqual(findings[0].code, "unexpected-npm-install-script") + + def test_affected_python_requirement_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "requirements.txt").write_text("mistralai==2.4.6\n", encoding="utf-8") + + findings = hardening.run_checks(root) + + self.assertEqual(findings[0].code, "mini-shai-hulud-pypi-package") + + def test_ioc_filename_is_reported_without_executing_dependency_code(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + package_dir = root / "node_modules" / "package" + package_dir.mkdir(parents=True) + (package_dir / "router_init.js").write_text("void 0;\n", encoding="utf-8") + + findings = hardening.run_checks(root) + + self.assertEqual(findings[0].code, "mini-shai-hulud-ioc-file") + + +if __name__ == "__main__": + unittest.main()