| 1 | #!/usr/bin/env python3 |
| 2 | """Conservative repository credential scan with value-safe output. |
| 3 | |
| 4 | The scanner never prints a matched value. Findings still require human review; |
| 5 | it is intended as a dependency-free pre-commit/CI guard, not a replacement for |
| 6 | a dedicated entropy-aware scanner. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import re |
| 12 | from collections import Counter |
| 13 | from pathlib import Path |
| 14 | |
| 15 | |
| 16 | ROOT = Path(__file__).resolve().parents[1] |
| 17 | SKIP_PARTS = { |
| 18 | ".git", ".codex", ".idea", ".terraform", ".next", "node_modules", |
| 19 | "target", "__pycache__", "running-broker-jar-inspect", ".tmp", |
| 20 | } |
| 21 | TEXT_SUFFIXES = { |
| 22 | ".env", ".ini", ".java", ".json", ".md", ".properties", ".ps1", |
| 23 | ".py", ".tf", ".toml", ".ts", ".tsx", ".xml", ".yaml", ".yml", |
| 24 | } |
| 25 | SENSITIVE_KEY = ( |
| 26 | r"password|passwd|api[_-]?key|apikey|secret|token|authorization|" |
| 27 | r"connection[_-]?string|connectionstring|shared[_-]?access[_-]?key" |
| 28 | ) |
| 29 | CONFIG_ASSIGNMENT = re.compile( |
| 30 | rf"(?i)^\s*[\"']?(?P<key>[a-z0-9_.-]*(?:{SENSITIVE_KEY})[a-z0-9_.-]*)[\"']?" |
| 31 | rf"\s*[:=]\s*[\"']?(?P<value>[^\s#,\"']*)" |
| 32 | ) |
| 33 | CODE_LITERAL = re.compile( |
| 34 | rf"(?i)\b(?P<key>[a-z0-9_]*(?:{SENSITIVE_KEY})[a-z0-9_]*)" |
| 35 | rf"\s*=\s*[\"'](?P<value>[^\"']+)[\"']" |
| 36 | ) |
| 37 | BEARER_LITERAL = re.compile(r"(?i)\bBearer\s+(?P<value>[A-Za-z0-9._~+/=-]{12,})") |
| 38 | CONFIG_SUFFIXES = {".env", ".ini", ".json", ".properties", ".tf", ".toml", ".yaml", ".yml"} |
| 39 | HIGH_CONFIDENCE = [ |
| 40 | ("PRIVATE_KEY", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")), |
| 41 | ("AZURE_STORAGE_KEY", re.compile(r"(?i)(?:AccountKey|SharedAccessKey)=")), |
| 42 | ("AWS_ACCESS_KEY", re.compile(r"AKIA[0-9A-Z]{16}")), |
| 43 | ("GITHUB_TOKEN", re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}")), |
| 44 | ("GOOGLE_API_KEY", re.compile(r"AIza[0-9A-Za-z_-]{30,}")), |
| 45 | ("OPENAI_STYLE_KEY", re.compile(r"sk-[A-Za-z0-9]{20,}")), |
| 46 | ] |
| 47 | PLACEHOLDER_MARKERS = ( |
| 48 | "${", "{{", "replace", "placeholder", "example", "invalid", "change-me", |
| 49 | "changeme", "dummy", "redacted", "your-", "dev-", "test-", "<", |
| 50 | ) |
| 51 | |
| 52 | |
| 53 | def classification(path: Path, key: str, value: str) -> str: |
| 54 | normalized = value.lower() |
| 55 | normalized_key = re.sub(r"[^a-z0-9]", "", key.lower()) |
| 56 | if "test" in path.parts or "tests" in path.parts or "fixture" in path.parts: |
| 57 | return "TEST_FIXTURE" |
| 58 | if ( |
| 59 | normalized in {"true", "false", "null", "[]", "{}", "{", "|", ">"} |
| 60 | or normalized_key.endswith(("ttl", "ttlseconds", "tokenfile")) |
| 61 | or normalized_key in { |
| 62 | "enabledbacauthorization", "enablerbacauthorization", |
| 63 | "secretrotationenabled", "imagepullsecrets", "secrets", |
| 64 | "secretobjects", "automountserviceaccounttoken", |
| 65 | } |
| 66 | ): |
| 67 | return "FALSE_POSITIVE" |
| 68 | if path.name in {"package.json", "package-lock.json"} and normalized_key.endswith("tokens"): |
| 69 | return "FALSE_POSITIVE" |
| 70 | if ( |
| 71 | normalized_key.endswith(("secretname", "secretkey", "secretkeyname")) |
| 72 | or normalized_key.endswith("secretkeyenvironmentvariable") |
| 73 | or normalized_key in {"existingsecret", "secretproviderclassname"} |
| 74 | or re.fullmatch(r"\$\{[A-Z][A-Z0-9_]*(?::[^}]*)?}", value) |
| 75 | ): |
| 76 | return "SECRET_REFERENCE" |
| 77 | if not value or any(marker in normalized for marker in PLACEHOLDER_MARKERS): |
| 78 | return "PLACEHOLDER" |
| 79 | if path.suffix.lower() == ".md": |
| 80 | return "DOCUMENTATION_EXAMPLE" |
| 81 | return "REVIEW_REQUIRED" |
| 82 | |
| 83 | |
| 84 | def files(): |
| 85 | for path in ROOT.rglob("*"): |
| 86 | if not path.is_file() or any( |
| 87 | part in SKIP_PARTS or part == "site-packages" or part.startswith(".venv") |
| 88 | for part in path.parts |
| 89 | ): |
| 90 | continue |
| 91 | if path.name.startswith(".env") or path.suffix.lower() in TEXT_SUFFIXES: |
| 92 | if path.stat().st_size <= 2_000_000: |
| 93 | yield path |
| 94 | |
| 95 | |
| 96 | def main() -> int: |
| 97 | findings: set[tuple[str, int, str, str]] = set() |
| 98 | for path in files(): |
| 99 | relative = path.relative_to(ROOT).as_posix() |
| 100 | text = path.read_text(encoding="utf-8", errors="ignore") |
| 101 | for line_number, line in enumerate(text.splitlines(), 1): |
| 102 | for name, pattern in HIGH_CONFIDENCE: |
| 103 | if pattern.search(line): |
| 104 | findings.add((relative, line_number, name, "REVIEW_REQUIRED")) |
| 105 | for match in BEARER_LITERAL.finditer(line): |
| 106 | findings.add( |
| 107 | ( |
| 108 | relative, |
| 109 | line_number, |
| 110 | "BEARER_TOKEN", |
| 111 | classification(path.relative_to(ROOT), "authorization", match.group("value")), |
| 112 | ) |
| 113 | ) |
| 114 | matcher = CONFIG_ASSIGNMENT if (path.name.startswith(".env") or path.suffix.lower() in CONFIG_SUFFIXES) else CODE_LITERAL |
| 115 | for match in matcher.finditer(line): |
| 116 | key = match.group("key").upper().replace("-", "_") |
| 117 | value = match.group("value") |
| 118 | findings.add((relative, line_number, key, classification(path.relative_to(ROOT), key, value))) |
| 119 | |
| 120 | categories = Counter(finding[3] for finding in findings) |
| 121 | print(f"secret_scan_findings={len(findings)}") |
| 122 | for category in ( |
| 123 | "REVIEW_REQUIRED", "PLACEHOLDER", "SECRET_REFERENCE", "TEST_FIXTURE", |
| 124 | "DOCUMENTATION_EXAMPLE", "FALSE_POSITIVE", |
| 125 | ): |
| 126 | print(f"secret_scan_{category.lower()}={categories[category]}") |
| 127 | for relative, line_number, key, category in sorted(findings): |
| 128 | print(f"{relative}:{line_number} key={key} classification={category} value=<redacted>") |
| 129 | return 1 if categories["REVIEW_REQUIRED"] else 0 |
| 130 | |
| 131 | |
| 132 | if __name__ == "__main__": |
| 133 | raise SystemExit(main()) |