attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/scanner.py ADDED
@@ -0,0 +1,1379 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import math
6
+ import re
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .progress import ScanProgress
12
+
13
+ from .srcpaths import JS_TS_SUFFIXES, in_skipped_dir, is_skipped_dir, line_number
14
+ from .redact import mask_secret
15
+ from .safe_fs import is_oversized, max_file_bytes, walk_repo
16
+ from .anomalies import find_anomalies
17
+ from .authz import analyze_authz
18
+ from .crypto import find_crypto_weaknesses
19
+ from .sbom import analyze_sbom
20
+ from .supply_chain import scan_supply_chain
21
+ from .workflow_scanner import scan_workflows
22
+ from .srcpaths import is_test_file, is_vendored_file
23
+ from .mass_assignment import analyze_mass_assignment
24
+ from .weaknesses import find_code_weaknesses
25
+ from .auth_protocol import _GO_JWT, _JS_JWT_DEFAULT, _JS_JWT_NAMED, find_jwt_issues, find_oauth_session_issues
26
+ from .webhardening import find_jvm_csrf_disabled, find_web_hardening_issues, has_csrf_protection
27
+ from .sdk.models import AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
28
+ from .taint import DEFAULT_RECALL, analyze_taint, recall_config
29
+
30
+ # Scanner responsibilities are intentionally generic-only:
31
+ # - file walking and suffix filtering
32
+ # - route extraction
33
+ # - external-call extraction
34
+ # - datastore/auth/secret hint extraction
35
+ # Ecosystem overlays (for example node-service and atproto service/protocol hints)
36
+ # must be emitted by specialized analyzers, not by this module.
37
+
38
+ CODE_EXTENSIONS = {
39
+ ".py": "python",
40
+ ".js": "javascript",
41
+ ".jsx": "javascript",
42
+ ".mjs": "javascript",
43
+ ".cjs": "javascript",
44
+ ".ts": "typescript",
45
+ ".tsx": "typescript",
46
+ ".go": "go",
47
+ ".php": "php",
48
+ }
49
+
50
+ # PHP route registrations (#103):
51
+ # Laravel: Route::get('/x', ...) / $router->get('/x', ...)
52
+ # Slim: $app->get('/x', ...)
53
+ # Symfony: #[Route('/x', ...)] / @Route("/x", ...)
54
+ # Leading-slash gated, same discipline as #99.
55
+ PHP_ROUTE_PATTERN = re.compile(
56
+ r"(?:\bRoute::|\$\w+->)(get|post|put|delete|patch|options|head|any)\s*\(\s*['\"](/[^'\"]*)['\"]",
57
+ re.IGNORECASE,
58
+ )
59
+ PHP_SYMFONY_ROUTE_PATTERN = re.compile(
60
+ r"(?:#\[\s*Route|@Route)\s*\(\s*['\"](/[^'\"]*)['\"]",
61
+ )
62
+ _PHP_ANY_METHODS = {"any"}
63
+
64
+ # Go web-router registrations across the common frameworks:
65
+ # net/http: mux.HandleFunc("/x", h) / http.Handle("/x", h)
66
+ # gin/echo: r.GET("/x", h) (uppercase verbs)
67
+ # chi/fiber: r.Get("/x", h) (Title-case verbs)
68
+ # Gated on a leading-slash path so `cache.Get("key")` / `http.Get(url)` (an
69
+ # outbound call, not a route) don't match — same discipline as #99.
70
+ GO_ROUTE_PATTERN = re.compile(
71
+ r"\b\w+\.(get|post|put|delete|patch|head|options|connect|trace|any|all|handle|handlefunc)"
72
+ r"\s*\(\s*\"(/[^\"]*)\"",
73
+ re.IGNORECASE,
74
+ )
75
+ _GO_ANY_METHODS = {"any", "all", "handle", "handlefunc"}
76
+
77
+ # (?<!\w) + possessive \w++: never restart mid-word, so a long word can't
78
+ # trigger quadratic backtracking (#236).
79
+ FASTAPI_ROUTER_PATTERN = re.compile(
80
+ r"(?<!\w)(\w++)\s*+=\s*+APIRouter\(\s*(?:[^)]*?\bprefix\s*=\s*['\"]([^'\"]*)['\"])?",
81
+ re.IGNORECASE | re.DOTALL,
82
+ )
83
+ FASTAPI_INCLUDE_ROUTER_PATTERN = re.compile(
84
+ r"(?<!\w)(\w++)\.include_router\(\s*(\w+)(?:\s*,\s*prefix\s*=\s*['\"]([^'\"]*)['\"])?",
85
+ re.IGNORECASE,
86
+ )
87
+ FASTAPI_DECORATOR_PATTERN = re.compile(
88
+ r"@(\w+)\.(get|post|put|delete|patch|options|head)\(\s*['\"]([^'\"]+)['\"]",
89
+ re.IGNORECASE,
90
+ )
91
+ FASTAPI_API_ROUTE_PATTERN = re.compile(
92
+ r"@(\w+)\.api_route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
93
+ re.IGNORECASE | re.DOTALL,
94
+ )
95
+ FLASK_BLUEPRINT_PATTERN = re.compile(
96
+ r"(?<!\w)(\w++)\s*+=\s*+Blueprint\(\s*['\"][^'\"]+['\"]\s*,\s*[^,]+(?:,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
97
+ re.IGNORECASE | re.DOTALL,
98
+ )
99
+ FLASK_REGISTER_BLUEPRINT_PATTERN = re.compile(
100
+ r"(?<!\w)(\w++)\.register_blueprint\(\s*(\w+)(?:\s*,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
101
+ re.IGNORECASE,
102
+ )
103
+ FLASK_ROUTE_PATTERN = re.compile(
104
+ r"@(\w+)\.route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
105
+ re.IGNORECASE | re.DOTALL,
106
+ )
107
+ # aiohttp's `RouteTableDef.route(method, path)` takes the verb first:
108
+ # `@routes.route("PATCH", "/users/{id}")`. Matched against the stripped
109
+ # arguments that follow the first string in FLASK_ROUTE_PATTERN (#342).
110
+ ROUTE_VERB_FIRST_ARGS_PATTERN = re.compile(r",\s*+['\"]([^'\"]+)['\"]")
111
+ _ROUTE_VERBS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"})
112
+ EXPRESS_DIRECT_ROUTE_PATTERN = re.compile(
113
+ r"\b(\w+)\.(get|post|put|delete|patch|options|head|all)\(\s*['\"]([^'\"]+)['\"]",
114
+ re.IGNORECASE,
115
+ )
116
+ EXPRESS_CHAIN_ROUTE_PATTERN = re.compile(
117
+ r"\b(\w+)\.route\(\s*['\"]([^'\"]+)['\"]\s*\)(?P<chain>[\s\S]*?)(?=(?:\n\s*\w+\.)|\Z)",
118
+ re.IGNORECASE,
119
+ )
120
+ EXPRESS_USE_PATTERN = re.compile(
121
+ r"\b(\w+)\.use\(\s*['\"]([^'\"]+)['\"]\s*,\s*(\w+)\s*\)",
122
+ re.IGNORECASE,
123
+ )
124
+ METHOD_LIST_PATTERN = re.compile(r"['\"](GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|ALL)['\"]", re.IGNORECASE)
125
+
126
+ # Named groups: `method` (the verb) and `url`. `requests`/`axios` carry the verb
127
+ # in the call syntax; for `fetch` the verb lives in an optional init object
128
+ # (`fetch(url, {method: "POST"})`), captured as `fmethod` when present — a bare
129
+ # `fetch(url)` is a GET by the fetch spec. The method feeds cross-repo contract
130
+ # linking (#146b).
131
+ EXTERNAL_CALL_PATTERNS = [
132
+ re.compile(r"requests\.(?P<method>get|post|put|delete|patch)\(['\"](?P<url>[^'\"]+)['\"]"),
133
+ re.compile(r"axios\.(?P<method>get|post|put|delete|patch)\(['\"](?P<url>[^'\"]+)['\"]"),
134
+ re.compile(
135
+ r"fetch\(['\"](?P<url>[^'\"]+)['\"]"
136
+ r"(?:\s*,\s*\{[^}]*?method\s*:\s*['\"](?P<fmethod>[A-Za-z]+)['\"])?"
137
+ ),
138
+ ]
139
+
140
+
141
+ def _external_call_fields(match: "re.Match[str]") -> tuple[str, str | None]:
142
+ """Extract (url, upper-cased method) from an EXTERNAL_CALL match.
143
+
144
+ `requests`/`axios` supply the verb directly. A `fetch` call resolves to its
145
+ init `method` when present, else GET (the fetch default) — never the
146
+ match-anything `None`, which would fan one call out to every route verb.
147
+ `None` is reserved for calls whose method is genuinely unknown (e.g. config
148
+ file URLs) and should match any verb.
149
+ """
150
+ groups = match.groupdict()
151
+ if groups.get("method"):
152
+ return groups["url"], groups["method"].upper()
153
+ if match.group(0).lstrip().startswith("fetch"):
154
+ fmethod = groups.get("fmethod")
155
+ return groups["url"], (fmethod.upper() if fmethod else "GET")
156
+ return groups["url"], None
157
+
158
+ DB_KEYWORDS = {
159
+ "postgres": "postgresql",
160
+ "psycopg": "postgresql",
161
+ "sqlalchemy": "sql",
162
+ "mongodb": "mongodb",
163
+ "mongo": "mongodb",
164
+ "redis": "redis",
165
+ "sqlite": "sqlite",
166
+ "mysql": "mysql",
167
+ }
168
+
169
+ DB_PATTERNS = [
170
+ # SQL — SQLAlchemy / raw / Django / Prisma / Drizzle
171
+ (re.compile(r"create_engine\(\s*['\"](?:postgresql|mysql|sqlite|mssql|oracle)\+?", re.IGNORECASE), "sql"),
172
+ (re.compile(r"create_async_engine\(", re.IGNORECASE), "sql"),
173
+ (re.compile(r"\bsessionmaker\s*\(", re.IGNORECASE), "sql"),
174
+ (re.compile(r"\bAsyncSession\s*\(", re.IGNORECASE), "sql"),
175
+ (re.compile(r"\bdeclarative_base\s*\(", re.IGNORECASE), "sql"),
176
+ (re.compile(r"new\s+PrismaClient\(", re.IGNORECASE), "sql"),
177
+ (re.compile(r"\bdrizzle\s*\(", re.IGNORECASE), "sql"),
178
+ (re.compile(r"\bknex\s*\(", re.IGNORECASE), "sql"),
179
+ # Raw SQL usage — cursor.execute with a SQL verb literal is a strong
180
+ # signal that raw SQL (not an ORM) is in play, which matters for
181
+ # injection review. #2.
182
+ (re.compile(r"\.execute\(\s*['\"]\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)\b", re.IGNORECASE), "sql"),
183
+ (re.compile(r"\.executemany\(\s*['\"]", re.IGNORECASE), "sql"),
184
+ # PostgreSQL
185
+ (re.compile(r"psycopg(?:2|3)?\.connect\(", re.IGNORECASE), "postgresql"),
186
+ (re.compile(r"\basyncpg\.connect\(", re.IGNORECASE), "postgresql"),
187
+ (re.compile(r"new\s+Pool\(", re.IGNORECASE), "postgresql"),
188
+ # SQLite
189
+ (re.compile(r"sqlite3\.connect\(", re.IGNORECASE), "sqlite"),
190
+ (re.compile(r"\bbetter-sqlite3\b|new\s+SQLite3?\b", re.IGNORECASE), "sqlite"),
191
+ # MySQL
192
+ (re.compile(r"\bmysql\.connector\.connect\(", re.IGNORECASE), "mysql"),
193
+ (re.compile(r"\bMySQLdb\.connect\(", re.IGNORECASE), "mysql"),
194
+ (re.compile(r"\baiomysql\.connect\(", re.IGNORECASE), "mysql"),
195
+ # MongoDB
196
+ (re.compile(r"(?:AsyncIOMotorClient|MongoClient)\(", re.IGNORECASE), "mongodb"),
197
+ (re.compile(r"\bpymongo\.MongoClient\(", re.IGNORECASE), "mongodb"),
198
+ (re.compile(r"mongoose\.connect\(", re.IGNORECASE), "mongodb"),
199
+ # Redis
200
+ (re.compile(r"redis\.(?:Redis|StrictRedis)\(", re.IGNORECASE), "redis"),
201
+ (re.compile(r"\bredis\.from_url\(", re.IGNORECASE), "redis"),
202
+ (re.compile(r"\baioredis\b|from\s+redis\s+import", re.IGNORECASE), "redis"),
203
+ (re.compile(r"redis\.createClient\(", re.IGNORECASE), "redis"),
204
+ ]
205
+
206
+ # Kept intentionally short — noisy naked words (`session`, `password`,
207
+ # `token`, `mfa`, `bearer`) were dropped in #2. They fired across
208
+ # unrelated code and turned the file-level auth-signal set into
209
+ # indistinguishable noise (see Bluesky FINDINGS.md §55). Specific
210
+ # compound patterns in AUTH_PATTERNS below carry the real signal. `jwt` went
211
+ # the same way in #325: an import, a comment or a `jwtSecret` name isn't a check.
212
+ AUTH_KEYWORDS = [
213
+ "oauth",
214
+ "auth0",
215
+ "apikey",
216
+ "api_key",
217
+ ]
218
+
219
+ AUTH_PATTERNS = [
220
+ # Decorator-based
221
+ (re.compile(r"@login_required\b", re.IGNORECASE), "login_required"),
222
+ (re.compile(r"@jwt_required\b", re.IGNORECASE), "jwt"),
223
+ (re.compile(r"@UseGuards\("), "auth_guard"),
224
+ # JWT verification only (#325). `jwt.decode` (jsonwebtoken), `jwt.sign` /
225
+ # `encode` and the library name don't authenticate a caller. An unpinned
226
+ # `jwt.verify(` still checks a signature (its missing `algorithms` is
227
+ # `jwt-alg-confusion`); a pinned one, jose `jwtVerify`, golang-jwt and
228
+ # PyJWT `decode(..., algorithms=…)` come from auth_protocol as `jwt_verified`.
229
+ (re.compile(r"\bjwt\.verify\(", re.IGNORECASE), "jwt"),
230
+ # Calls and middleware that verify: jose / @fastify/jwt `jwtVerify()`,
231
+ # firebase/php-jwt `JWT::decode` (it always checks the signature),
232
+ # golang-jwt `Parse`/`ParseWithClaims` (not `ParseUnverified`), express-jwt /
233
+ # koa-jwt, flask-jwt-extended, Tuupola JwtAuthentication, NestJS
234
+ # JwtAuthGuard, passport-jwt JwtStrategy, DRF simplejwt JWTAuthentication.
235
+ (re.compile(r"\bjwtVerify\s*\(|\bJWT::decode\s*\(|\bjwt\.Parse(?:WithClaims)?\s*\("), "jwt"),
236
+ (re.compile(r"\b(?:express|koa)jwt\s*\(|\bverify_jwt_in_request\s*\(", re.IGNORECASE), "jwt"),
237
+ (re.compile(r"\b(?:Jwt|JSONWebToken)(?:Authentication|AuthGuard|Strategy)\b", re.IGNORECASE), "jwt"),
238
+ # echo-jwt / echo's JWT middleware, tymon/jwt-auth's verifying calls.
239
+ (re.compile(r"\bechojwt\.(?:JWT|WithConfig)\s*\(|\bmiddleware\.JWT(?:WithConfig)?\s*\("), "jwt"),
240
+ (re.compile(r"\bJWTAuth::(?:parseToken|authenticate|toUser|getPayload|check)\b"), "jwt"),
241
+ # OAuth / OIDC library forms
242
+ (re.compile(r"OAuth2PasswordBearer\(", re.IGNORECASE), "oauth"),
243
+ (re.compile(r"\bauthlib\.\w+", re.IGNORECASE), "oauth"),
244
+ # FastAPI Depends-based auth
245
+ (re.compile(r"Depends\(\s*(?:oauth2_scheme|get_current_user|current_user|verify_token)\s*\)", re.IGNORECASE), "depends_auth"),
246
+ # Authorization header access — Python, JS, Node
247
+ (re.compile(r"request\.authorization\b", re.IGNORECASE), "authorization"),
248
+ (re.compile(r"request\.headers\.get\(\s*['\"]authorization['\"]", re.IGNORECASE), "authorization"),
249
+ (re.compile(r"req\.headers\[\s*['\"]authorization['\"]", re.IGNORECASE), "authorization"),
250
+ (re.compile(r"req\.get\(\s*['\"]authorization['\"]", re.IGNORECASE), "authorization"),
251
+ (re.compile(r"Authorization['\"]?\s*\]", re.IGNORECASE), "authorization"),
252
+ # Bearer-token access (context, not naked word)
253
+ (re.compile(r"\bBearer\s+\$\{?[a-z_]"), "bearer_token"),
254
+ (re.compile(r"['\"]Bearer\s"), "bearer_token"),
255
+ # Middleware / guard naming conventions
256
+ (re.compile(r"passport\.authenticate\(", re.IGNORECASE), "passport"),
257
+ (re.compile(r"\bpassport\.use\(", re.IGNORECASE), "passport"),
258
+ (re.compile(r"\b(?:verify|require)Token\s*\(", re.IGNORECASE), "verify_token"),
259
+ (re.compile(r"\bisAuthenticated\s*\(", re.IGNORECASE), "auth_middleware"),
260
+ (re.compile(r"\bensureLoggedIn\s*\(", re.IGNORECASE), "auth_middleware"),
261
+ (re.compile(r"\brequire(?:Auth|Login)\s*\(", re.IGNORECASE), "auth_middleware"),
262
+ (re.compile(r"\bwithAuth\s*[(<]", re.IGNORECASE), "auth_middleware"),
263
+ (re.compile(r"\bauthMiddleware\b", re.IGNORECASE), "auth_middleware"),
264
+ (re.compile(r"\bauthGuard\b|\bAuthGuard\b"), "auth_guard"),
265
+ # Custom middleware factories / guards (#100): CamelCase names ending in
266
+ # Auth/Guard called as a function — e.g. webhookAuth({secret}), apiKeyAuth(),
267
+ # tenantGuard(). Case-sensitive so `Author(`/`Guardian(` don't match.
268
+ (re.compile(r"\b\w+(?:Auth|Guard)\s*\("), "auth_middleware"),
269
+ # Auth middleware installed globally: app.use(requireAuth) / router.use(authz).
270
+ (re.compile(r"\.use\(\s*[\w$.]*(?:[Aa]uth|[Gg]uard|passport)", re.IGNORECASE), "auth_middleware"),
271
+ # Auth-ish guard passed as a middleware ARGUMENT in a route registration:
272
+ # router.get('/x', requireAuth, handler) — the guard is a bare reference.
273
+ (
274
+ re.compile(
275
+ r"\.(?:get|post|put|delete|patch|all)\(\s*['\"][^'\"]*['\"]\s*,[^)\n]*"
276
+ r"\b\w*(?:[Aa]uth|[Gg]uard|authenticate|authorize)\w*\b",
277
+ re.IGNORECASE,
278
+ ),
279
+ "auth_middleware",
280
+ ),
281
+ # Session-based auth — specific compound tokens, not the bare word "session"
282
+ (re.compile(r"\bexpress-session\b|require\(['\"]express-session['\"]"), "session_middleware"),
283
+ (re.compile(r"\breq\.session\.\w"), "session_state"),
284
+ (re.compile(r"\brequest\.session\.\w"), "session_state"),
285
+ (re.compile(r"\bcookie-session\b"), "session_middleware"),
286
+ (re.compile(r"\bflask_login\b|from\s+flask_login\s+import"), "session_middleware"),
287
+ (re.compile(r"\bdjango\.contrib\.auth\b"), "session_middleware"),
288
+ # MFA / 2FA — compound tokens only
289
+ (re.compile(r"\bmfa_required\b|\btwo_factor\b|\btotp_verify\b", re.IGNORECASE), "mfa"),
290
+ # Password-handling — compound tokens only
291
+ (re.compile(r"\bpassword_hash\b|\bpassword_reset\b|\bcheck_password\b", re.IGNORECASE), "password_flow"),
292
+ (re.compile(r"\bbcrypt\.(?:hash|compare)\s*\(", re.IGNORECASE), "password_flow"),
293
+ (re.compile(r"\bargon2\b"), "password_flow"),
294
+ ]
295
+
296
+ SECRET_PATTERNS = [
297
+ re.compile(r"os\.getenv\(['\"]([^'\"]*(SECRET|TOKEN|KEY|PASSWORD)[^'\"]*)['\"]", re.IGNORECASE),
298
+ re.compile(r"process\.env\.([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)", re.IGNORECASE),
299
+ ]
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Hard-coded secret literals (#39). Each entry is (regex, kind).
303
+ # The regex captures the FULL secret in group 0 or its main group; the
304
+ # `kind` becomes SecretHint.kind and the finder reads it as the display
305
+ # name. Values are redacted before being included in evidence_text — the
306
+ # raw secret must never appear in reports.
307
+ # ---------------------------------------------------------------------------
308
+
309
+ HARDCODED_SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
310
+ # AWS
311
+ (re.compile(r"\b(AKIA[0-9A-Z]{16})\b"), "aws_access_key"),
312
+ (re.compile(r"\b(ASIA[0-9A-Z]{16})\b"), "aws_temporary_key"),
313
+ # GitHub — classic PATs and the newer prefixes (fine-grained tokens,
314
+ # OAuth apps, server tokens, refresh tokens).
315
+ (re.compile(r"\b(ghp_[A-Za-z0-9]{36,})\b"), "github_pat"),
316
+ (re.compile(r"\b(gho_[A-Za-z0-9]{36,})\b"), "github_oauth_token"),
317
+ (re.compile(r"\b(ghu_[A-Za-z0-9]{36,})\b"), "github_user_token"),
318
+ (re.compile(r"\b(ghs_[A-Za-z0-9]{36,})\b"), "github_server_token"),
319
+ (re.compile(r"\b(ghr_[A-Za-z0-9]{36,})\b"), "github_refresh_token"),
320
+ # Slack bot / user / app tokens
321
+ (re.compile(r"\b(xox[bpsare]-[0-9A-Za-z-]{10,})\b"), "slack_token"),
322
+ # Stripe live / test keys
323
+ (re.compile(r"\b((?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,})\b"), "stripe_key"),
324
+ # SendGrid
325
+ (re.compile(r"\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b"), "sendgrid_key"),
326
+ # Mailgun
327
+ (re.compile(r"\b(key-[a-f0-9]{32})\b"), "mailgun_key"),
328
+ # Twilio account SID — AC followed by 32 hex. Narrow enough to avoid
329
+ # false-positives on random 34-char strings starting with AC.
330
+ (re.compile(r"\b(AC[a-f0-9]{32})\b"), "twilio_account_sid"),
331
+ # Google API key — most are 35 chars after AIza; some Cloud variants
332
+ # run slightly longer.
333
+ (re.compile(r"\b(AIza[A-Za-z0-9_-]{35,42})\b"), "google_api_key"),
334
+ # Anthropic API key (long, characteristic prefix)
335
+ (re.compile(r"\b(sk-ant-[A-Za-z0-9_-]{40,})\b"), "anthropic_key"),
336
+ # OpenAI API keys, one pattern per documented shape so each enforces
337
+ # its own length/charset (a loose `sk-…{20,}` would classify hyphenated
338
+ # placeholders like `sk-this-is-a-placeholder` as a live credential).
339
+ # - project keys: `sk-proj-<token>`
340
+ # - service-account keys: `sk-svcacct-<token>`
341
+ # - legacy keys: `sk-<48 alphanumerics>` (no `-`/`_`, which
342
+ # also keeps Anthropic's hyphenated `sk-ant-…` on its own kind).
343
+ (re.compile(r"\b(sk-proj-[A-Za-z0-9_-]{20,})\b"), "openai_key"),
344
+ (re.compile(r"\b(sk-svcacct-[A-Za-z0-9_-]{20,})\b"), "openai_key"),
345
+ (re.compile(r"\b(sk-[A-Za-z0-9]{48,})\b"), "openai_key"),
346
+ # GitLab personal / project / group access tokens.
347
+ (re.compile(r"\b(glpat-[A-Za-z0-9_-]{20,})\b"), "gitlab_pat"),
348
+ # npm automation / publish tokens.
349
+ (re.compile(r"\b(npm_[A-Za-z0-9]{36})\b"), "npm_token"),
350
+ # PEM private-key block — match `-----BEGIN [<algo> ]PRIVATE KEY-----`,
351
+ # covering both bare `-----BEGIN PRIVATE KEY-----` and prefixed forms
352
+ # (`RSA`, `EC`, `DSA`, `OPENSSH`, `ENCRYPTED`). Capture just the header
353
+ # line as evidence; never emit key material.
354
+ (re.compile(r"(-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----)"), "pem_private_key"),
355
+ # JWT — three base64url segments separated by dots, first decoding
356
+ # to a JSON header. Recognized by shape here; verified for shape
357
+ # (base64url + non-trivial length) in the extractor below.
358
+ (re.compile(r"\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b"), "jwt"),
359
+ # --- #252: credential families without a classic prefix -------------
360
+ # AWS secret access key: 40 chars, only trusted next to its key name.
361
+ (re.compile(r"(?i)aws_?secret_?(?:access_?)?key['\"]?\s{0,5}(?::=|=>|[:=])\s{0,5}['\"]?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])"), "aws_secret_access_key"),
362
+ # Azure storage / Service Bus connection-string keys.
363
+ (re.compile(r"(?i)\bAccountKey=([A-Za-z0-9+/]{40,100}={0,2})"), "azure_storage_account_key"),
364
+ (re.compile(r"(?i)\bSharedAccessKey=([A-Za-z0-9+/]{40,64}={0,2})"), "azure_shared_access_key"),
365
+ # Azure AD (Entra) client secret: 3 chars, a digit, `Q~`, 31-34 chars.
366
+ (re.compile(r"(?<![A-Za-z0-9_~.-])([A-Za-z0-9_~.]{3}\dQ~[A-Za-z0-9_~.-]{31,34})(?![A-Za-z0-9_~.-])"), "azure_ad_client_secret"),
367
+ # Datadog API / application keys (hex, keyed by name).
368
+ (re.compile(r"(?i)\b(?:dd|datadog)_?(?:api|app(?:lication)?)_?key['\"]?\s{0,5}[:=]\s{0,5}['\"]?([a-f0-9]{32}(?:[a-f0-9]{8})?)\b"), "datadog_key"),
369
+ # Heroku platform API key (new format).
370
+ (re.compile(r"\b(HRKU-AA[0-9A-Za-z_-]{58})\b"), "heroku_api_key"),
371
+ # DigitalOcean personal / OAuth / refresh tokens.
372
+ (re.compile(r"\b(do[opr]_v1_[a-f0-9]{64})\b"), "digitalocean_token"),
373
+ # HashiCorp Vault service / batch tokens.
374
+ (re.compile(r"\b(hv[sb]\.[A-Za-z0-9_-]{24,})\b"), "vault_token"),
375
+ # .npmrc registry auth: `//registry.npmjs.org/:_authToken=…`, `_auth=`/`_password=`.
376
+ (re.compile(r":_authToken\s{0,5}=\s{0,5}([A-Za-z0-9._~+/=-]{8,})"), "npm_auth_token"),
377
+ (re.compile(r"(?m)^\s{0,8}(?:\S{0,200}:)?_(?:auth|password)\s{0,5}=\s{0,5}([A-Za-z0-9+/=]{8,})"), "npm_basic_auth"),
378
+ # Docker registry auth blob in `.dockercfg` / `config.json`.
379
+ (re.compile(r"\"auth\"\s{0,5}:\s{0,5}\"([A-Za-z0-9+/]{12,}={0,2})\""), "docker_registry_auth"),
380
+ ]
381
+
382
+ # Minimum length for the high-entropy fallback. Under this, entropy
383
+ # calculations aren't statistically meaningful, and shorter secrets
384
+ # tend to be caught by explicit provider patterns anyway.
385
+ _ENTROPY_MIN_LENGTH = 32
386
+ _ENTROPY_MAX_LENGTH = 256 # very long strings are usually inline assets
387
+ _ENTROPY_THRESHOLD = 4.5 # bits per character
388
+ # Extracts quoted string literals (single or double quoted) for entropy
389
+ # fallback. Kept intentionally narrow: no multi-line strings.
390
+ _QUOTED_LITERAL = re.compile(r"['\"]([A-Za-z0-9+/_=-]{32,256})['\"]")
391
+
392
+
393
+ _SNIPPET_MAX_CHARS = 160
394
+
395
+
396
+ def _line_of(content: str, offset: int) -> int:
397
+ """1-indexed line number for a character offset within content."""
398
+ return line_number(content, offset)
399
+
400
+
401
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
402
+ """Return the line containing `offset`, stripped and length-capped."""
403
+ line_start = content.rfind("\n", 0, offset) + 1
404
+ line_end = content.find("\n", offset)
405
+ if line_end == -1:
406
+ line_end = len(content)
407
+ line = content[line_start:line_end].strip()
408
+ if len(line) > max_chars:
409
+ line = line[: max_chars - 1] + "…"
410
+ return line
411
+
412
+
413
+ def _redacted_snippet(
414
+ content: str, offset: int, literal: str, *, redact: bool = True
415
+ ) -> str:
416
+ """Line snippet with the matched secret masked in place. Evidence text
417
+ is serialized into report.json, so the raw credential must never survive
418
+ into it. (Every other credential on the line is masked too, by the
419
+ evidence redaction in ``models`` — #235.)"""
420
+ snippet = _line_snippet(content, offset)
421
+ if redact and literal in snippet:
422
+ snippet = snippet.replace(literal, _redact_secret(literal))
423
+ return snippet
424
+
425
+
426
+ # Package-manager trees (pnpm, venvs) are full of symlinks; don't list those
427
+ # as scan limitations.
428
+ # Prefix of the limitation recorded for a directory skipped because of an
429
+ # output marker; the diff reads it back to classify vanished findings.
430
+ EXCLUDED_DIR_LIMITATION = "directory skipped (output marker): "
431
+ _NOISY_SYMLINK_PARTS = {"node_modules", ".git", ".venv", "venv", "dist", "build", "__pycache__"}
432
+
433
+
434
+ def should_scan(path: Path) -> bool:
435
+ """``path`` is relative to the scanned root (#215): only directories
436
+ inside the repo decide whether a file is skipped."""
437
+ if path.name.startswith("."):
438
+ return False
439
+ if in_skipped_dir(path):
440
+ return False
441
+ return path.suffix in CODE_EXTENSIONS
442
+
443
+
444
+ def should_scan_with_suffixes(path: Path, suffixes: set[str] | None = None) -> bool:
445
+ if not should_scan(path):
446
+ return False
447
+ if suffixes is None:
448
+ return True
449
+ return path.suffix in suffixes
450
+
451
+
452
+ def _normalize_path(path: str) -> str:
453
+ """Ensure path is non-empty and starts with a single forward slash.
454
+
455
+ This guarantees consistent path output across frameworks: FastAPI, Flask,
456
+ and Express can all be invoked with or without a leading ``/``, but
457
+ downstream consumers (path joining, prefix concatenation, attack-surface
458
+ display) rely on a canonical ``/foo/bar`` shape.
459
+ """
460
+ cleaned = (path or "").strip()
461
+ if not cleaned:
462
+ return "/"
463
+ if not cleaned.startswith("/"):
464
+ cleaned = f"/{cleaned}"
465
+ # Collapse runs of slashes so ``//a//b`` -> ``/a/b``.
466
+ while "//" in cleaned:
467
+ cleaned = cleaned.replace("//", "/")
468
+ return cleaned
469
+
470
+
471
+ def _join_route_parts(prefix: str, path: str) -> str:
472
+ base = _normalize_path(prefix)
473
+ suffix = _normalize_path(path)
474
+
475
+ if base == "/":
476
+ return suffix
477
+ if suffix == "/":
478
+ return base
479
+ return f"{base.rstrip('/')}/{suffix.lstrip('/')}"
480
+
481
+
482
+ def _extract_methods(args: str, default_method: str = "ANY") -> list[str]:
483
+ methods = [match.upper() for match in METHOD_LIST_PATTERN.findall(args)]
484
+ # "ALL" is a non-standard alias sometimes used in API code; normalize it
485
+ # to the canonical Route(method="ANY") sentinel.
486
+ methods = ["ANY" if m == "ALL" else m for m in methods]
487
+ return methods or [default_method]
488
+
489
+
490
+ def _python_route_prefixes(content: str) -> dict[str, str]:
491
+ local_prefixes: dict[str, str] = {}
492
+
493
+ for match in FASTAPI_ROUTER_PATTERN.finditer(content):
494
+ local_prefixes[match.group(1)] = match.group(2) or ""
495
+
496
+ for match in FLASK_BLUEPRINT_PATTERN.finditer(content):
497
+ local_prefixes[match.group(1)] = match.group(2) or ""
498
+
499
+ prefixes = dict(local_prefixes)
500
+ updated = True
501
+ while updated:
502
+ updated = False
503
+
504
+ for match in FASTAPI_INCLUDE_ROUTER_PATTERN.finditer(content):
505
+ parent_name, child_name, extra_prefix = match.groups()
506
+ parent_prefix = prefixes.get(parent_name, "")
507
+ child_prefix = local_prefixes.get(child_name, "")
508
+ combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
509
+ if prefixes.get(child_name) != combined:
510
+ prefixes[child_name] = combined
511
+ updated = True
512
+
513
+ for match in FLASK_REGISTER_BLUEPRINT_PATTERN.finditer(content):
514
+ parent_name, child_name, extra_prefix = match.groups()
515
+ parent_prefix = prefixes.get(parent_name, "")
516
+ child_prefix = local_prefixes.get(child_name, "")
517
+ combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
518
+ if prefixes.get(child_name) != combined:
519
+ prefixes[child_name] = combined
520
+ updated = True
521
+
522
+ return prefixes
523
+
524
+
525
+ def _extract_python_routes(content: str, file: str) -> list[Route]:
526
+ routes: list[Route] = []
527
+ prefixes = _python_route_prefixes(content)
528
+
529
+ for match in FASTAPI_DECORATOR_PATTERN.finditer(content):
530
+ router_name, method, route_path = match.groups()
531
+ routes.append(
532
+ Route(
533
+ path=_join_route_parts(prefixes.get(router_name, ""), route_path),
534
+ method=method.upper(),
535
+ file=file,
536
+ line=_line_of(content, match.start()),
537
+ )
538
+ )
539
+
540
+ for match in FASTAPI_API_ROUTE_PATTERN.finditer(content):
541
+ router_name, route_path, args = match.group(1), match.group(2), match.group("args")
542
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
543
+ line = _line_of(content, match.start())
544
+ for method in _extract_methods(args, default_method="ANY"):
545
+ routes.append(Route(path=full_path, method=method, file=file, line=line))
546
+
547
+ for match in FLASK_ROUTE_PATTERN.finditer(content):
548
+ router_name, route_path, args = match.group(1), match.group(2), match.group("args")
549
+ line = _line_of(content, match.start())
550
+ verb_first = ROUTE_VERB_FIRST_ARGS_PATTERN.match(args.lstrip())
551
+ if route_path.upper() in _ROUTE_VERBS and verb_first is not None:
552
+ # aiohttp `@routes.route("PATCH", "/x")`: the first string is the
553
+ # verb and the path comes second — not Flask's `GET /PATCH` (#342).
554
+ method = "ANY" if route_path == "*" else route_path.upper()
555
+ full_path = _join_route_parts(prefixes.get(router_name, ""), verb_first.group(1))
556
+ routes.append(Route(path=full_path, method=method, file=file, line=line))
557
+ continue
558
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
559
+ for method in _extract_methods(args, default_method="GET"):
560
+ routes.append(Route(path=full_path, method=method, file=file, line=line))
561
+
562
+ return routes
563
+
564
+
565
+ def _express_prefixes(content: str) -> dict[str, str]:
566
+ """Mount prefix of each router: `app.use('/api', apiRouter)` → `/api`,
567
+ following nested mounts (`api.use('/v1', v1)` → `/api/v1`).
568
+
569
+ The first mount of a router wins. A name mounted at several paths
570
+ (`app.use('/api/auth/*', csrf); app.use('/api/trpc/*', csrf)`, usually
571
+ middleware) used to make the fixpoint below flip between its mounts
572
+ forever, hanging the scan; mount cycles grew the prefix forever. Each
573
+ router now has one mount, self-mounts are skipped, and each name is
574
+ resolved once by walking its parent links (linear in the number of
575
+ mounts); the members of a mount cycle get no prefix, since no request
576
+ path reaches them from the app.
577
+ """
578
+ mounts: dict[str, tuple[str, str]] = {}
579
+ for match in EXPRESS_USE_PATTERN.finditer(content):
580
+ parent_name, mount_path, child_name = match.groups()
581
+ if child_name != parent_name:
582
+ mounts.setdefault(child_name, (parent_name, mount_path))
583
+
584
+ prefixes: dict[str, str] = {}
585
+ for start in mounts:
586
+ # Walk up to a resolved name, a root, or back into this walk (a cycle).
587
+ chain: list[str] = []
588
+ position: dict[str, int] = {}
589
+ node = start
590
+ while node in mounts and node not in prefixes:
591
+ if node in position:
592
+ for member in chain[position[node]:]:
593
+ prefixes[member] = ""
594
+ del chain[position[node]:]
595
+ break
596
+ position[node] = len(chain)
597
+ chain.append(node)
598
+ node = mounts[node][0]
599
+ for name in reversed(chain):
600
+ parent_name, mount_path = mounts[name]
601
+ prefixes[name] = _join_route_parts(prefixes.get(parent_name, ""), _join_route_parts(mount_path, ""))
602
+ return prefixes
603
+
604
+
605
+ # Receiver names that identify an Express/Koa-style app or router (so
606
+ # `x.get("s")` is a route registration, not an unrelated method call like
607
+ # `headers.get(...)` / `params.get(...)` / `map.get(...)`). Exact names plus
608
+ # the common `…Router` / `…App` suffixes.
609
+ _ROUTER_RECEIVERS = frozenset(
610
+ {"app", "application", "router", "route", "routes", "api", "apirouter",
611
+ "server", "srv", "http", "https", "express", "r", "rt"}
612
+ )
613
+
614
+
615
+ def _looks_like_route(receiver: str, raw_path: str) -> bool:
616
+ """True when `<receiver>.<verb>("<raw_path>")` is plausibly a real HTTP
617
+ route: either the path is URL-shaped (leading `/` or a `*` wildcard) or the
618
+ receiver looks like an app/router. Cuts the `xxx.get("config-key")` and
619
+ `headers.delete("content-type")` false routes (#99)."""
620
+ if raw_path.startswith("/") or raw_path == "*":
621
+ return True
622
+ lower = receiver.lower()
623
+ return lower in _ROUTER_RECEIVERS or lower.endswith("router") or lower.endswith("app")
624
+
625
+
626
+ def _extract_javascript_routes(content: str, file: str) -> list[Route]:
627
+ routes: list[Route] = []
628
+ prefixes = _express_prefixes(content)
629
+
630
+ for match in EXPRESS_DIRECT_ROUTE_PATTERN.finditer(content):
631
+ router_name, method, route_path = match.groups()
632
+ if not _looks_like_route(router_name, route_path):
633
+ continue
634
+ method_normalized = method.upper()
635
+ if method_normalized == "ALL":
636
+ method_normalized = "ANY"
637
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
638
+ routes.append(
639
+ Route(
640
+ path=full_path,
641
+ method=method_normalized,
642
+ file=file,
643
+ line=_line_of(content, match.start()),
644
+ )
645
+ )
646
+
647
+ for match in EXPRESS_CHAIN_ROUTE_PATTERN.finditer(content):
648
+ router_name, route_path, chain = match.group(1), match.group(2), match.group("chain")
649
+ if not _looks_like_route(router_name, route_path):
650
+ continue
651
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
652
+ chain_offset = match.start("chain")
653
+ for method_match in re.finditer(r"\.(get|post|put|delete|patch|options|head|all)\s*\(", chain, re.IGNORECASE):
654
+ method = method_match.group(1).upper()
655
+ if method == "ALL":
656
+ method = "ANY"
657
+ routes.append(
658
+ Route(
659
+ path=full_path,
660
+ method=method,
661
+ file=file,
662
+ line=_line_of(content, chain_offset + method_match.start()),
663
+ )
664
+ )
665
+
666
+ return routes
667
+
668
+
669
+ def _extract_go_routes(content: str, file: str) -> list[Route]:
670
+ routes: list[Route] = []
671
+ for match in GO_ROUTE_PATTERN.finditer(content):
672
+ verb = match.group(1).lower()
673
+ method = "ANY" if verb in _GO_ANY_METHODS else verb.upper()
674
+ routes.append(
675
+ Route(
676
+ path=match.group(2),
677
+ method=method,
678
+ file=file,
679
+ line=_line_of(content, match.start()),
680
+ )
681
+ )
682
+ return routes
683
+
684
+
685
+ def _extract_php_routes(content: str, file: str) -> list[Route]:
686
+ routes: list[Route] = []
687
+ for match in PHP_ROUTE_PATTERN.finditer(content):
688
+ verb = match.group(1).lower()
689
+ method = "ANY" if verb in _PHP_ANY_METHODS else verb.upper()
690
+ routes.append(
691
+ Route(path=match.group(2), method=method, file=file, line=_line_of(content, match.start()))
692
+ )
693
+ for match in PHP_SYMFONY_ROUTE_PATTERN.finditer(content):
694
+ # Symfony attribute/annotation — method lives in `methods:`; default ANY.
695
+ routes.append(
696
+ Route(path=match.group(1), method="ANY", file=file, line=_line_of(content, match.start()))
697
+ )
698
+ return routes
699
+
700
+
701
+ def extract_routes(content: str, file: str, suffix: str) -> list[Route]:
702
+ if suffix == ".py":
703
+ return _extract_python_routes(content, file)
704
+ if suffix in JS_TS_SUFFIXES:
705
+ return _extract_javascript_routes(content, file)
706
+ if suffix == ".go":
707
+ return _extract_go_routes(content, file)
708
+ if suffix == ".php":
709
+ return _extract_php_routes(content, file)
710
+ return []
711
+
712
+
713
+ def _append_unique_database_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
714
+ seen = {(hint.kind, hint.file) for hint in result.databases}
715
+
716
+ for pattern, kind in DB_PATTERNS:
717
+ match = pattern.search(content)
718
+ if match and (kind, relative) not in seen:
719
+ result.databases.append(
720
+ DatabaseHint(
721
+ kind=kind,
722
+ file=relative,
723
+ line=_line_of(content, match.start()),
724
+ evidence_text=_line_snippet(content, match.start()),
725
+ )
726
+ )
727
+ seen.add((kind, relative))
728
+
729
+ for keyword, kind in DB_KEYWORDS.items():
730
+ idx = lowered.find(keyword)
731
+ if idx != -1 and (kind, relative) not in seen:
732
+ result.databases.append(
733
+ DatabaseHint(
734
+ kind=kind,
735
+ file=relative,
736
+ line=_line_of(content, idx),
737
+ evidence_text=_line_snippet(content, idx),
738
+ )
739
+ )
740
+ seen.add((kind, relative))
741
+
742
+
743
+ # JWT verification whose call name alone is too generic to trust (#325):
744
+ # middleware called as `jwt({ secret })` counts only in a file that imports
745
+ # express-jwt, koa-jwt or hono's jwt; `<name>.verify(` / `<name>.Parse(` and a
746
+ # bare `verify(` only when the name is bound to jsonwebtoken / golang-jwt by an
747
+ # import (auth_protocol's import patterns, so any alias works and a named
748
+ # `{ decode: verify }` doesn't).
749
+ _JWT_MIDDLEWARE_IMPORT_RE = re.compile(r"""['"](?:express-jwt|koa-jwt|hono/jwt|@hono/[\w-]{0,40}jwt)['"]""")
750
+ _JWT_MIDDLEWARE_CALL_RE = re.compile(r"\b(?:jwt|expressjwt|expressJwt|koaJwt|koajwt)\s*\(\s*\{")
751
+ _NAMED_CALL_SCAN_LIMIT = 50
752
+ _GO_JWT_VERIFY_METHODS = ("Parse(", "ParseWithClaims(")
753
+
754
+
755
+ def _call_at(content: str, needle: str, start: int) -> int | None:
756
+ """First `needle` (a callee ending in `(`) at or after ``start`` that isn't
757
+ the tail of a longer name or member (`xverify(`, `a.verify(` for `verify(`)."""
758
+ pos = start
759
+ for _ in range(_NAMED_CALL_SCAN_LIMIT):
760
+ pos = content.find(needle, pos)
761
+ if pos < 0:
762
+ return None
763
+ before = content[pos - 1] if pos else ""
764
+ if not (before.isalnum() or before in "_$."):
765
+ return pos
766
+ pos += len(needle)
767
+ return None
768
+
769
+
770
+ def _named_verify_aliases(members: str) -> set[str]:
771
+ """Local names bound to jsonwebtoken's `verify` in a `{ … }` import list
772
+ (`verify`, `verify as check`, `verify: check`; not `decode: verify`)."""
773
+ out: set[str] = set()
774
+ for part in members.split(","):
775
+ pieces = part.replace(":", " as ").split(" as ")
776
+ source = pieces[0].strip()
777
+ local = pieces[-1].strip() if len(pieces) > 1 else source
778
+ if source == "verify" and local:
779
+ out.add(local)
780
+ return out
781
+
782
+
783
+ def _gated_jwt_verification(content: str) -> int | None:
784
+ """Offset of a JWT verification that only counts with its import (see above)."""
785
+ if _JWT_MIDDLEWARE_IMPORT_RE.search(content):
786
+ call = _JWT_MIDDLEWARE_CALL_RE.search(content)
787
+ if call:
788
+ return call.start()
789
+ for imp in _JS_JWT_DEFAULT.finditer(content):
790
+ name = imp.group(1) or imp.group(2)
791
+ found = _call_at(content, f"{name}.verify(", imp.end()) if name else None
792
+ if found is not None:
793
+ return found
794
+ for imp in _JS_JWT_NAMED.finditer(content):
795
+ for name in sorted(_named_verify_aliases(imp.group(1) or imp.group(2) or "")):
796
+ found = _call_at(content, f"{name}(", imp.end())
797
+ if found is not None:
798
+ return found
799
+ for imp in _GO_JWT.finditer(content):
800
+ name = (imp.group(1) or "jwt").strip()
801
+ for method in _GO_JWT_VERIFY_METHODS:
802
+ found = _call_at(content, f"{name}.{method}", imp.end())
803
+ if found is not None:
804
+ return found
805
+ return None
806
+
807
+
808
+ def _append_unique_auth_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
809
+ seen = {(hint.hint, hint.file) for hint in result.auth_hints}
810
+
811
+ for pattern, hint in AUTH_PATTERNS:
812
+ match = pattern.search(content)
813
+ if match and (hint, relative) not in seen:
814
+ result.auth_hints.append(
815
+ AuthHint(
816
+ hint=hint,
817
+ file=relative,
818
+ line=_line_of(content, match.start()),
819
+ evidence_text=_line_snippet(content, match.start()),
820
+ confidence=0.85,
821
+ )
822
+ )
823
+ seen.add((hint, relative))
824
+
825
+ if ("jwt", relative) not in seen:
826
+ offset = _gated_jwt_verification(content)
827
+ if offset is not None:
828
+ result.auth_hints.append(
829
+ AuthHint(
830
+ hint="jwt",
831
+ file=relative,
832
+ line=_line_of(content, offset),
833
+ evidence_text=_line_snippet(content, offset),
834
+ confidence=0.85,
835
+ )
836
+ )
837
+ seen.add(("jwt", relative))
838
+
839
+ for keyword in AUTH_KEYWORDS:
840
+ idx = lowered.find(keyword)
841
+ if idx != -1 and (keyword, relative) not in seen:
842
+ result.auth_hints.append(
843
+ AuthHint(
844
+ hint=keyword,
845
+ file=relative,
846
+ line=_line_of(content, idx),
847
+ evidence_text=_line_snippet(content, idx),
848
+ confidence=0.5,
849
+ )
850
+ )
851
+ seen.add((keyword, relative))
852
+
853
+
854
+ def scan_repo(
855
+ root: str | Path,
856
+ suffixes: set[str] | None = None,
857
+ progress: "ScanProgress | None" = None,
858
+ recall: bool = False,
859
+ ) -> ScanResult:
860
+ """Per-file signal extraction plus the whole-repo passes, for callers that
861
+ scan a repo directly. ``analyze_repository`` instead runs ``scan_files``
862
+ per analyzer and ``run_repo_passes`` once on the merged result (#219)."""
863
+ result = scan_files(root, suffixes=suffixes, progress=progress)
864
+ run_repo_passes(result, Path(result.root), progress=progress, recall=recall)
865
+ if progress is not None:
866
+ progress.done()
867
+ return result
868
+
869
+
870
+ def scan_files(
871
+ root: str | Path,
872
+ suffixes: set[str] | None = None,
873
+ progress: "ScanProgress | None" = None,
874
+ ) -> ScanResult:
875
+ """The per-file pass: routes, calls, hints, secrets and per-file weakness
876
+ detectors for files with ``suffixes``. No whole-repo passes (#219)."""
877
+ root_path = Path(root).resolve()
878
+ result = ScanResult(root=str(root_path))
879
+
880
+ # Materialize the scannable file list first so progress has a total to
881
+ # compute a percentage and ETA against. The extra directory walk is cheap
882
+ # next to reading + regex-scanning each file.
883
+ # Symlinks are never followed out of the repo (#234); the ones skipped
884
+ # are recorded so the report says what wasn't analyzed.
885
+ def _record_symlink(path: Path) -> None:
886
+ rel = path.relative_to(root_path)
887
+ if not any(part in _NOISY_SYMLINK_PARTS for part in rel.parts):
888
+ result.limitations.append(f"symlink not followed: {rel.as_posix()}")
889
+
890
+ def _record_prune(path: Path) -> None:
891
+ # A directory carrying an output marker is never scanned (#216); say so,
892
+ # since a committed marker would otherwise hide code silently
893
+ # (GHSA-j544-8x8g-c3mw).
894
+ result.limitations.append(f"{EXCLUDED_DIR_LIMITATION}{path.relative_to(root_path).as_posix()}")
895
+
896
+ scan_files = []
897
+ for p in walk_repo(root_path, prune=is_skipped_dir, on_symlink=_record_symlink, on_prune=_record_prune):
898
+ if not should_scan_with_suffixes(p.relative_to(root_path), suffixes):
899
+ continue
900
+ if is_oversized(p):
901
+ # #236: a multi-MB file is generated or hostile; reading and
902
+ # regex-scanning it can stall the whole scan.
903
+ result.limitations.append(
904
+ f"oversized file skipped (> {max_file_bytes()} bytes): "
905
+ f"{p.relative_to(root_path).as_posix()}"
906
+ )
907
+ continue
908
+ scan_files.append(p)
909
+ if progress is not None:
910
+ progress.begin(len(scan_files))
911
+
912
+ csrf_protected = False
913
+ server_session_store = False
914
+ for file_path in scan_files:
915
+ if progress is not None:
916
+ progress.advance(str(file_path.relative_to(root_path)))
917
+
918
+ result.files_scanned += 1
919
+ language = CODE_EXTENSIONS[file_path.suffix]
920
+ if language not in result.languages:
921
+ result.languages.append(language)
922
+
923
+ try:
924
+ content = file_path.read_text(encoding="utf-8")
925
+ except UnicodeDecodeError:
926
+ continue
927
+ except OSError as exc:
928
+ # An unreadable file (permissions, vanished mid-scan, …) must not
929
+ # abort the whole scan (#217): skip it and say so in the report.
930
+ result.limitations.append(
931
+ f"unreadable file skipped ({type(exc).__name__}): {file_path.relative_to(root_path).as_posix()}"
932
+ )
933
+ continue
934
+
935
+ relative = str(file_path.relative_to(root_path))
936
+ # Routes declared in test/spec or vendored files aren't real attack
937
+ # surface — Laravel/Go/pytest test helpers call `.get('/x')` heavily
938
+ # (#113). Skip them by default (ATTACKMAP_INCLUDE_TESTS / _VENDORED opt in).
939
+ if not is_test_file(relative) and not is_vendored_file(relative):
940
+ file_routes = extract_routes(content, relative, file_path.suffix)
941
+ result.routes.extend(file_routes)
942
+
943
+ for pattern in EXTERNAL_CALL_PATTERNS:
944
+ for match in pattern.finditer(content):
945
+ target, method = _external_call_fields(match)
946
+ result.external_calls.append(
947
+ ExternalCall(
948
+ target=target,
949
+ method=method,
950
+ file=relative,
951
+ line=_line_of(content, match.start()),
952
+ evidence_text=_line_snippet(content, match.start()),
953
+ )
954
+ )
955
+
956
+ lowered = content.lower()
957
+ _append_unique_database_hints(result, relative, content, lowered)
958
+ _append_unique_auth_hints(result, relative, content, lowered)
959
+
960
+ for pattern in SECRET_PATTERNS:
961
+ for match in pattern.finditer(content):
962
+ result.secret_hints.append(
963
+ SecretHint(
964
+ name=match.groups()[0],
965
+ file=relative,
966
+ line=_line_of(content, match.start()),
967
+ evidence_text=_line_snippet(content, match.start()),
968
+ kind="env_reference",
969
+ )
970
+ )
971
+
972
+ # The keyword-proximity and URL-credential rules (#252) are skipped in
973
+ # test/vendored code, where fake credential fixtures are the norm.
974
+ first_party = not is_test_file(relative) and not is_vendored_file(relative)
975
+ # The high-entropy fallback is a guess, and test fixtures are full of
976
+ # random-looking ids and hashes (#385). Provider-shaped credentials
977
+ # (AWS keys, PEM blocks, verified JWTs, …) are still reported there.
978
+ _append_hardcoded_secret_hints(
979
+ result, relative, content, entropy=first_party, generic=first_party, urls=first_party
980
+ )
981
+
982
+ # The weakness passes below are noise in test scaffolding (#67) and in
983
+ # vendored/minified third-party code (#95) — a bug in a dependency the
984
+ # project doesn't maintain isn't this repo's finding. Skip both by
985
+ # default (ATTACKMAP_INCLUDE_TESTS / ATTACKMAP_INCLUDE_VENDORED opt in).
986
+ if not is_test_file(relative) and not is_vendored_file(relative):
987
+ # Insecure crypto / weak randomness (#70). Content is already
988
+ # read, so this rides the per-file pass rather than re-walking.
989
+ result.crypto_weaknesses.extend(find_crypto_weaknesses(content, relative))
990
+ # Web-hardening gaps (#71): CORS, CSRF, cookies, CSP, debug.
991
+ result.web_hardening_issues.extend(find_web_hardening_issues(content, relative))
992
+ csrf_protected = csrf_protected or has_csrf_protection(content)
993
+ # Novel vuln classes (#77): proto pollution, mass assignment, JWT, XXE.
994
+ result.code_weaknesses.extend(find_code_weaknesses(content, relative))
995
+ # JWT verification (#243): library-aware checks, plus the
996
+ # `jwt_verified` hint that credits the "JWT verification" control.
997
+ jwt_weaknesses, jwt_verified = find_jwt_issues(content, relative)
998
+ result.code_weaknesses.extend(jwt_weaknesses)
999
+ result.auth_hints.extend(jwt_verified)
1000
+ # OAuth clients and sessions (#243, part 2).
1001
+ oauth_session, file_server_store = find_oauth_session_issues(content, relative)
1002
+ result.code_weaknesses.extend(oauth_session)
1003
+ server_session_store = server_session_store or file_server_store
1004
+
1005
+ if not server_session_store:
1006
+ # Session fixation needs a server-side session id; with cookie
1007
+ # sessions (cookie-session) there is none to fix. PHP's $_SESSION is
1008
+ # always server-side.
1009
+ result.code_weaknesses = [
1010
+ w for w in result.code_weaknesses
1011
+ if not (w.kind == "session_fixation" and PurePosixPath(w.file).suffix.lower() in JS_TS_SUFFIXES)
1012
+ ]
1013
+ if csrf_protected:
1014
+ # A CSRF defence anywhere in the repo (#244) — session config and CSRF
1015
+ # middleware often live in different files — clears the inference.
1016
+ result.web_hardening_issues = [
1017
+ i for i in result.web_hardening_issues if i.kind != "csrf_unprotected_session"
1018
+ ]
1019
+ result.languages.sort()
1020
+ return result
1021
+
1022
+
1023
+ def run_repo_passes(
1024
+ result: ScanResult,
1025
+ root_path: Path,
1026
+ progress: "ScanProgress | None" = None,
1027
+ recall: bool = False,
1028
+ ) -> ScanResult:
1029
+ """Whole-repo passes over an assembled result — run once per scan, after
1030
+ every analyzer's per-file output is merged, so taint/authz/anomalies see
1031
+ the full route set (#219)."""
1032
+ # Taint pass runs after regular signal extraction — it needs the
1033
+ # route list to know where to seed source flows from (#45). This is the
1034
+ # slow tail on big monorepos (import-graph walk), so it gets its own
1035
+ # progress stage.
1036
+ if progress is not None:
1037
+ progress.stage("Taint / data-flow analysis")
1038
+ result.taint_chains = result.taint_chains + analyze_taint(
1039
+ result, root_path, recall=recall_config() if recall else DEFAULT_RECALL
1040
+ )
1041
+ # SBOM inventory: direct-dep parse of manifest files (#48, slice 1).
1042
+ if progress is not None:
1043
+ progress.stage("Dependency inventory (SBOM)")
1044
+ result.dependencies = result.dependencies + analyze_sbom(root_path)
1045
+ # Supply-chain risk beyond CVEs (#247): offline, over the same manifests.
1046
+ if progress is not None:
1047
+ progress.stage("Dependency supply-chain risk")
1048
+ result.supply_chain_issues = result.supply_chain_issues + scan_supply_chain(
1049
+ root_path, result.dependencies
1050
+ )
1051
+ # CI workflow security (#142): parse .github/workflows for unpinned actions,
1052
+ # pull_request_target checkouts, secrets/injectable context in run steps,
1053
+ # over-broad permissions, and self-hosted runners on PR triggers.
1054
+ if progress is not None:
1055
+ progress.stage("CI workflow security")
1056
+ result.workflow_issues = result.workflow_issues + scan_workflows(root_path)
1057
+ # BOLA/IDOR: routes with an id param reaching a datastore with no
1058
+ # ownership check nearby (#69). Runs after taint so it can reuse
1059
+ # sql_execute reachability.
1060
+ if progress is not None:
1061
+ progress.stage("Authorization (BOLA/IDOR)")
1062
+ result.authz_candidates = result.authz_candidates + analyze_authz(result, root_path)
1063
+ # Field-level mass assignment (#242): binders and models live in
1064
+ # different files, so this can't ride the per-file weakness pass.
1065
+ result.code_weaknesses = result.code_weaknesses + analyze_mass_assignment(root_path)
1066
+ # Spring Security `csrf_disabled` in Java/Kotlin config (#341): the
1067
+ # per-file pass doesn't read JVM sources.
1068
+ seen = {(i.kind, i.file, i.line) for i in result.web_hardening_issues}
1069
+ result.web_hardening_issues = result.web_hardening_issues + [
1070
+ i for i in find_jvm_csrf_disabled(root_path) if (i.kind, i.file, i.line) not in seen
1071
+ ]
1072
+ # Anomaly / outlier pass (#78): the odd-one-out among sibling routes.
1073
+ # Runs last so the full route list is assembled into cohorts. It drives its
1074
+ # own determinate per-cohort progress (the slow tail on big route surfaces).
1075
+ result.anomalies = result.anomalies + find_anomalies(result, root_path, progress=progress)
1076
+ return result
1077
+
1078
+
1079
+ def _redact_secret(value: str) -> str:
1080
+ """Display form of a secret: at most its first 4 characters (#235)."""
1081
+ return mask_secret(value)
1082
+
1083
+
1084
+ def _shannon_entropy(value: str) -> float:
1085
+ if not value:
1086
+ return 0.0
1087
+ frequencies: dict[str, int] = {}
1088
+ for ch in value:
1089
+ frequencies[ch] = frequencies.get(ch, 0) + 1
1090
+ length = len(value)
1091
+ entropy = 0.0
1092
+ for count in frequencies.values():
1093
+ p = count / length
1094
+ entropy -= p * math.log2(p)
1095
+ return entropy
1096
+
1097
+
1098
+ # Multiformat content identifiers: CIDv1 in base32 (multibase `b`, version 1
1099
+ # → `baf…`/`bag…`) and CIDv0 (46-char base58btc starting `Qm`).
1100
+ _CID_RE = re.compile(r"ba[fg][a-z2-7]{50,120}$|Qm[1-9A-HJ-NP-Za-km-z]{44}$")
1101
+
1102
+
1103
+ def _looks_like_secret_candidate(value: str) -> bool:
1104
+ """Reject obvious non-secret shapes that would otherwise clear the
1105
+ entropy bar (hex hashes in comments, URLs, etc.)."""
1106
+ if len(value) < _ENTROPY_MIN_LENGTH or len(value) > _ENTROPY_MAX_LENGTH:
1107
+ return False
1108
+ if all(c in "0123456789abcdef" for c in value.lower()):
1109
+ # Pure hex — commit SHAs, hashes. Not a secret in code.
1110
+ return False
1111
+ if _is_integrity_digest(value):
1112
+ # Subresource-integrity / lockfile integrity digests
1113
+ # (`sha384-…`, `sha512-…`). High-entropy base64, but they are
1114
+ # published fingerprints of public assets, not secrets (#141). The
1115
+ # body is verified as a correct-length base64 digest so a real
1116
+ # credential that merely starts with `sha256-` is not suppressed.
1117
+ return False
1118
+ if _UUID_RE.match(value):
1119
+ # Canonical UUID — an identifier, not a credential.
1120
+ return False
1121
+ if _CID_RE.match(value):
1122
+ # An IPLD/IPFS content identifier (`bafy…`, `bafk…`, `bafyrei…`,
1123
+ # CIDv0 `Qm…`): a public content hash, not a credential (#385).
1124
+ return False
1125
+ if "/" in value and value.count("/") > 2:
1126
+ # Looks like a URL path fragment.
1127
+ return False
1128
+ if value.startswith(("http://", "https://", "data:", "file:")):
1129
+ return False
1130
+ if value.replace(".", "").replace("-", "").replace("_", "").isdigit():
1131
+ # Version strings, phone numbers, etc.
1132
+ return False
1133
+ return True
1134
+
1135
+
1136
+ # --- #252: prefix-less credentials ----------------------------------------
1137
+
1138
+ # Credentials embedded in a URL (`https://user:pass@host`). Kept out of the
1139
+ # redaction token list: `redact._URI_USERINFO` already masks userinfo.
1140
+ _BASIC_AUTH_URL_RE = re.compile(
1141
+ r"\b(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]{1,15})://[^\s:/@'\"]{1,128}:(?P<secret>[^\s/@'\"]{3,256})@[A-Za-z0-9.\[-]"
1142
+ )
1143
+ # Keyword-proximity assignment of a quoted literal to a credential-named key.
1144
+ _CREDENTIAL_ASSIGN_RE = re.compile(
1145
+ r"(?<![\w.-])(?P<key>[\w.-]{0,40}?(?:password|passwd|pwd|secret|token|api[_-]?key)[\w.-]{0,40}?)['\"]?"
1146
+ r"\s{0,5}(?::=|=>|[:=])\s{0,5}(?P<q>['\"])(?P<secret>[^'\"\s]{8,128})(?P=q)",
1147
+ re.IGNORECASE,
1148
+ )
1149
+ # GCP service-account key file: the type marker plus an embedded private key.
1150
+ _GCP_SA_TYPE_RE = re.compile(r"\"type\"\s{0,5}:\s{0,5}\"service_account\"")
1151
+ _GCP_SA_KEY_RE = re.compile(r"\"private_key\"\s{0,5}:\s{0,5}\"(?P<secret>-----BEGIN [A-Z ]{0,20}PRIVATE KEY-----[^\"]{0,8000})\"")
1152
+
1153
+ # Values that are placeholders, templates or references, not credentials.
1154
+ _PLACEHOLDER_WORDS = (
1155
+ "changeme", "change_me", "change-me", "example", "placeholder", "dummy", "sample", "your_", "your-",
1156
+ "yourpass", "xxxx", "redacted", "replace", "secret_here", "token_here", "password_here", "insert",
1157
+ "fake", "mock",
1158
+ )
1159
+ _PLACEHOLDER_EXACT = frozenset({
1160
+ "password", "passwd", "secret", "token", "apikey", "api_key", "api-key", "pass", "none", "null",
1161
+ "undefined", "test", "testing", "default", "notasecret",
1162
+ })
1163
+ # Key names that carry a secret's *metadata*, not the secret.
1164
+ _NON_SECRET_KEY_PARTS = (
1165
+ "name", "field", "header", "param", "type", "url", "uri", "path", "file", "_id", "env", "var",
1166
+ "label", "prompt", "length", "regex", "pattern", "prefix", "endpoint", "hash", "policy",
1167
+ "expir", "ttl", "count", "format", "placeholder", "message", "error", "hint", "reset", "column",
1168
+ "table", "kind", "mode", "style", "class", "selector", "route", "template", "strength", "rule",
1169
+ )
1170
+ _IDENTIFIER_VALUE_RE = re.compile(
1171
+ r"^(?:[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+|[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+|[a-z]+(?:[A-Z][a-z0-9]+)+)$"
1172
+ )
1173
+
1174
+
1175
+ def is_placeholder_secret(value: str) -> bool:
1176
+ """A credential-shaped value that is a placeholder, template, env lookup
1177
+ or masked value rather than a real secret."""
1178
+ trimmed = value.strip()
1179
+ lowered = trimmed.lower()
1180
+ if not trimmed or lowered in _PLACEHOLDER_EXACT:
1181
+ return True
1182
+ if trimmed.startswith(("${", "{{", "<", "%(", "$", "%", "#{", "@{")) or trimmed.endswith(("}", ">")):
1183
+ return True
1184
+ if any(word in lowered for word in _PLACEHOLDER_WORDS):
1185
+ return True
1186
+ if len(set(trimmed)) <= 2 or "***" in trimmed or "…" in trimmed:
1187
+ return True
1188
+ return False
1189
+
1190
+
1191
+ def _credential_assignment_ok(key: str, value: str) -> bool:
1192
+ key_l = key.lower()
1193
+ if any(part in key_l for part in _NON_SECRET_KEY_PARTS):
1194
+ return False
1195
+ if is_placeholder_secret(value) or value.lower() == key_l:
1196
+ return False
1197
+ if _IDENTIFIER_VALUE_RE.match(value):
1198
+ return False # an identifier / env-var name / dotted reference
1199
+ if "/" in value or "://" in value or value.startswith(("http", ".")):
1200
+ return False
1201
+ if value.replace(".", "").replace("-", "").replace("_", "").isdigit():
1202
+ return False
1203
+ return True
1204
+
1205
+
1206
+ def iter_secret_matches(
1207
+ content: str, *, entropy: bool = True, generic: bool = True, urls: bool = True
1208
+ ):
1209
+ """Yield ``(kind, literal, offset, confidence)`` for every hard-coded
1210
+ credential in ``content``: provider patterns, URL userinfo passwords,
1211
+ GCP service-account keys, then (opt.) the keyword-proximity assignment
1212
+ and the high-entropy fallback. ``literal`` is the raw secret — callers
1213
+ must mask it before it leaves the process (#235)."""
1214
+ matched_spans: list[tuple[int, int]] = []
1215
+
1216
+ def _overlaps(start: int, end: int) -> bool:
1217
+ return any(s < end and start < e for s, e in matched_spans)
1218
+
1219
+ # A GCP service-account key file is reported as such, not as a bare PEM.
1220
+ if _GCP_SA_TYPE_RE.search(content):
1221
+ key = _GCP_SA_KEY_RE.search(content)
1222
+ if key:
1223
+ matched_spans.append((key.start("secret"), key.end("secret")))
1224
+ yield "gcp_service_account_key", key.group("secret"), key.start(), 1.0
1225
+
1226
+ for pattern, kind in HARDCODED_SECRET_PATTERNS:
1227
+ for match in pattern.finditer(content):
1228
+ literal = match.group(1)
1229
+ if kind == "pem_private_key" and _overlaps(match.start(1), match.end(1)):
1230
+ continue
1231
+ # JWT shape needs additional verification — the base-64 header
1232
+ # segment must decode to something starting with `{` and
1233
+ # containing an "alg" key.
1234
+ if kind == "jwt" and not _is_jwt_shape(literal):
1235
+ continue
1236
+ if kind in {"npm_auth_token", "npm_basic_auth", "docker_registry_auth"} and is_placeholder_secret(literal):
1237
+ continue
1238
+ matched_spans.append((match.start(1), match.end(1)))
1239
+ yield kind, literal, match.start(), 1.0
1240
+
1241
+ for match in (_BASIC_AUTH_URL_RE.finditer(content) if urls else ()):
1242
+ literal = match.group("secret")
1243
+ if is_placeholder_secret(literal) or _overlaps(match.start("secret"), match.end("secret")):
1244
+ continue
1245
+ host = content[match.end("secret") + 1 : match.end("secret") + 64].split("/", 1)[0].split(":", 1)[0]
1246
+ if host.lower() in {"localhost", "127.0.0.1", "0.0.0.0", "[::1]"}:
1247
+ continue # a local dev DSN
1248
+ matched_spans.append((match.start("secret"), match.end("secret")))
1249
+ yield "basic_auth_url", literal, match.start("secret"), 0.9
1250
+
1251
+ # High-entropy fallback — only where no stronger match already fired.
1252
+ for match in (_QUOTED_LITERAL.finditer(content) if entropy else ()):
1253
+ literal = match.group(1)
1254
+ pos = match.start(1)
1255
+ if _overlaps(pos, match.end(1)):
1256
+ continue
1257
+ if not _looks_like_secret_candidate(literal):
1258
+ continue
1259
+ if _looks_like_charset(literal):
1260
+ continue # base64/base32/hex alphabet constant, not a secret (#96)
1261
+ if _shannon_entropy(literal) < _ENTROPY_THRESHOLD:
1262
+ continue
1263
+ matched_spans.append((pos, match.end(1)))
1264
+ yield "high_entropy", literal, pos, 0.7
1265
+
1266
+ if generic:
1267
+ for match in _CREDENTIAL_ASSIGN_RE.finditer(content):
1268
+ literal = match.group("secret")
1269
+ if _overlaps(match.start("secret"), match.end("secret")):
1270
+ continue
1271
+ if not _credential_assignment_ok(match.group("key"), literal):
1272
+ continue
1273
+ matched_spans.append((match.start("secret"), match.end("secret")))
1274
+ yield "credential_assignment", literal, match.start("secret"), 0.5
1275
+
1276
+
1277
+ def _append_hardcoded_secret_hints(
1278
+ result: ScanResult, relative: str, content: str, *, entropy: bool = True, generic: bool = True,
1279
+ urls: bool = True,
1280
+ ) -> None:
1281
+ """Emit SecretHint records for literals pasted into source or config
1282
+ (#39, #252). Every value is redacted before it lands in a hint."""
1283
+ seen: set[tuple[str, str, int]] = {
1284
+ (hint.kind, hint.file, hint.line or 0) for hint in result.secret_hints
1285
+ }
1286
+ # A line that already carries a secret hint (e.g. a config DB URL
1287
+ # password) doesn't get a second, weaker one.
1288
+ lines_with_hint = {hint.line for hint in result.secret_hints if hint.file == relative}
1289
+ for kind, literal, offset, confidence in iter_secret_matches(content, entropy=entropy, generic=generic, urls=urls):
1290
+ line = _line_of(content, offset)
1291
+ key = (kind, relative, line)
1292
+ if key in seen or (confidence < 1.0 and line in lines_with_hint):
1293
+ continue
1294
+ seen.add(key)
1295
+ lines_with_hint.add(line)
1296
+ # PEM only captures the (non-secret) header line — keep it verbatim.
1297
+ # Everything else is redacted in both the name *and* the evidence
1298
+ # snippet so the raw credential never reaches report.json.
1299
+ redact = kind != "pem_private_key"
1300
+ result.secret_hints.append(
1301
+ SecretHint(
1302
+ name=_redact_secret(literal) if redact else literal,
1303
+ file=relative,
1304
+ line=line,
1305
+ evidence_text=_redacted_snippet(content, offset, literal, redact=redact),
1306
+ confidence=confidence,
1307
+ kind=kind,
1308
+ )
1309
+ )
1310
+
1311
+
1312
+ # Sequential alphabet runs that mark a well-known charset/alphabet constant
1313
+ # (base64/base32/base16 digit strings) — high-entropy but not a secret (#96).
1314
+ _CHARSET_MARKERS = (
1315
+ "abcdefghijklmnop", # lowercase alphabet run (base64/base32/base36)
1316
+ "ABCDEFGHIJKLMNOP", # uppercase alphabet run
1317
+ "0123456789abcdef", # lower hex
1318
+ "0123456789ABCDEF", # upper hex
1319
+ )
1320
+
1321
+
1322
+ def _looks_like_charset(value: str) -> bool:
1323
+ return any(marker in value for marker in _CHARSET_MARKERS)
1324
+
1325
+
1326
+ # Integrity-digest algorithms (Subresource Integrity, lockfile `integrity`
1327
+ # fields) and their raw digest byte-lengths. These are high-entropy base64
1328
+ # but are public asset fingerprints, not credentials.
1329
+ _INTEGRITY_DIGEST_BYTES = {"md5": 16, "sha1": 20, "sha256": 32, "sha384": 48, "sha512": 64}
1330
+
1331
+
1332
+ def _is_integrity_digest(value: str) -> bool:
1333
+ """True if ``value`` is an `<algo>-<base64>` integrity digest whose body
1334
+ decodes to the algorithm's expected length. Length-checking the body
1335
+ keeps a genuine secret that merely starts with `sha256-` from being
1336
+ suppressed as if it were a public fingerprint."""
1337
+ algo, sep, body = value.partition("-")
1338
+ if not sep:
1339
+ return False
1340
+ expected = _INTEGRITY_DIGEST_BYTES.get(algo.lower())
1341
+ if expected is None or not body:
1342
+ return False
1343
+ stripped = body.rstrip("=")
1344
+ padding = "=" * ((4 - len(stripped) % 4) % 4)
1345
+ try:
1346
+ decoded = base64.b64decode(stripped + padding, validate=True)
1347
+ except (ValueError, binascii.Error):
1348
+ return False
1349
+ return len(decoded) == expected
1350
+
1351
+
1352
+ # Canonical UUID (8-4-4-4-12 hex). An identifier, not a credential.
1353
+ _UUID_RE = re.compile(
1354
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
1355
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
1356
+ )
1357
+
1358
+
1359
+ def _is_jwt_shape(literal: str) -> bool:
1360
+ """Verify the header segment of a candidate JWT decodes to a JSON
1361
+ object with an `alg` field. Cheap and precise enough to keep the
1362
+ shape regex from spamming."""
1363
+ parts = literal.split(".")
1364
+ if len(parts) != 3:
1365
+ return False
1366
+ header_b64 = parts[0]
1367
+ # base64url decode with padding tolerance
1368
+ padding = "=" * ((4 - len(header_b64) % 4) % 4)
1369
+ try:
1370
+ header_bytes = base64.urlsafe_b64decode(header_b64 + padding)
1371
+ except (ValueError, binascii.Error):
1372
+ return False
1373
+ try:
1374
+ header_text = header_bytes.decode("utf-8")
1375
+ except UnicodeDecodeError:
1376
+ return False
1377
+ if not header_text.startswith("{") or "alg" not in header_text.lower():
1378
+ return False
1379
+ return True