main
py 133 lines 5.47 KB
Raw
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())