security: harden supply-chain CI gates

This commit is contained in:
rookiestar28
2026-05-13 10:17:57 +08:00
parent 06f395b4ec
commit bba8055c3f
16 changed files with 544 additions and 23 deletions
+28 -5
View File
@@ -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
+26
View File
@@ -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
+2 -1
View File
@@ -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 }}
+297
View File
@@ -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())
+2
View File
@@ -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"
+1 -1
View File
@@ -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);
}
+5 -2
View File
@@ -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"
+7 -2
View File
@@ -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"
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
```
@@ -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",
}
@@ -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()
@@ -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)
@@ -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 }}",
+87
View File
@@ -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()