|
84 | 84 | REMOTE_SHELL_RE = re.compile(r"\b(curl|wget)\b[^\n|;]*(\||;)\s*(sh|bash)\b") |
85 | 85 | DESTRUCTIVE_RE = re.compile(r"\b(rm\s+-rf|git\s+reset\s+--hard|git\s+clean\s+-fdx|chmod\s+777)\b") |
86 | 86 | UNPINNED_NPX_RE = re.compile(r"\bnpx\s+(?:-y\s+)?([a-zA-Z0-9_.-]+)(?:\s|$)") |
| 87 | +ENV_DUMP_RE = re.compile(r"\b(env|printenv|set)\b.*(>\s*\S+|\|\s*(curl|nc|netcat|tee))") |
| 88 | +UNPINNED_ACTION_RE = re.compile(r"uses:\s*['\"]?([^@\s'\":]+/[^@\s'\"]+|docker://[^@\s'\"]+)['\"]?\s*$") |
| 89 | +PINNED_ACTION_RE = re.compile(r"uses:\s*['\"]?([^@\s'\"]+)@([^@\s'\"]+)") |
| 90 | +PYTHON_URL_DEP_RE = re.compile(r"(?i)(https?://|git\+https?://|git\+ssh://)") |
87 | 91 | HTTP_MCP_RE = re.compile(r'"url"\s*:\s*"https?://') |
88 | 92 | AUTO_APPROVE_RE = re.compile(r"(?i)(auto[_-]?approve|always[_-]?allow|allow[_-]?all)") |
89 | 93 | PROMPT_INJECTION_RE = re.compile( |
|
100 | 104 | MCP_SERVER_COUNT_WARN = 8 |
101 | 105 | MCP_SHELL_META_RE = re.compile(r"[;&|`<>]|\$\(") |
102 | 106 | FINGERPRINT_RE = re.compile(r"^[a-f0-9]{16}$") |
| 107 | +GITHUB_ACTION_FLOATING_REFS = {"main", "master", "latest", "dev", "develop", "trunk", "head"} |
103 | 108 |
|
104 | 109 |
|
105 | 110 | @dataclass(frozen=True) |
@@ -781,6 +786,167 @@ def _first_npx_package(args: list[object]) -> str | None: |
781 | 786 | return None |
782 | 787 |
|
783 | 788 |
|
| 789 | +def _scan_package_json(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None: |
| 790 | + if path.name != "package.json": |
| 791 | + return |
| 792 | + try: |
| 793 | + data = json.loads(text) |
| 794 | + except json.JSONDecodeError: |
| 795 | + return |
| 796 | + if not isinstance(data, dict): |
| 797 | + return |
| 798 | + scripts = data.get("scripts", {}) |
| 799 | + if not isinstance(scripts, dict): |
| 800 | + return |
| 801 | + for name, command in scripts.items(): |
| 802 | + if not isinstance(name, str) or not isinstance(command, str): |
| 803 | + continue |
| 804 | + line_number = _line_number_for(text, f'"{name}"') |
| 805 | + evidence = f"scripts.{name}: {command}" |
| 806 | + if REMOTE_SHELL_RE.search(command): |
| 807 | + _finding( |
| 808 | + findings, |
| 809 | + target=target, |
| 810 | + path=path, |
| 811 | + line=line_number, |
| 812 | + severity="high", |
| 813 | + category="supply-chain", |
| 814 | + title="Package script pipes remote content into shell", |
| 815 | + evidence=evidence, |
| 816 | + suggestion="Replace curl-to-shell package scripts with checked-in, pinned, and reviewed installer steps.", |
| 817 | + ) |
| 818 | + if DESTRUCTIVE_RE.search(command): |
| 819 | + _finding( |
| 820 | + findings, |
| 821 | + target=target, |
| 822 | + path=path, |
| 823 | + line=line_number, |
| 824 | + severity="medium", |
| 825 | + category="supply-chain", |
| 826 | + title="Package script contains destructive command", |
| 827 | + evidence=evidence, |
| 828 | + suggestion="Gate destructive package scripts behind explicit operator approval and document recovery steps.", |
| 829 | + ) |
| 830 | + npx_match = UNPINNED_NPX_RE.search(command) |
| 831 | + if npx_match and "@" not in npx_match.group(1): |
| 832 | + _finding( |
| 833 | + findings, |
| 834 | + target=target, |
| 835 | + path=path, |
| 836 | + line=line_number, |
| 837 | + severity="medium", |
| 838 | + category="supply-chain", |
| 839 | + title="Package script uses unpinned npx", |
| 840 | + evidence=evidence, |
| 841 | + suggestion="Pin npx package versions or move execution behind a reviewed lockfile.", |
| 842 | + ) |
| 843 | + if ENV_DUMP_RE.search(command): |
| 844 | + _finding( |
| 845 | + findings, |
| 846 | + target=target, |
| 847 | + path=path, |
| 848 | + line=line_number, |
| 849 | + severity="high", |
| 850 | + category="supply-chain", |
| 851 | + title="Package script may leak environment", |
| 852 | + evidence=evidence, |
| 853 | + suggestion="Avoid dumping environment variables in package scripts, especially near network or file redirection.", |
| 854 | + ) |
| 855 | + |
| 856 | + |
| 857 | +def _scan_github_actions(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None: |
| 858 | + rel = path.relative_to(target) |
| 859 | + if len(rel.parts) < 3 or rel.parts[0] != ".github" or rel.parts[1] != "workflows": |
| 860 | + return |
| 861 | + for line_number, line in enumerate(text.splitlines(), start=1): |
| 862 | + stripped = line.strip() |
| 863 | + if stripped.startswith("pull_request_target:") or stripped == "- pull_request_target": |
| 864 | + _finding( |
| 865 | + findings, |
| 866 | + target=target, |
| 867 | + path=path, |
| 868 | + line=line_number, |
| 869 | + severity="high", |
| 870 | + category="supply-chain", |
| 871 | + title="GitHub Actions uses pull_request_target", |
| 872 | + evidence=stripped, |
| 873 | + suggestion="Avoid pull_request_target for untrusted code paths or isolate it from checkout and secret access.", |
| 874 | + ) |
| 875 | + if stripped.startswith("permissions: write-all"): |
| 876 | + _finding( |
| 877 | + findings, |
| 878 | + target=target, |
| 879 | + path=path, |
| 880 | + line=line_number, |
| 881 | + severity="high", |
| 882 | + category="supply-chain", |
| 883 | + title="GitHub Actions grants write-all permissions", |
| 884 | + evidence=stripped, |
| 885 | + suggestion="Use least-privilege workflow permissions instead of write-all.", |
| 886 | + ) |
| 887 | + action_match = UNPINNED_ACTION_RE.search(stripped) |
| 888 | + if action_match: |
| 889 | + _finding( |
| 890 | + findings, |
| 891 | + target=target, |
| 892 | + path=path, |
| 893 | + line=line_number, |
| 894 | + severity="medium", |
| 895 | + category="supply-chain", |
| 896 | + title="GitHub Action missing pinned ref", |
| 897 | + evidence=stripped, |
| 898 | + suggestion="Pin actions to an immutable commit SHA or a reviewed release ref.", |
| 899 | + ) |
| 900 | + pinned_match = PINNED_ACTION_RE.search(stripped) |
| 901 | + if pinned_match: |
| 902 | + ref = pinned_match.group(2) |
| 903 | + if ref.lower() in GITHUB_ACTION_FLOATING_REFS or (not ref.startswith("v") and not re.fullmatch(r"[a-fA-F0-9]{40}", ref)): |
| 904 | + _finding( |
| 905 | + findings, |
| 906 | + target=target, |
| 907 | + path=path, |
| 908 | + line=line_number, |
| 909 | + severity="medium", |
| 910 | + category="supply-chain", |
| 911 | + title="GitHub Action uses floating ref", |
| 912 | + evidence=stripped, |
| 913 | + suggestion="Pin GitHub Actions to immutable commit SHAs for release-sensitive workflows.", |
| 914 | + ) |
| 915 | + |
| 916 | + |
| 917 | +def _scan_python_project(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None: |
| 918 | + if path.name not in {"pyproject.toml", "setup.cfg", "requirements.txt"}: |
| 919 | + return |
| 920 | + for line_number, line in enumerate(text.splitlines(), start=1): |
| 921 | + stripped = line.strip() |
| 922 | + if not stripped or stripped.startswith("#"): |
| 923 | + continue |
| 924 | + if PYTHON_URL_DEP_RE.search(stripped): |
| 925 | + _finding( |
| 926 | + findings, |
| 927 | + target=target, |
| 928 | + path=path, |
| 929 | + line=line_number, |
| 930 | + severity="medium", |
| 931 | + category="supply-chain", |
| 932 | + title="Python dependency uses URL source", |
| 933 | + evidence=stripped, |
| 934 | + suggestion="Prefer pinned package versions or reviewed immutable commit URLs for Python dependencies.", |
| 935 | + ) |
| 936 | + if "setup_requires" in stripped or "dependency_links" in stripped: |
| 937 | + _finding( |
| 938 | + findings, |
| 939 | + target=target, |
| 940 | + path=path, |
| 941 | + line=line_number, |
| 942 | + severity="medium", |
| 943 | + category="supply-chain", |
| 944 | + title="Python project uses legacy install hook", |
| 945 | + evidence=stripped, |
| 946 | + suggestion="Avoid legacy install-time dependency hooks and move dependencies into static project metadata.", |
| 947 | + ) |
| 948 | + |
| 949 | + |
784 | 950 | def _iter_scan_files(target: Path) -> list[Path]: |
785 | 951 | paths: list[Path] = [] |
786 | 952 | for path in target.rglob("*"): |
@@ -999,6 +1165,9 @@ def scan_target(target: Path, *, include_templates: bool = False, suppressions: |
999 | 1165 | for line_number, line in enumerate(text.splitlines(), start=1): |
1000 | 1166 | _scan_line(findings, target=target, path=path, line_number=line_number, line=line) |
1001 | 1167 | _scan_mcp_document(findings, target=target, path=path, text=text) |
| 1168 | + _scan_package_json(findings, target=target, path=path, text=text) |
| 1169 | + _scan_github_actions(findings, target=target, path=path, text=text) |
| 1170 | + _scan_python_project(findings, target=target, path=path, text=text) |
1002 | 1171 | suppressed = [finding for finding in findings if finding.get("fingerprint") in suppressions] |
1003 | 1172 | findings = [finding for finding in findings if finding.get("fingerprint") not in suppressions] |
1004 | 1173 | counts: dict[str, int] = {} |
|
0 commit comments