#!/usr/bin/env python3 """Analyze software packages for supply chain compromise indicators. Inspects Python (PyPI), npm, and Ruby (RubyGems) packages for suspicious patterns including obfuscated code, malicious install scripts, data exfiltration hooks, typosquatting, and dependency confusion. """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path from typing import Any # Common suspicious patterns by category _SUSPICIOUS_PATTERNS: dict[str, list[re.Pattern[str]]] = { "dynamic_execution": [ re.compile(r"\beval\s*\(", re.IGNORECASE), re.compile(r"\bexec\s*\(", re.IGNORECASE), re.compile(r"\bcompile\s*\(", re.IGNORECASE), re.compile(r"__import__\s*\("), re.compile(r"\bFunction\s*\("), ], "base64_encoding": [ re.compile(r"b64decode", re.IGNORECASE), re.compile(r"base64\.decode", re.IGNORECASE), re.compile(r"\batob\s*\("), ], "network_calls": [ re.compile(r"requests\.(get|post|put)\s*\("), re.compile(r"urllib\.\w+\.\w+"), re.compile(r"http\.client"), re.compile(r"socket\.connect"), re.compile(r"\bfetch\s*\("), re.compile(r"XMLHttpRequest"), ], "credential_access": [ re.compile(r"os\.environ"), re.compile(r"process\.env"), re.compile(r"\.aws/credentials"), re.compile(r"\.ssh/"), re.compile(r"\.npmrc"), re.compile(r"\.pypirc"), ], "data_exfiltration": [ re.compile(r"webhook", re.IGNORECASE), re.compile(r"discord\.com/api/webhooks"), re.compile(r"telegram", re.IGNORECASE), re.compile(r"pastebin", re.IGNORECASE), re.compile(r"transfer\.sh"), re.compile(r"requestbin", re.IGNORECASE), ], "obfuscation": [ re.compile(r"\\x[0-9a-f]{2}"), re.compile(r"chr\s*\(\s*\d+\s*\)"), re.compile(r"String\.fromCharCode"), ], } def _scan_file(file_path: Path) -> list[dict[str, Any]]: """Scan a single file for suspicious patterns.""" findings: list[dict[str, Any]] = [] try: content = file_path.read_text(encoding="utf-8", errors="replace") except OSError: return findings for line_no, line in enumerate(content.splitlines(), start=1): for category, patterns in _SUSPICIOUS_PATTERNS.items(): for pat in patterns: if pat.search(line): findings.append({ "file": str(file_path), "line": line_no, "category": category, "pattern": pat.pattern, "snippet": line.strip()[:120], }) return findings def _detect_package_type(pkg_dir: Path) -> str: """Auto-detect package ecosystem from files present.""" if (pkg_dir / "setup.py").exists() or (pkg_dir / "pyproject.toml").exists(): return "pypi" if (pkg_dir / "package.json").exists(): return "npm" if any(pkg_dir.glob("*.gemspec")): return "gem" return "unknown" def _check_install_scripts_pypi(pkg_dir: Path) -> list[dict[str, Any]]: """Check Python package install-time hooks for suspicious code.""" findings: list[dict[str, Any]] = [] setup_py = pkg_dir / "setup.py" if setup_py.exists(): content = setup_py.read_text(encoding="utf-8", errors="replace") if "cmdclass" in content: findings.append({ "file": str(setup_py), "severity": "high", "finding": "Custom cmdclass detected - may run arbitrary code at install time.", }) if re.search(r"subprocess|os\.system|os\.popen", content): findings.append({ "file": str(setup_py), "severity": "high", "finding": "Shell command execution in setup.py.", }) return findings def _check_install_scripts_npm(pkg_dir: Path) -> list[dict[str, Any]]: """Check npm package install hooks.""" findings: list[dict[str, Any]] = [] pkg_json = pkg_dir / "package.json" if pkg_json.exists(): try: data = json.loads(pkg_json.read_text(encoding="utf-8")) except json.JSONDecodeError: return findings scripts = data.get("scripts", {}) for hook in ("preinstall", "install", "postinstall"): if hook in scripts: findings.append({ "file": str(pkg_json), "severity": "high", "finding": f"'{hook}' script detected: {scripts[hook][:120]}", }) return findings def _levenshtein_distance(s1: str, s2: str) -> int: """Compute Levenshtein edit distance between two strings.""" if len(s1) < len(s2): return _levenshtein_distance(s2, s1) if len(s2) == 0: return len(s1) prev_row = list(range(len(s2) + 1)) for i, c1 in enumerate(s1): curr_row = [i + 1] for j, c2 in enumerate(s2): cost = 0 if c1 == c2 else 1 curr_row.append(min( curr_row[j] + 1, prev_row[j + 1] + 1, prev_row[j] + cost, )) prev_row = curr_row return prev_row[-1] def check_typosquat(suspect_name: str, legitimate_name: str | None = None) -> dict[str, Any]: """Check if a package name is a potential typosquat of a known package.""" result: dict[str, Any] = { "suspect": suspect_name, "legitimate": legitimate_name, "is_typosquat": False, "techniques": [], } if not legitimate_name: return result distance = _levenshtein_distance( suspect_name.lower().replace("-", "").replace("_", ""), legitimate_name.lower().replace("-", "").replace("_", ""), ) if 0 < distance <= 2: result["is_typosquat"] = True result["techniques"].append(f"edit distance {distance}") # Hyphen/underscore confusion if suspect_name.replace("-", "_") == legitimate_name.replace("-", "_") and suspect_name != legitimate_name: result["is_typosquat"] = True result["techniques"].append("hyphen/underscore confusion") # Version suffix if re.match(re.escape(legitimate_name) + r"\d+$", suspect_name): result["is_typosquat"] = True result["techniques"].append("version suffix") return result def analyze_package( pkg_dir: Path, pkg_type: str = "auto", deep_scan: bool = False, ) -> dict[str, Any]: """Perform full analysis of a package directory.""" if pkg_type == "auto": pkg_type = _detect_package_type(pkg_dir) result: dict[str, Any] = { "package_path": str(pkg_dir), "detected_type": pkg_type, "suspicious_patterns": [], "install_script_findings": [], "file_count": 0, } # Scan all source files extensions = { "pypi": ("*.py",), "npm": ("*.js", "*.ts", "*.mjs", "*.cjs"), "gem": ("*.rb",), "unknown": ("*.py", "*.js", "*.rb"), } globs = extensions.get(pkg_type, ("*.py", "*.js")) scanned_files = 0 for glob_pattern in globs: for fpath in pkg_dir.rglob(glob_pattern): findings = _scan_file(fpath) result["suspicious_patterns"].extend(findings) scanned_files += 1 result["file_count"] = scanned_files # Check install scripts if pkg_type == "pypi": result["install_script_findings"] = _check_install_scripts_pypi(pkg_dir) elif pkg_type == "npm": result["install_script_findings"] = _check_install_scripts_npm(pkg_dir) # Risk rating total_findings = len(result["suspicious_patterns"]) + len(result["install_script_findings"]) if total_findings >= 10: result["risk_level"] = "high" elif total_findings >= 3: result["risk_level"] = "medium" elif total_findings > 0: result["risk_level"] = "low" else: result["risk_level"] = "none" return result def analyze(input_path: Path, output_format: str = "json") -> dict[str, Any]: """Analyze a package directory and return results.""" return analyze_package(input_path) def main() -> None: parser = argparse.ArgumentParser( description="Analyze packages for supply chain compromise indicators." ) parser.add_argument("--input", type=Path, help="Path to package directory") parser.add_argument("--package", type=Path, help="Path to package directory") parser.add_argument("--type", default="auto", choices=["auto", "pypi", "npm", "gem"], help="Package ecosystem type (default: auto)") parser.add_argument("--deep-scan", action="store_true", help="Perform deeper analysis including deobfuscation attempts") parser.add_argument("--check-typosquat", nargs="+", metavar="NAME", help="Check if a name is a typosquat (SUSPECT [LEGITIMATE])") parser.add_argument("--check-dependency-confusion", type=Path, help="Check package for dependency confusion risks") parser.add_argument("--analyze-install-scripts", action="store_true", help="Focus analysis on install-time scripts") parser.add_argument("--output", type=Path, help="Path to output file (stdout if omitted)") parser.add_argument("--format", default="json", choices=["json", "text", "csv"], help="Output format (default: json)") args = parser.parse_args() result: dict[str, Any] = {} if args.check_typosquat: suspect = args.check_typosquat[0] legit = args.check_typosquat[1] if len(args.check_typosquat) > 1 else None result = check_typosquat(suspect, legit) else: pkg_dir = args.package or args.input or args.check_dependency_confusion if not pkg_dir: parser.error("Provide a package directory via --package or --input") if not pkg_dir.exists(): print(f"[error] directory not found: {pkg_dir}", file=sys.stderr) sys.exit(1) result = analyze_package(pkg_dir, pkg_type=args.type, deep_scan=args.deep_scan) rendered = json.dumps(result, indent=2) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(rendered, encoding="utf-8") print(f"[+] Analysis written to {args.output}", file=sys.stderr) else: print(rendered) if __name__ == "__main__": main()