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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Config-file scanner (#43).
|
|
2
|
+
|
|
3
|
+
YAML / TOML / JSON / INI / .env files often carry the real DB
|
|
4
|
+
connection strings, service URLs, and — connecting to #39 — hard-coded
|
|
5
|
+
credentials the source code hasn't yet inlined. `scan_repo`
|
|
6
|
+
intentionally covers only source, so this module fills the config gap
|
|
7
|
+
with the same regex-based, dependency-free approach: text-scan the
|
|
8
|
+
files, emit `DatabaseHint` / `ExternalCall` / `SecretHint` records
|
|
9
|
+
into a `ScanResult`, and let the merge pipeline dedup as usual.
|
|
10
|
+
|
|
11
|
+
Deliberately no YAML / TOML / JSON *parsers* — the goal is broad,
|
|
12
|
+
resilient extraction that works on malformed files too. Callers who
|
|
13
|
+
want structural analysis can extend on top; this layer stays regex-only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import bisect
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .srcpaths import SKIP_DIRS, in_skipped_dir, is_skipped_dir
|
|
23
|
+
from .redact import mask_secret
|
|
24
|
+
from .safe_fs import max_file_bytes, walk_repo
|
|
25
|
+
from .models import DatabaseHint, ExternalCall, ScanResult, SecretHint
|
|
26
|
+
from .scanner import _append_hardcoded_secret_hints, _line_snippet
|
|
27
|
+
|
|
28
|
+
# Config files larger than this are skipped — at that size they're invariably
|
|
29
|
+
# generated (lock files, minified bundles, or AttackMap's own JSON reports)
|
|
30
|
+
# rather than hand-written config, and scanning multi-megabyte text for URL /
|
|
31
|
+
# secret regexes is pointlessly slow.
|
|
32
|
+
_MAX_CONFIG_BYTES = 5_000_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _LineIndex:
|
|
36
|
+
"""O(log n) line-number lookups for a file's text.
|
|
37
|
+
|
|
38
|
+
`scanner._line_of` counts newlines from the start on every call — O(n) each,
|
|
39
|
+
which degrades to O(n^2) when a large file yields many matches (the cause of
|
|
40
|
+
a scan hang on multi-megabyte JSON). Precomputing the line-start offsets once
|
|
41
|
+
and binary-searching keeps extraction linear.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_starts",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, content: str) -> None:
|
|
47
|
+
starts = [0]
|
|
48
|
+
find = content.find
|
|
49
|
+
idx = find("\n")
|
|
50
|
+
while idx != -1:
|
|
51
|
+
starts.append(idx + 1)
|
|
52
|
+
idx = find("\n", idx + 1)
|
|
53
|
+
self._starts = starts
|
|
54
|
+
|
|
55
|
+
def line_of(self, offset: int) -> int:
|
|
56
|
+
"""1-indexed line number for a character offset."""
|
|
57
|
+
if offset <= 0:
|
|
58
|
+
return 1
|
|
59
|
+
return bisect.bisect_right(self._starts, offset)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
CONFIG_SUFFIXES = {".yaml", ".yml", ".toml", ".json", ".ini", ".cfg", ".env"}
|
|
63
|
+
# Also match dotfiles like `.env.example`, `.env.sample`, `sample.env` —
|
|
64
|
+
# handled via filename check rather than suffix.
|
|
65
|
+
_CONFIG_FILENAME_HINTS = ("env",) # substring in stem for `.env`-family files
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Filenames excluded even when they have a matching suffix. These carry
|
|
69
|
+
# inventory / build state, not application config.
|
|
70
|
+
CONFIG_EXCLUDE_FILENAMES = frozenset({
|
|
71
|
+
# AttackMap's own artifacts (#216) — never re-ingest a previous report,
|
|
72
|
+
# even from an output dir written before the output marker existed.
|
|
73
|
+
"attackmap-report.json",
|
|
74
|
+
"defensive-review.json",
|
|
75
|
+
"review-context-pack.json",
|
|
76
|
+
"fleet-summary.json",
|
|
77
|
+
"defensive-review-llm.meta.json",
|
|
78
|
+
"benchmark-results.json",
|
|
79
|
+
"package-lock.json",
|
|
80
|
+
"yarn.lock",
|
|
81
|
+
"pnpm-lock.yaml",
|
|
82
|
+
"cargo.lock",
|
|
83
|
+
"poetry.lock",
|
|
84
|
+
"uv.lock",
|
|
85
|
+
"tsconfig.json",
|
|
86
|
+
"jsconfig.json",
|
|
87
|
+
".eslintrc.json",
|
|
88
|
+
".prettierrc.json",
|
|
89
|
+
"renovate.json",
|
|
90
|
+
"package.json", # noisy: `scripts:` values look like URLs and DB strings
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
_SKIP_DIRS = SKIP_DIRS # shared set (#215)
|
|
94
|
+
|
|
95
|
+
# Credential dotfiles (#252): registry / VCS / SSH credentials that neither
|
|
96
|
+
# the source pass (skips dotfiles) nor the suffix match above would read.
|
|
97
|
+
# Symlinked ones are still skipped by walk_repo (#234).
|
|
98
|
+
CREDENTIAL_FILENAMES = frozenset({
|
|
99
|
+
".npmrc", ".pypirc", ".netrc", "_netrc", ".dockercfg", ".git-credentials",
|
|
100
|
+
"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
101
|
+
})
|
|
102
|
+
# `.netrc`: `machine host login user password SECRET`.
|
|
103
|
+
_NETRC_PASSWORD_RE = re.compile(r"\bpassword\s{1,20}(?P<value>\S{4,256})")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- DB URL extraction --------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# `postgresql://user:pass@host:5432/db`, `mysql://...`, `mongodb://...`,
|
|
109
|
+
# `redis://...`, `sqlite:///path`. Scheme drives the `kind` value.
|
|
110
|
+
_DB_URL_PATTERN = re.compile(
|
|
111
|
+
r"""(?P<scheme>postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|sqlite)
|
|
112
|
+
://
|
|
113
|
+
(?P<userinfo>[^@\s"'/]*@)? # optional user[:pass]@
|
|
114
|
+
[^\s"']* # host + path
|
|
115
|
+
""",
|
|
116
|
+
re.IGNORECASE | re.VERBOSE,
|
|
117
|
+
)
|
|
118
|
+
_DB_SCHEME_TO_KIND = {
|
|
119
|
+
"postgres": "postgresql",
|
|
120
|
+
"postgresql": "postgresql",
|
|
121
|
+
"mysql": "mysql",
|
|
122
|
+
"mongodb": "mongodb",
|
|
123
|
+
"mongodb+srv": "mongodb",
|
|
124
|
+
"redis": "redis",
|
|
125
|
+
"sqlite": "sqlite",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# HTTPS URLs targeting real external hosts. Skip common non-target hosts
|
|
129
|
+
# (localhost, example.com, github.com CI links, cdn.jsdelivr, etc.) so
|
|
130
|
+
# `homepage:` and `repository:` config fields don't flood ExternalCall.
|
|
131
|
+
_HTTP_URL_PATTERN = re.compile(r"https?://[a-zA-Z0-9._-]+(?::\d+)?(?:/[^\s\"'`]*)?")
|
|
132
|
+
_URL_HOST_SKIP = re.compile(
|
|
133
|
+
r"^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|(?:.*\.)?(?:example\.com|invalid|"
|
|
134
|
+
r"github\.com|githubusercontent\.com|jsdelivr\.net|unpkg\.com|w3\.org|"
|
|
135
|
+
r"schemastore\.org|npmjs\.com))(?::\d+)?$",
|
|
136
|
+
re.IGNORECASE,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- Secret KV extraction -----------------------------------------------------
|
|
141
|
+
|
|
142
|
+
# Keys that mark a secret-bearing config field. Match on the KEY side of
|
|
143
|
+
# a `key: value` / `key = value` pair.
|
|
144
|
+
_SECRET_KEY_RE = re.compile(
|
|
145
|
+
# start-of-line or dict/list intro. (`-` is not a start char: YAML's
|
|
146
|
+
# `- key:` already starts at the space, and allowing it made every `-` in
|
|
147
|
+
# a long dashed token a new start — #236.)
|
|
148
|
+
r"(?:^|[\s,{\[])"
|
|
149
|
+
r"['\"]?(?P<key>[a-zA-Z0-9_.-]{0,64}" # optional key prefix (bounded, #236)
|
|
150
|
+
r"(?:password|passwd|secret|token|api[_-]?key|priv[_-]?key|private[_-]?key|apikey|_pass|_pwd))"
|
|
151
|
+
r"['\"]?"
|
|
152
|
+
# Same line only: `\s*` let an empty `KEY=` take the next line's
|
|
153
|
+
# `OTHER_KEY=` as its value (#385).
|
|
154
|
+
r"[ \t]*[:=][ \t]*"
|
|
155
|
+
r"(?:['\"](?P<value>[^'\"\n]{4,})['\"]|(?P<bare>[^\s#'\",]{4,}))",
|
|
156
|
+
re.IGNORECASE | re.MULTILINE,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Values that look like placeholders / template interpolation / dev
|
|
160
|
+
# defaults — must NOT be flagged as real secrets. Case-insensitive
|
|
161
|
+
# comparison against the trimmed literal.
|
|
162
|
+
_PLACEHOLDER_LITERALS = frozenset({
|
|
163
|
+
"changeme", "change-me", "change_me",
|
|
164
|
+
"example", "example-value",
|
|
165
|
+
"your-secret", "your-secret-here", "your_secret", "your_secret_here",
|
|
166
|
+
"your-token-here", "your_token_here",
|
|
167
|
+
"your-api-key", "your-api-key-here", "your_api_key", "your_api_key_here",
|
|
168
|
+
"your-password", "your-password-here", "your_password", "your_password_here",
|
|
169
|
+
"placeholder", "todo", "fixme",
|
|
170
|
+
"xxx", "yyy", "zzz",
|
|
171
|
+
"null", "none", "false", "true",
|
|
172
|
+
"n/a", "na", "tbd",
|
|
173
|
+
"root", "admin", # noisy default usernames masquerading as passwords
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# GitHub Actions `permissions:` levels (`id-token: write`, `contents: read`),
|
|
178
|
+
# which the key pattern sees as `…token: write` (#385).
|
|
179
|
+
_PERMISSION_LEVELS = frozenset({"read", "write", "none", "read-all", "write-all"})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_placeholder(value: str) -> bool:
|
|
183
|
+
trimmed = value.strip()
|
|
184
|
+
lower = trimmed.lower()
|
|
185
|
+
if lower in _PLACEHOLDER_LITERALS or lower in _PERMISSION_LEVELS:
|
|
186
|
+
return True
|
|
187
|
+
# Template interpolation — `${ENV_VAR}`, `{{ .Values.foo }}`, `<value>`.
|
|
188
|
+
if trimmed.startswith(("${", "{{", "<")):
|
|
189
|
+
return True
|
|
190
|
+
# Repeating single character — `xxxxxxxx`, `----`, `********`.
|
|
191
|
+
if len(set(trimmed)) <= 1:
|
|
192
|
+
return True
|
|
193
|
+
# Environment variable substitution shape `$VAR`.
|
|
194
|
+
if re.fullmatch(r"\$[A-Z_][A-Z0-9_]*", trimmed):
|
|
195
|
+
return True
|
|
196
|
+
# File path — probably a keyfile pointer, not the key itself.
|
|
197
|
+
if trimmed.startswith(("/", "./", "../")) or trimmed.endswith((".pem", ".key", ".crt")):
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _redact(value: str) -> str:
|
|
203
|
+
return mask_secret(value)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- Public entry points ------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def should_scan_config_file(path: Path) -> bool:
|
|
210
|
+
"""True if the file is a config file this module should extract from."""
|
|
211
|
+
name = path.name.lower()
|
|
212
|
+
if name in CREDENTIAL_FILENAMES or (name.startswith("id_") and name.split(".")[0] in CREDENTIAL_FILENAMES and not name.endswith(".pub")):
|
|
213
|
+
return True
|
|
214
|
+
if name in CONFIG_EXCLUDE_FILENAMES:
|
|
215
|
+
return False
|
|
216
|
+
if path.suffix.lower() in CONFIG_SUFFIXES:
|
|
217
|
+
return True
|
|
218
|
+
# .env, .env.example, sample.env, etc.
|
|
219
|
+
return any(hint in name for hint in _CONFIG_FILENAME_HINTS) and name.startswith(".env") or name.endswith(".env") or ".env." in name
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def scan_config_repo(root: str | Path) -> ScanResult:
|
|
223
|
+
"""Walk `root`, extract config-shaped signals, return a ScanResult
|
|
224
|
+
that merges naturally with source-scanner output via the standard
|
|
225
|
+
merge pipeline."""
|
|
226
|
+
repo = Path(root).resolve()
|
|
227
|
+
result = ScanResult(root=str(repo))
|
|
228
|
+
if not repo.exists() or not repo.is_dir():
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
for path in walk_repo(repo, prune=is_skipped_dir):
|
|
232
|
+
if in_skipped_dir(path.relative_to(repo)):
|
|
233
|
+
continue
|
|
234
|
+
if not should_scan_config_file(path):
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
if path.stat().st_size > min(_MAX_CONFIG_BYTES, max_file_bytes()):
|
|
238
|
+
continue
|
|
239
|
+
content = path.read_text(encoding="utf-8")
|
|
240
|
+
except (UnicodeDecodeError, OSError):
|
|
241
|
+
continue
|
|
242
|
+
result.files_scanned += 1
|
|
243
|
+
relative = str(path.relative_to(repo)).replace("\\", "/")
|
|
244
|
+
_extract_db_urls(content, relative, result)
|
|
245
|
+
_extract_http_targets(content, relative, result)
|
|
246
|
+
_extract_secret_kvs(content, relative, result)
|
|
247
|
+
if path.name.lower() in {".netrc", "_netrc"}:
|
|
248
|
+
_extract_netrc(content, relative, result)
|
|
249
|
+
# Provider-shaped tokens, URL passwords and GCP service-account keys
|
|
250
|
+
# (#252). No entropy fallback or keyword rule here: config already
|
|
251
|
+
# has the key/value pass, and the entropy fallback is noisy on data.
|
|
252
|
+
_append_hardcoded_secret_hints(result, relative, content, entropy=False, generic=False)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _extract_netrc(content: str, relative: str, result: ScanResult) -> None:
|
|
257
|
+
index = _LineIndex(content)
|
|
258
|
+
for match in _NETRC_PASSWORD_RE.finditer(content):
|
|
259
|
+
value = match.group("value")
|
|
260
|
+
if _is_placeholder(value):
|
|
261
|
+
continue
|
|
262
|
+
result.secret_hints.append(
|
|
263
|
+
SecretHint(
|
|
264
|
+
name=_redact(value),
|
|
265
|
+
file=relative,
|
|
266
|
+
line=index.line_of(match.start()),
|
|
267
|
+
evidence_text=_line_snippet(content, match.start()).replace(value, _redact(value)),
|
|
268
|
+
confidence=1.0,
|
|
269
|
+
kind="netrc_password",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _extract_db_urls(content: str, relative: str, result: ScanResult) -> None:
|
|
275
|
+
seen: set[str] = {(h.kind, h.file) for h in result.databases if h.file == relative}
|
|
276
|
+
index = _LineIndex(content)
|
|
277
|
+
for match in _DB_URL_PATTERN.finditer(content):
|
|
278
|
+
scheme = match.group("scheme").lower()
|
|
279
|
+
kind = _DB_SCHEME_TO_KIND.get(scheme, scheme)
|
|
280
|
+
key = (kind, relative)
|
|
281
|
+
if key not in seen:
|
|
282
|
+
result.databases.append(
|
|
283
|
+
DatabaseHint(
|
|
284
|
+
kind=kind,
|
|
285
|
+
file=relative,
|
|
286
|
+
line=index.line_of(match.start()),
|
|
287
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
seen.add(key)
|
|
291
|
+
# DB URL with embedded userinfo → password in the URL is a real secret.
|
|
292
|
+
userinfo = match.group("userinfo")
|
|
293
|
+
if userinfo and ":" in userinfo:
|
|
294
|
+
# userinfo shape is `user:pass@`; strip the trailing `@`
|
|
295
|
+
user_pass = userinfo.rstrip("@")
|
|
296
|
+
_, _, password = user_pass.partition(":")
|
|
297
|
+
if password and not _is_placeholder(password):
|
|
298
|
+
result.secret_hints.append(
|
|
299
|
+
SecretHint(
|
|
300
|
+
name=_redact(password),
|
|
301
|
+
file=relative,
|
|
302
|
+
line=index.line_of(match.start()),
|
|
303
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
304
|
+
confidence=1.0,
|
|
305
|
+
kind=f"{kind}_url_password",
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _extract_http_targets(content: str, relative: str, result: ScanResult) -> None:
|
|
311
|
+
seen_targets = {(c.target, c.file) for c in result.external_calls if c.file == relative}
|
|
312
|
+
index = _LineIndex(content)
|
|
313
|
+
for match in _HTTP_URL_PATTERN.finditer(content):
|
|
314
|
+
target = match.group(0)
|
|
315
|
+
# Trim trailing punctuation that looks like URL end
|
|
316
|
+
target = target.rstrip("),;.\"'")
|
|
317
|
+
parsed_host = _extract_host(target)
|
|
318
|
+
if parsed_host and _URL_HOST_SKIP.match(parsed_host):
|
|
319
|
+
continue
|
|
320
|
+
if (target, relative) in seen_targets:
|
|
321
|
+
continue
|
|
322
|
+
result.external_calls.append(
|
|
323
|
+
ExternalCall(
|
|
324
|
+
target=target,
|
|
325
|
+
file=relative,
|
|
326
|
+
line=index.line_of(match.start()),
|
|
327
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
seen_targets.add((target, relative))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _extract_host(url: str) -> str | None:
|
|
334
|
+
match = re.match(r"https?://([^/:\s]+)", url, re.IGNORECASE)
|
|
335
|
+
return match.group(1) if match else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _masked_kv_snippet(content: str, match: re.Match[str], value: str) -> str:
|
|
339
|
+
"""The line up to the value, then the masked value. Masking explicitly
|
|
340
|
+
(not via the generic evidence redaction) matters when the line is long:
|
|
341
|
+
a truncated snippet loses the closing quote that redaction keys on, and a
|
|
342
|
+
multi-line value such as a JSON-escaped PEM key would leak (#252)."""
|
|
343
|
+
group = "value" if match.group("value") is not None else "bare"
|
|
344
|
+
start = match.start(group)
|
|
345
|
+
line_start = content.rfind("\n", 0, start) + 1
|
|
346
|
+
head = content[line_start:start].lstrip()
|
|
347
|
+
if len(head) > 120:
|
|
348
|
+
head = "…" + head[-119:]
|
|
349
|
+
quote = content[start - 1] if group == "value" else ""
|
|
350
|
+
return f"{head}{_redact(value)}{quote}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _extract_secret_kvs(content: str, relative: str, result: ScanResult) -> None:
|
|
354
|
+
seen: set[tuple[str, int]] = {(h.name, h.line or 0) for h in result.secret_hints if h.file == relative}
|
|
355
|
+
index = _LineIndex(content)
|
|
356
|
+
for match in _SECRET_KEY_RE.finditer(content):
|
|
357
|
+
key = match.group("key")
|
|
358
|
+
value = match.group("value") or match.group("bare") or ""
|
|
359
|
+
if not value or _is_placeholder(value):
|
|
360
|
+
continue
|
|
361
|
+
# Line from the key itself: the pattern's leading `\s` alternative can
|
|
362
|
+
# consume the previous line's newline, so match.start() may sit one
|
|
363
|
+
# line early (#235).
|
|
364
|
+
line = index.line_of(match.start("key"))
|
|
365
|
+
# Store `key` as the SecretHint.name so consumers see WHICH secret
|
|
366
|
+
# was leaked (auth vs stripe vs db). Value itself is redacted into
|
|
367
|
+
# evidence_text via the same helper hardcoded-secret detection uses.
|
|
368
|
+
secret_key = (key, line)
|
|
369
|
+
if secret_key in seen:
|
|
370
|
+
continue
|
|
371
|
+
seen.add(secret_key)
|
|
372
|
+
result.secret_hints.append(
|
|
373
|
+
SecretHint(
|
|
374
|
+
name=key,
|
|
375
|
+
file=relative,
|
|
376
|
+
line=line,
|
|
377
|
+
evidence_text=_masked_kv_snippet(content, match, value),
|
|
378
|
+
confidence=0.9,
|
|
379
|
+
kind="config_literal",
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
__all__ = [
|
|
385
|
+
"scan_config_repo",
|
|
386
|
+
"should_scan_config_file",
|
|
387
|
+
"CONFIG_SUFFIXES",
|
|
388
|
+
"CONFIG_EXCLUDE_FILENAMES",
|
|
389
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import ScanResult
|
|
4
|
+
|
|
5
|
+
SCHEMA_VERSION = "1.0.0"
|
|
6
|
+
LOW_QUALITY_SEGMENTS = ["/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _infer_domain_hints(scan: ScanResult) -> dict:
|
|
10
|
+
route_values = [route.path.lower() for route in scan.routes]
|
|
11
|
+
hint_values = [hint.hint.lower() for hint in scan.auth_hints]
|
|
12
|
+
file_values = [route.file.lower() for route in scan.routes]
|
|
13
|
+
combined = " ".join([*route_values, *hint_values, *file_values])
|
|
14
|
+
|
|
15
|
+
tags: list[str] = []
|
|
16
|
+
signals: list[str] = []
|
|
17
|
+
|
|
18
|
+
if "/xrpc/" in combined or "atproto_" in combined or "com.atproto." in combined:
|
|
19
|
+
tags.append("atproto")
|
|
20
|
+
signals.append("xrpc/atproto namespace patterns observed")
|
|
21
|
+
if "app.bsky." in combined:
|
|
22
|
+
if "atproto" not in tags:
|
|
23
|
+
tags.append("atproto")
|
|
24
|
+
tags.append("bluesky")
|
|
25
|
+
signals.append("app.bsky namespace patterns observed")
|
|
26
|
+
if "laminas" in combined or "module.config.php" in combined:
|
|
27
|
+
tags.append("laminas")
|
|
28
|
+
signals.append("laminas/module config patterns observed")
|
|
29
|
+
|
|
30
|
+
if not tags:
|
|
31
|
+
tags.append("general")
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"tags": sorted(set(tags)),
|
|
35
|
+
"signals": sorted(set(signals)),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_review_context_pack(
|
|
40
|
+
review_json: dict,
|
|
41
|
+
scan: ScanResult,
|
|
42
|
+
analyzer_metadata: list[dict[str, object]],
|
|
43
|
+
) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"schema_version": SCHEMA_VERSION,
|
|
46
|
+
"artifact_type": "attackmap_review_context_pack",
|
|
47
|
+
"attackmap_review_json": review_json,
|
|
48
|
+
"analyzer_metadata_used": analyzer_metadata,
|
|
49
|
+
"source_quality_rules": {
|
|
50
|
+
"low_quality_path_segments": LOW_QUALITY_SEGMENTS,
|
|
51
|
+
"handling": {
|
|
52
|
+
"default": "down-rank low-quality signals in triage and prioritization",
|
|
53
|
+
"reporting": "retain low-quality signals in raw_structured_signals with explicit evidence-class labeling",
|
|
54
|
+
"operator_note": "do not treat low-quality-only evidence as strong production exposure without corroboration",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"output_hints": {
|
|
58
|
+
"audience": ["security engineers", "software engineers", "tech leads"],
|
|
59
|
+
"style": {
|
|
60
|
+
"tone": "defensive, concrete, non-hype",
|
|
61
|
+
"reasoning": "evidence-first and uncertainty-aware",
|
|
62
|
+
"disallowed": [
|
|
63
|
+
"inventing findings or architecture elements",
|
|
64
|
+
"offensive exploitation guidance",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
"prioritization": "focus on highest-impact, highest-confidence chains and trust-boundary issues first",
|
|
68
|
+
},
|
|
69
|
+
"domain_hints": _infer_domain_hints(scan),
|
|
70
|
+
"rag_expansion_hooks": {
|
|
71
|
+
"enabled": False,
|
|
72
|
+
"notes": "local-first context pack; retrieval slots intentionally minimal for future expansion",
|
|
73
|
+
"slots": [],
|
|
74
|
+
},
|
|
75
|
+
"limitations_meta": {
|
|
76
|
+
"analysis_mode": "heuristic",
|
|
77
|
+
"notes": [
|
|
78
|
+
"Domain tags are inferred heuristically from routes, hints, and file patterns.",
|
|
79
|
+
"Analyzer metadata reflects analyzers selected to run after detect-filtering.",
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
}
|
attackmap/contracts.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Cross-repo contract linking — cross-repo phase 2 (#146b).
|
|
2
|
+
|
|
3
|
+
Given a fleet of independently-scanned repos (#146a), link one repo's **outbound
|
|
4
|
+
HTTP calls** to another repo's **routes** — the client↔server seam. This is the
|
|
5
|
+
one linking dimension that reuses signals both sides already produce
|
|
6
|
+
(`ExternalCall.target`/`.method` and `Route.path`/`.method`); the richer
|
|
7
|
+
dimensions (RPC callers, shared schemas, queues, DB tables, token iss/aud) are
|
|
8
|
+
new extraction deferred to later work.
|
|
9
|
+
|
|
10
|
+
Matching is by **normalized path template + method**, cross-repo only:
|
|
11
|
+
|
|
12
|
+
- The call's URL path and the route's path are each normalized to a segment
|
|
13
|
+
tuple with concrete ids and path params collapsed to ``*`` (so
|
|
14
|
+
``/users/123`` ↔ ``/users/{id}`` ↔ ``/users/:id`` align).
|
|
15
|
+
- A link requires the normalized paths to be **equal** and the methods
|
|
16
|
+
compatible (equal, or either side unknown/``ANY``).
|
|
17
|
+
- Precision guards: the path must carry at least one **static** segment (a bare
|
|
18
|
+
``/{id}`` won't link), infra/static routes are excluded, and client and
|
|
19
|
+
server must be **different** repos.
|
|
20
|
+
|
|
21
|
+
Pure and deterministic — takes ``(repo_id, ScanResult)`` primitives so it stays
|
|
22
|
+
unit-testable without a real scan and imports nothing from the CLI/fleet layer.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
from .models import ExternalCall, Route, ScanResult
|
|
32
|
+
from .srcpaths import is_infra_route, is_test_file, is_vendored_file
|
|
33
|
+
|
|
34
|
+
# A path segment that is a concrete instance id rather than a static resource
|
|
35
|
+
# name: an explicit path param (`{id}`, `:id`, `<id>`), an all-digits segment,
|
|
36
|
+
# a uuid, or a long hex blob. Collapsed to `*` so caller and server templates
|
|
37
|
+
# align regardless of how each side writes the variable part.
|
|
38
|
+
_PARAM_SEG = re.compile(r"^([{:<].*|.*[}>]|\d+|[0-9a-fA-F-]{16,}|[0-9a-fA-F]{8,})$")
|
|
39
|
+
_PLACEHOLDER = "*"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ContractLink:
|
|
44
|
+
"""A matched client→server seam across two repos."""
|
|
45
|
+
|
|
46
|
+
client_repo: str
|
|
47
|
+
server_repo: str
|
|
48
|
+
method: str # the route's method (the served contract)
|
|
49
|
+
path_template: str # normalized template, e.g. "users/*"
|
|
50
|
+
client_target: str # the raw outbound URL as written in the caller
|
|
51
|
+
client_file: str
|
|
52
|
+
client_line: int | None
|
|
53
|
+
server_route_path: str # the route path as declared on the server
|
|
54
|
+
server_file: str
|
|
55
|
+
server_line: int | None
|
|
56
|
+
# The caller's concrete verb — retained so a state-changing call to a
|
|
57
|
+
# method-unspecified (`ANY`) server route (e.g. XRPC surfaces) isn't lost by
|
|
58
|
+
# downstream trust-gap analysis (#146d). `None` when unknown (config URL).
|
|
59
|
+
client_method: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_path(path: str, *, client: bool = False) -> tuple[str, ...] | None:
|
|
63
|
+
"""Normalize a URL/route path to a segment tuple with instance ids collapsed
|
|
64
|
+
to ``*``. Returns ``None`` when there is nothing linkable (no path, or only a
|
|
65
|
+
root / all-variable path with no static segment to anchor on).
|
|
66
|
+
|
|
67
|
+
``client=True`` handles the concatenation idiom: an outbound URL built as
|
|
68
|
+
``base + "/api/orders/" + oid`` is captured only up to the trailing slash, so
|
|
69
|
+
a client path ending in ``/`` is treated as having a dynamic final segment
|
|
70
|
+
(``api/orders/`` → ``("api","orders","*")``) — which then aligns with the
|
|
71
|
+
server template ``/api/orders/{id}`` while still separating from the bare
|
|
72
|
+
collection route ``/api/orders``."""
|
|
73
|
+
if not path:
|
|
74
|
+
return None
|
|
75
|
+
# A full URL (client side) — take just the path component.
|
|
76
|
+
if "://" in path:
|
|
77
|
+
path = urlsplit(path).path
|
|
78
|
+
else:
|
|
79
|
+
path = path.split("?", 1)[0].split("#", 1)[0]
|
|
80
|
+
trailing_slash = path.endswith("/") and path.strip() not in {"", "/"}
|
|
81
|
+
segments = [s for s in path.split("/") if s]
|
|
82
|
+
if not segments:
|
|
83
|
+
return None
|
|
84
|
+
norm = [_PLACEHOLDER if _PARAM_SEG.match(s) else s.lower() for s in segments]
|
|
85
|
+
if client and trailing_slash:
|
|
86
|
+
norm.append(_PLACEHOLDER) # the concatenated dynamic segment
|
|
87
|
+
# Require at least one static segment — a bare `/{id}` or `/*/*` is too
|
|
88
|
+
# generic to link on without producing spurious cross-repo edges.
|
|
89
|
+
if all(s == _PLACEHOLDER for s in norm):
|
|
90
|
+
return None
|
|
91
|
+
return tuple(norm)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _method_compatible(call_method: str | None, route_method: str) -> bool:
|
|
95
|
+
"""Methods match when they're equal, or either side is unknown/ANY."""
|
|
96
|
+
rm = (route_method or "ANY").upper()
|
|
97
|
+
if call_method is None or rm in {"", "ANY"}:
|
|
98
|
+
return True
|
|
99
|
+
return call_method.upper() == rm
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _excluded_source(rel_file: str) -> bool:
|
|
103
|
+
"""Test/spec/fixture and vendored/generated files are not production
|
|
104
|
+
architecture — a cross-repo link anchored on one is noise. Honors the same
|
|
105
|
+
`ATTACKMAP_INCLUDE_TESTS`/`_VENDORED` opt-ins as the rest of the scanner."""
|
|
106
|
+
return is_test_file(rel_file) or is_vendored_file(rel_file)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _linkable_calls(scan: ScanResult) -> list[tuple[ExternalCall, tuple[str, ...]]]:
|
|
110
|
+
out: list[tuple[ExternalCall, tuple[str, ...]]] = []
|
|
111
|
+
for call in scan.external_calls:
|
|
112
|
+
if _excluded_source(call.file):
|
|
113
|
+
continue
|
|
114
|
+
norm = _normalize_path(call.target, client=True)
|
|
115
|
+
if norm is None:
|
|
116
|
+
continue
|
|
117
|
+
if is_infra_route("/" + "/".join(norm)):
|
|
118
|
+
continue
|
|
119
|
+
out.append((call, norm))
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _linkable_routes(scan: ScanResult) -> list[tuple[Route, tuple[str, ...]]]:
|
|
124
|
+
out: list[tuple[Route, tuple[str, ...]]] = []
|
|
125
|
+
for route in scan.routes:
|
|
126
|
+
if _excluded_source(route.file):
|
|
127
|
+
continue
|
|
128
|
+
if is_infra_route(route.path):
|
|
129
|
+
continue
|
|
130
|
+
norm = _normalize_path(route.path)
|
|
131
|
+
if norm is None:
|
|
132
|
+
continue
|
|
133
|
+
out.append((route, norm))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def link_contracts(repo_scans: list[tuple[str, ScanResult]]) -> list[ContractLink]:
|
|
138
|
+
"""Return the client→server contract links across a fleet of repos.
|
|
139
|
+
|
|
140
|
+
For every ordered pair of *distinct* repos (A as client, B as server), match
|
|
141
|
+
A's linkable outbound calls to B's linkable routes by normalized path +
|
|
142
|
+
method. Deterministic; deduped on the full link identity.
|
|
143
|
+
"""
|
|
144
|
+
calls_by_repo = {rid: _linkable_calls(scan) for rid, scan in repo_scans}
|
|
145
|
+
routes_by_repo = {rid: _linkable_routes(scan) for rid, scan in repo_scans}
|
|
146
|
+
|
|
147
|
+
links: list[ContractLink] = []
|
|
148
|
+
seen: set[tuple] = set()
|
|
149
|
+
for client_id, _client_scan in repo_scans:
|
|
150
|
+
for call, call_norm in calls_by_repo[client_id]:
|
|
151
|
+
for server_id, _server_scan in repo_scans:
|
|
152
|
+
if server_id == client_id:
|
|
153
|
+
continue
|
|
154
|
+
for route, route_norm in routes_by_repo[server_id]:
|
|
155
|
+
if call_norm != route_norm:
|
|
156
|
+
continue
|
|
157
|
+
if not _method_compatible(call.method, route.method):
|
|
158
|
+
continue
|
|
159
|
+
key = (
|
|
160
|
+
client_id,
|
|
161
|
+
server_id,
|
|
162
|
+
call.file,
|
|
163
|
+
call.line,
|
|
164
|
+
route.file,
|
|
165
|
+
route.method,
|
|
166
|
+
route.path,
|
|
167
|
+
)
|
|
168
|
+
if key in seen:
|
|
169
|
+
continue
|
|
170
|
+
seen.add(key)
|
|
171
|
+
links.append(
|
|
172
|
+
ContractLink(
|
|
173
|
+
client_repo=client_id,
|
|
174
|
+
server_repo=server_id,
|
|
175
|
+
method=(route.method or "ANY").upper(),
|
|
176
|
+
path_template="/".join(call_norm),
|
|
177
|
+
client_target=call.target,
|
|
178
|
+
client_file=call.file,
|
|
179
|
+
client_line=call.line,
|
|
180
|
+
server_route_path=route.path,
|
|
181
|
+
server_file=route.file,
|
|
182
|
+
server_line=route.line,
|
|
183
|
+
client_method=(call.method.upper() if call.method else None),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
links.sort(
|
|
187
|
+
key=lambda link: (link.client_repo, link.server_repo, link.path_template, link.method)
|
|
188
|
+
)
|
|
189
|
+
return links
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def route_template(path: str) -> str | None:
|
|
193
|
+
"""Public helper: the normalized template for a route path (instance ids
|
|
194
|
+
collapsed to ``*``), or ``None`` if too generic to key on. Used by the
|
|
195
|
+
cross-repo anomaly cohorting (#146d) to group the *same* resource route
|
|
196
|
+
across repos."""
|
|
197
|
+
norm = _normalize_path(path)
|
|
198
|
+
return "/".join(norm) if norm is not None else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["ContractLink", "link_contracts", "route_template"]
|