|
19 | 19 | """Security analysis checks for GitHub Actions.""" |
20 | 20 |
|
21 | 21 | import json |
| 22 | +import os |
22 | 23 | import re |
23 | 24 |
|
| 25 | +import requests |
| 26 | + |
24 | 27 | from .console import console, link |
25 | 28 | from .github_client import GitHubClient |
26 | 29 | from .action_ref import ( |
@@ -724,11 +727,121 @@ def analyze_action_metadata( |
724 | 727 | ] |
725 | 728 |
|
726 | 729 |
|
| 730 | +# Patterns indicating a JS/TS download of a remote artifact. Most JS actions |
| 731 | +# that fetch binaries go through @actions/tool-cache's downloadTool (which |
| 732 | +# does NOT verify checksums), or via node's http/https, fetch, axios, or |
| 733 | +# @actions/http-client. Each of these should have a companion hash/signature |
| 734 | +# check in the same file to count as verified. |
| 735 | +_JS_DOWNLOAD_PATTERNS = [ |
| 736 | + re.compile(r"\btc\.downloadTool\s*\("), |
| 737 | + re.compile(r"(?<![a-zA-Z_.])downloadTool\s*\("), |
| 738 | + re.compile(r"\bfetch\s*\([^)]*['\"`]https?://"), |
| 739 | + re.compile(r"\bhttps?\.(?:get|request)\s*\("), |
| 740 | + re.compile(r"\baxios(?:\.(?:get|post|request))?\s*\("), |
| 741 | + re.compile(r"\bnew\s+HttpClient\s*\("), |
| 742 | + re.compile(r"\brequire\(\s*['\"`]node-fetch['\"`]"), |
| 743 | +] |
| 744 | + |
| 745 | +# Verification patterns in JS/TS source: node crypto, WebCrypto, or common |
| 746 | +# sigstore/cosign / custom "verify" helper names. |
| 747 | +_JS_VERIFICATION_PATTERNS = [ |
| 748 | + re.compile(r"\bcrypto\.createHash\s*\("), |
| 749 | + re.compile(r"\bcrypto\.subtle\.digest\b"), |
| 750 | + re.compile(r"\bsubtle\.verify\s*\("), |
| 751 | + re.compile(r"\b@noble/hashes\b"), |
| 752 | + re.compile(r"\bsigstore\b", re.IGNORECASE), |
| 753 | + re.compile(r"\bcosign\b", re.IGNORECASE), |
| 754 | + re.compile(r"\bverifySignature\b"), |
| 755 | + re.compile(r"\bverifyChecksum\b"), |
| 756 | + re.compile(r"\bcomputeHash\b"), |
| 757 | +] |
| 758 | + |
| 759 | +_JS_SOURCE_EXTENSIONS = (".ts", ".js", ".mjs", ".cjs") |
| 760 | +_JS_SCAN_DIR_PREFIXES = ("src/", "lib/", "source/", "sources/", "scripts/") |
| 761 | +_JS_EXCLUDE_DIR_PREFIXES = ( |
| 762 | + "dist/", "build/", "out/", "node_modules/", "coverage/", |
| 763 | + "__tests__/", "test/", "tests/", "examples/", "example/", |
| 764 | + "docs/", ".github/", |
| 765 | +) |
| 766 | + |
| 767 | + |
727 | 768 | def _line_is_pkg_manager(line: str) -> bool: |
728 | 769 | lower = line.lower() |
729 | 770 | return any(marker in lower for marker in _PKG_MANAGER_MARKERS) |
730 | 771 |
|
731 | 772 |
|
| 773 | +def _find_binary_downloads_js(content: str) -> list[tuple[int, str]]: |
| 774 | + """Find lines in JS/TS source that fetch remote artifacts. |
| 775 | +
|
| 776 | + Flags calls to ``tc.downloadTool`` / ``downloadTool``, bare ``fetch`` to |
| 777 | + an http(s) URL, node's ``http(s).get`` / ``.request``, ``axios.*``, and |
| 778 | + ``new HttpClient()``. Skips comment-only lines. |
| 779 | + """ |
| 780 | + findings: list[tuple[int, str]] = [] |
| 781 | + for i, line in enumerate(content.splitlines(), 1): |
| 782 | + stripped = line.strip() |
| 783 | + if not stripped or stripped.startswith("//") or stripped.startswith("*"): |
| 784 | + continue |
| 785 | + if any(p.search(line) for p in _JS_DOWNLOAD_PATTERNS): |
| 786 | + findings.append((i, stripped[:120])) |
| 787 | + return findings |
| 788 | + |
| 789 | + |
| 790 | +def _list_repo_files(org: str, repo: str, commit_hash: str) -> list[str]: |
| 791 | + """List every blob path in the repo at ``commit_hash`` via the trees API. |
| 792 | +
|
| 793 | + Returns an empty list on error, auth failure, or truncated results (the |
| 794 | + caller should treat "no files discovered" as best-effort, not canonical). |
| 795 | + """ |
| 796 | + url = f"https://api.github.com/repos/{org}/{repo}/git/trees/{commit_hash}?recursive=1" |
| 797 | + headers = {"Accept": "application/vnd.github+json"} |
| 798 | + token = os.environ.get("GITHUB_TOKEN") |
| 799 | + if token: |
| 800 | + headers["Authorization"] = f"Bearer {token}" |
| 801 | + try: |
| 802 | + resp = requests.get(url, timeout=15, headers=headers) |
| 803 | + if not resp.ok: |
| 804 | + return [] |
| 805 | + data = resp.json() |
| 806 | + if data.get("truncated"): |
| 807 | + return [] |
| 808 | + return [t["path"] for t in data.get("tree", []) if t.get("type") == "blob"] |
| 809 | + except requests.RequestException: |
| 810 | + return [] |
| 811 | + |
| 812 | + |
| 813 | +def _discover_js_source_files( |
| 814 | + org: str, repo: str, commit_hash: str, sub_path: str, |
| 815 | +) -> list[tuple[str, str]]: |
| 816 | + """Return ``(path, content)`` for JS/TS source files worth scanning. |
| 817 | +
|
| 818 | + Includes files at the repo root and under conventional source dirs |
| 819 | + (``src/``, ``lib/``, …). Excludes compiled output, vendored modules, |
| 820 | + test/example dirs, and generated docs. For monorepo sub-actions the |
| 821 | + ``sub_path`` acts as a prefix filter. |
| 822 | + """ |
| 823 | + files: list[tuple[str, str]] = [] |
| 824 | + all_paths = _list_repo_files(org, repo, commit_hash) |
| 825 | + if not all_paths: |
| 826 | + return files |
| 827 | + |
| 828 | + prefix = f"{sub_path.rstrip('/')}/" if sub_path else "" |
| 829 | + for path in all_paths: |
| 830 | + if prefix and not path.startswith(prefix): |
| 831 | + continue |
| 832 | + rel = path[len(prefix):] if prefix else path |
| 833 | + if not rel.endswith(_JS_SOURCE_EXTENSIONS): |
| 834 | + continue |
| 835 | + if any(rel.startswith(d) for d in _JS_EXCLUDE_DIR_PREFIXES): |
| 836 | + continue |
| 837 | + if "/" in rel and not any(rel.startswith(d) for d in _JS_SCAN_DIR_PREFIXES): |
| 838 | + continue |
| 839 | + content = fetch_file_from_github(org, repo, commit_hash, path) |
| 840 | + if content is not None: |
| 841 | + files.append((rel, content)) |
| 842 | + return files |
| 843 | + |
| 844 | + |
732 | 845 | def _find_binary_downloads(content: str) -> list[tuple[int, str]]: |
733 | 846 | """Find lines that download binaries or scripts over HTTP(S). |
734 | 847 |
|
@@ -857,7 +970,12 @@ def analyze_binary_downloads( |
857 | 970 | if content is not None: |
858 | 971 | files_to_scan.append((script_path, content)) |
859 | 972 |
|
860 | | - if not files_to_scan: |
| 973 | + # JS/TS source files: shell-pattern downloads (curl/wget) are rare here |
| 974 | + # but JS actions commonly fetch binaries via @actions/tool-cache etc., |
| 975 | + # so discover those separately and scan with JS-specific patterns. |
| 976 | + js_files_to_scan = _discover_js_source_files(org, repo, commit_hash, sub_path) |
| 977 | + |
| 978 | + if not files_to_scan and not js_files_to_scan: |
861 | 979 | return warnings, failures |
862 | 980 |
|
863 | 981 | console.print() |
@@ -896,6 +1014,39 @@ def analyze_binary_downloads( |
896 | 1014 | f"{path} line {line_num}: unverified download: {snippet[:80]}" |
897 | 1015 | ) |
898 | 1016 |
|
| 1017 | + for path, content in js_files_to_scan: |
| 1018 | + downloads = _find_binary_downloads_js(content) |
| 1019 | + if not downloads: |
| 1020 | + continue |
| 1021 | + any_downloads = True |
| 1022 | + has_verify = any(p.search(content) for p in _JS_VERIFICATION_PATTERNS) |
| 1023 | + if has_verify: |
| 1024 | + console.print( |
| 1025 | + f" [green]✓[/green] {path}: {len(downloads)} JS download(s), " |
| 1026 | + f"verification present in file" |
| 1027 | + ) |
| 1028 | + for line_num, snippet in downloads[:3]: |
| 1029 | + console.print(f" [dim]line {line_num}:[/dim] [dim]{snippet}[/dim]") |
| 1030 | + if len(downloads) > 3: |
| 1031 | + console.print(f" [dim]... and {len(downloads) - 3} more[/dim]") |
| 1032 | + for line_num, snippet in downloads: |
| 1033 | + warnings.append( |
| 1034 | + f"{path} line {line_num}: JS download present (review coverage): {snippet[:80]}" |
| 1035 | + ) |
| 1036 | + else: |
| 1037 | + console.print( |
| 1038 | + f" [red]✗[/red] {path}: {len(downloads)} unverified JS download(s) " |
| 1039 | + f"[red bold](no checksum/signature check in file)[/red bold]" |
| 1040 | + ) |
| 1041 | + for line_num, snippet in downloads[:5]: |
| 1042 | + console.print(f" [dim]line {line_num}:[/dim] [red]{snippet}[/red]") |
| 1043 | + if len(downloads) > 5: |
| 1044 | + console.print(f" [dim]... and {len(downloads) - 5} more[/dim]") |
| 1045 | + for line_num, snippet in downloads: |
| 1046 | + failures.append( |
| 1047 | + f"{path} line {line_num}: unverified JS download: {snippet[:80]}" |
| 1048 | + ) |
| 1049 | + |
899 | 1050 | if not any_downloads: |
900 | 1051 | console.print(" [green]✓[/green] No binary downloads detected") |
901 | 1052 |
|
|
0 commit comments