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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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
|