attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -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"]