src2sink 1.0.0__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 (81) hide show
  1. src2sink/__init__.py +1 -0
  2. src2sink/aggregators/__init__.py +3 -0
  3. src2sink/aggregators/api_client_discovery.py +259 -0
  4. src2sink/aggregators/auth_cards.py +116 -0
  5. src2sink/aggregators/crypto_cards.py +132 -0
  6. src2sink/aggregators/data_stores.py +125 -0
  7. src2sink/aggregators/graphs.py +36 -0
  8. src2sink/aggregators/index_v2.py +138 -0
  9. src2sink/aggregators/library_source_map.py +188 -0
  10. src2sink/aggregators/openapi_discovery.py +113 -0
  11. src2sink/aggregators/openapi_edges.py +30 -0
  12. src2sink/aggregators/openapi_match.py +81 -0
  13. src2sink/aggregators/openapi_models.py +25 -0
  14. src2sink/aggregators/openapi_report.py +75 -0
  15. src2sink/aggregators/payload_producers.py +369 -0
  16. src2sink/aggregators/phase3.py +30 -0
  17. src2sink/aggregators/pii_cross_repo.py +283 -0
  18. src2sink/aggregators/pii_flow_v2.py +118 -0
  19. src2sink/aggregators/pii_lifecycle.py +19 -0
  20. src2sink/aggregators/pii_lifecycle_report.py +121 -0
  21. src2sink/aggregators/pii_touchpoint_collect.py +134 -0
  22. src2sink/aggregators/pii_touchpoint_utils.py +96 -0
  23. src2sink/aggregators/queues.py +141 -0
  24. src2sink/aggregators/ropa.py +190 -0
  25. src2sink/aggregators/service_call_collect.py +240 -0
  26. src2sink/aggregators/service_call_index.py +31 -0
  27. src2sink/aggregators/service_call_models.py +17 -0
  28. src2sink/aggregators/service_call_report.py +118 -0
  29. src2sink/aggregators/service_calls.py +14 -0
  30. src2sink/aggregators/taint_buckets.py +74 -0
  31. src2sink/aggregators/taint_catalogs.py +35 -0
  32. src2sink/aggregators/taint_writers.py +369 -0
  33. src2sink/aggregators/traces_index.py +103 -0
  34. src2sink/build_metabase_v2.py +807 -0
  35. src2sink/constants.py +44 -0
  36. src2sink/curate_internal_libraries.py +135 -0
  37. src2sink/extractors/__init__.py +17 -0
  38. src2sink/extractors/ast_walk.py +131 -0
  39. src2sink/extractors/base.py +59 -0
  40. src2sink/extractors/config.py +224 -0
  41. src2sink/extractors/file_context.py +35 -0
  42. src2sink/extractors/go.py +3 -0
  43. src2sink/extractors/http_out.py +108 -0
  44. src2sink/extractors/java.py +3 -0
  45. src2sink/extractors/javascript.py +3 -0
  46. src2sink/extractors/kotlin.py +3 -0
  47. src2sink/extractors/node_factory.py +65 -0
  48. src2sink/extractors/patterns.py +145 -0
  49. src2sink/extractors/python_extractor.py +3 -0
  50. src2sink/extractors/regex_extractors.py +378 -0
  51. src2sink/extractors/ts_extractors.py +123 -0
  52. src2sink/extractors/unified.py +64 -0
  53. src2sink/graph_common.py +213 -0
  54. src2sink/internal_groups.py +142 -0
  55. src2sink/known_api_clients.py +108 -0
  56. src2sink/library_taint_java.py +92 -0
  57. src2sink/limits.py +171 -0
  58. src2sink/models/__init__.py +3 -0
  59. src2sink/models/auth_model.py +3 -0
  60. src2sink/models/crypto_agility.py +3 -0
  61. src2sink/models/pii_lifecycle.py +92 -0
  62. src2sink/models/ropa.py +37 -0
  63. src2sink/prescreen.py +93 -0
  64. src2sink/py.typed +0 -0
  65. src2sink/record_fleet_baseline.py +81 -0
  66. src2sink/renderers/__init__.py +3 -0
  67. src2sink/renderers/markdown.py +149 -0
  68. src2sink/renderers/mermaid.py +3 -0
  69. src2sink/repo_utils.py +885 -0
  70. src2sink/safe_paths.py +54 -0
  71. src2sink/sanitize.py +107 -0
  72. src2sink/schema.py +57 -0
  73. src2sink/trace.py +558 -0
  74. src2sink/trace_batch.py +176 -0
  75. src2sink/vocabulary.py +152 -0
  76. src2sink-1.0.0.dist-info/METADATA +355 -0
  77. src2sink-1.0.0.dist-info/RECORD +81 -0
  78. src2sink-1.0.0.dist-info/WHEEL +5 -0
  79. src2sink-1.0.0.dist-info/entry_points.txt +6 -0
  80. src2sink-1.0.0.dist-info/licenses/LICENSE +21 -0
  81. src2sink-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,807 @@
1
+ #!/usr/bin/env python3
2
+ """Metabase v2 extractor — flow-graph nodes (source / propagator / sink / store)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import dataclasses
8
+ import datetime as dt
9
+ import json
10
+ import multiprocessing as mp
11
+ import os
12
+ import re
13
+ import sys
14
+ from collections import Counter
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .internal_groups import add_internal_groups_arguments, apply_internal_groups_from_args
20
+ from .limits import (
21
+ DEFAULT_MAX_FILES_PER_REPO,
22
+ DEFAULT_PER_REPO_TIMEOUT_S,
23
+ map_with_timeout,
24
+ )
25
+ from . import prescreen
26
+ from .safe_paths import is_escaping_symlink
27
+ from .sanitize import redact_literals
28
+ from .repo_utils import (
29
+ classify_frameworks,
30
+ detect_build_systems,
31
+ detect_git_sha,
32
+ is_internal_coordinate,
33
+ is_skipped_path,
34
+ parse_package_json_dependencies,
35
+ parse_pom_dependencies,
36
+ )
37
+
38
+ from .aggregators.graphs import aggregate_graphs_v2
39
+ from .aggregators.phase3 import aggregate_phase3_v2
40
+ from .aggregators.pii_flow_v2 import write_pii_flow_v2
41
+ from .aggregators.taint_catalogs import aggregate_taint_catalogs_v2
42
+ from .constants import MAX_FILE_BYTES, SKIP_DIRS, SOURCE_EXTENSIONS
43
+ from .extractors.config import extract_from_config, is_config_path
44
+ from .extractors.unified import extract_from_file
45
+ from .renderers.markdown import merge_with_manual, render_repo_md_v2
46
+ from .schema import SCHEMA_VERSION, FlowEdge, FlowNode, RepoSummaryV2
47
+
48
+ ROOT = Path(__file__).resolve().parent.parent.parent
49
+
50
+ # Per-repo file-count cap, configured per worker via _worker_init (see D-4).
51
+ # A repo with more scannable files than this is truncated with a note rather
52
+ # than silently — protects against file-count amplification by a hostile repo.
53
+ _MAX_FILES_PER_REPO = DEFAULT_MAX_FILES_PER_REPO
54
+
55
+ # Per-file size cap in bytes, configured per worker via _worker_init (see D-4).
56
+ # A file larger than this is skipped with a recorded note rather than parsed;
57
+ # 0 disables the cap. Overridable with --max-file-bytes.
58
+ _MAX_FILE_BYTES = MAX_FILE_BYTES
59
+
60
+
61
+ def language_for_path(path: Path) -> str | None:
62
+ """Return the extractor language id for a file's extension, or None if unsupported."""
63
+ return SOURCE_EXTENSIONS.get(path.suffix.lower())
64
+
65
+
66
+ def is_skip_dir(name: str) -> bool:
67
+ """True if a directory name should be excluded from scanning."""
68
+ return name in SKIP_DIRS or (name.startswith(".") and name not in {".github"})
69
+
70
+
71
+ def iter_repo_files(repo_root: Path) -> Iterator[Path]:
72
+ """Yield every scannable file under a repo, skipping excluded dirs and escaping symlinks.
73
+
74
+ os.walk does not follow symlinked directories (followlinks defaults to
75
+ False), but it still lists symlinked *files*. Skip any file symlink whose
76
+ target escapes the repo so a crafted repo cannot exfiltrate outside content
77
+ (e.g. ``x.java -> /etc/passwd``) into the metabase. See threat-model T-2.
78
+ """
79
+ for dirpath, dirnames, filenames in os.walk(repo_root):
80
+ dirnames[:] = [d for d in dirnames if not is_skip_dir(d)]
81
+ for fn in filenames:
82
+ path = Path(dirpath) / fn
83
+ if is_escaping_symlink(path, repo_root):
84
+ continue
85
+ yield path
86
+
87
+
88
+ def safe_read_text(path: Path) -> str | None:
89
+ """Read a text file, returning None if it is oversized or unreadable.
90
+
91
+ Bounds untrusted scanned-repo files to ``_MAX_FILE_BYTES`` before reading
92
+ (0 disables the cap).
93
+ """
94
+ try:
95
+ if _MAX_FILE_BYTES and path.stat().st_size > _MAX_FILE_BYTES:
96
+ return None
97
+ except OSError:
98
+ return None
99
+ try:
100
+ return path.read_text(encoding="utf-8", errors="replace")
101
+ except (OSError, UnicodeDecodeError):
102
+ return None
103
+
104
+
105
+ def _apply_max_file_bytes(max_file_bytes: int) -> None:
106
+ """Propagate the per-file size cap to this process's read paths (0 disables).
107
+
108
+ Sets both this module's ``_MAX_FILE_BYTES`` (used by ``safe_read_text`` on the
109
+ node-producing scan path) and ``repo_utils.MAX_FILE_BYTES`` (manifest reads +
110
+ the XML size gates) so a ``--max-file-bytes`` override applies uniformly.
111
+ """
112
+ global _MAX_FILE_BYTES # noqa: PLW0603
113
+ _MAX_FILE_BYTES = max_file_bytes
114
+ from . import repo_utils
115
+ repo_utils.MAX_FILE_BYTES = max_file_bytes
116
+
117
+
118
+ def repo_relpath(repo_root: Path, abs_path: Path) -> str:
119
+ """Return abs_path relative to repo_root as a string, or the absolute path if unrelated."""
120
+ try:
121
+ return str(abs_path.relative_to(repo_root))
122
+ except ValueError:
123
+ return str(abs_path)
124
+
125
+
126
+ _GRADLE_DEP_RX = re.compile(
127
+ r"(?:implementation|api|compile|runtimeOnly|testImplementation)\s*"
128
+ r'[\(\s]+["\']'
129
+ r"([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+)(?::([A-Za-z0-9_.\-+]+))?"
130
+ )
131
+
132
+
133
+ def _parse_gradle_deps(gradle_path: Path) -> list[dict[str, str]]:
134
+ """Extract dependency coordinates from a Gradle build script via regex.
135
+
136
+ Scans untrusted source text; matches are only recorded, never evaluated.
137
+ """
138
+ text = safe_read_text(gradle_path)
139
+ if not text:
140
+ return []
141
+ deps: list[dict[str, str]] = []
142
+ for m in _GRADLE_DEP_RX.finditer(text):
143
+ gid, aid, ver = m.group(1), m.group(2), m.group(3) or ""
144
+ kind = "internal" if is_internal_coordinate(gid, aid) else "external"
145
+ deps.append({"groupId": gid, "artifactId": aid, "version": ver, "kind": kind})
146
+ return deps
147
+
148
+
149
+ def _collect_dependencies(repo_root: Path) -> list[dict[str, str]]:
150
+ """Gather declared dependencies from pom.xml, build.gradle*, and package.json."""
151
+ deps: list[dict[str, str]] = []
152
+ for pom in repo_root.rglob("pom.xml"):
153
+ if not is_skipped_path(pom, repo_root):
154
+ deps.extend(parse_pom_dependencies(pom))
155
+ for gradle in list(repo_root.rglob("build.gradle")) + list(
156
+ repo_root.rglob("build.gradle.kts")
157
+ ):
158
+ if not is_skipped_path(gradle, repo_root):
159
+ deps.extend(_parse_gradle_deps(gradle))
160
+ for pkg in repo_root.rglob("package.json"):
161
+ if not is_skipped_path(pkg, repo_root):
162
+ deps.extend(parse_package_json_dependencies(pkg))
163
+ return deps
164
+
165
+
166
+ def _record_dependencies(summary: RepoSummaryV2, deps: list[dict[str, str]]) -> None:
167
+ """De-duplicate deps by coordinate and split into internal / external count."""
168
+ seen_coord: set[str] = set()
169
+ for d in deps:
170
+ key = f"{d.get('groupId', '')}:{d.get('artifactId', '')}"
171
+ if key in seen_coord:
172
+ continue
173
+ seen_coord.add(key)
174
+ if d["kind"] == "internal":
175
+ summary.dependencies_internal.append(d)
176
+ else:
177
+ summary.dependencies_external_count += 1
178
+
179
+
180
+ def _scan_repo_files(
181
+ repo_root: Path, repo_id: str, summary: RepoSummaryV2
182
+ ) -> tuple[Counter[str], list[FlowNode], list[FlowEdge]]:
183
+ """Extract flow nodes/edges from each scannable file (with cap + pre-screen)."""
184
+ lang_counts: Counter[str] = Counter()
185
+ all_nodes: list[FlowNode] = []
186
+ all_edges: list[FlowEdge] = []
187
+ scanned = 0
188
+ for path in iter_repo_files(repo_root):
189
+ if _MAX_FILES_PER_REPO and scanned >= _MAX_FILES_PER_REPO:
190
+ summary.notes.append(
191
+ f"file cap reached ({_MAX_FILES_PER_REPO}); remaining files skipped"
192
+ )
193
+ break
194
+ scanned += 1
195
+ rel = repo_relpath(repo_root, path)
196
+ # Record oversized-file skips explicitly (they were previously silent —
197
+ # see SAST "no silent caps"): a big generated/bundled file dropping tens
198
+ # of thousands of nodes should tell the user which knob to turn.
199
+ try:
200
+ size = path.stat().st_size
201
+ except OSError:
202
+ continue
203
+ if _MAX_FILE_BYTES and size > _MAX_FILE_BYTES:
204
+ summary.notes.append(
205
+ f"skipped {rel}: file exceeds size cap "
206
+ f"({size} > {_MAX_FILE_BYTES} bytes; raise --max-file-bytes)"
207
+ )
208
+ continue
209
+ text = safe_read_text(path)
210
+ if not text:
211
+ continue
212
+
213
+ # Skip suspicious/pathological files before they reach the parsers.
214
+ skip_reason = prescreen.screen(path, text)
215
+ if skip_reason:
216
+ summary.notes.append(f"skipped {rel}: {skip_reason}")
217
+ continue
218
+
219
+ if is_config_path(path.name, path.suffix.lower()):
220
+ cnodes, cedges = extract_from_config(repo_id=repo_id, rel_path=rel, source=text)
221
+ all_nodes.extend(cnodes)
222
+ all_edges.extend(cedges)
223
+ continue
224
+
225
+ lang = language_for_path(path)
226
+ if not lang:
227
+ continue
228
+ lang_counts[lang] += 1
229
+ nodes, edges = extract_from_file(
230
+ repo_id=repo_id, rel_path=rel, language=lang, source=text
231
+ )
232
+ all_nodes.extend(nodes)
233
+ all_edges.extend(edges)
234
+ return lang_counts, all_nodes, all_edges
235
+
236
+
237
+ def analyse_repo_v2(repo_root: Path, group: str, name: str, path_rel: str) -> RepoSummaryV2:
238
+ """Analyse one repo: git SHA, build systems, dependencies, and extracted flow nodes/edges."""
239
+ repo_id = f"{group}/{name}"
240
+ summary = RepoSummaryV2(
241
+ group=group,
242
+ name=name,
243
+ path=path_rel,
244
+ schema_version=SCHEMA_VERSION,
245
+ )
246
+ summary.git_sha = detect_git_sha(repo_root)
247
+ summary.analysed_at = dt.datetime.now(dt.timezone.utc).isoformat()
248
+ summary.build_systems = detect_build_systems(repo_root)
249
+
250
+ deps = _collect_dependencies(repo_root)
251
+ _record_dependencies(summary, deps)
252
+ summary.frameworks = classify_frameworks(deps)
253
+
254
+ lang_counts, all_nodes, all_edges = _scan_repo_files(repo_root, repo_id, summary)
255
+ summary.language_breakdown = dict(lang_counts)
256
+ if lang_counts:
257
+ summary.primary_language = lang_counts.most_common(1)[0][0]
258
+ summary.file_counts = dict(lang_counts)
259
+ summary.nodes = all_nodes
260
+ summary.edges = all_edges
261
+ return summary
262
+
263
+
264
+ # Free-text node detail fields that may incidentally capture literal PII/secrets
265
+ # from source and must be redacted before they are persisted (see PRV-NEW-2 and
266
+ # SAST finding 4). Redacting here — once, at the source — means every downstream
267
+ # JSONL/Markdown writer inherits the masking. Symbol/field *names* are deliberately
268
+ # excluded: they are the tool's output, not values (and redact_literals is a no-op
269
+ # on a bare identifier anyway).
270
+ _REDACT_DETAIL_FIELDS = ("snippet", "raw", "url", "bucket", "endpoint_path")
271
+
272
+
273
+ def summary_to_dict(summary: RepoSummaryV2) -> dict[str, Any]:
274
+ """Serialize a repo summary to a JSON-ready dict, redacting literal PII/secret fields."""
275
+ d = dataclasses.asdict(summary)
276
+ node_dicts = [dataclasses.asdict(n) for n in summary.nodes]
277
+ for node in node_dicts:
278
+ detail = node.get("detail")
279
+ if isinstance(detail, dict):
280
+ for f in _REDACT_DETAIL_FIELDS:
281
+ if isinstance(detail.get(f), str):
282
+ detail[f] = redact_literals(detail[f])
283
+ d["nodes"] = node_dicts
284
+ d["edges"] = [dataclasses.asdict(e) for e in summary.edges]
285
+ return d
286
+
287
+
288
+ def _read_existing_sha(json_path: Path) -> str | None:
289
+ """Return the git_sha recorded in a prior run's JSON output, if present and readable."""
290
+ if not json_path.is_file():
291
+ return None
292
+ try:
293
+ data = json.loads(json_path.read_text(encoding="utf-8"))
294
+ except (OSError, json.JSONDecodeError):
295
+ return None
296
+ return data.get("git_sha") or None
297
+
298
+
299
+ def process_one_v2(args: tuple[Path, str, str, Path, bool]) -> dict[str, Any] | None:
300
+ """Worker unit of work: analyse one repo (unless unchanged) and write its JSON/Markdown."""
301
+ repo_root, group, name, metabase_root, force = args
302
+ json_path = metabase_root / "repos" / group / f"{name}.json"
303
+
304
+ if not force:
305
+ current_sha = detect_git_sha(repo_root)
306
+ if current_sha and current_sha == _read_existing_sha(json_path):
307
+ return {"_skipped": True, "group": group, "name": name}
308
+
309
+ try:
310
+ path_rel = str(repo_root.relative_to(repo_root.parent.parent))
311
+ except ValueError:
312
+ path_rel = str(repo_root)
313
+ try:
314
+ summary = analyse_repo_v2(repo_root, group, name, path_rel)
315
+ except Exception as exc: # pragma: no cover
316
+ # Report only the exception *type* + repo id — never the message, which
317
+ # could carry file paths or scanned content into CI logs (I-2).
318
+ return {"_error": True, "group": group, "name": name, "error": type(exc).__name__}
319
+
320
+ out_dir = metabase_root / "repos" / group
321
+ out_dir.mkdir(parents=True, exist_ok=True)
322
+ json_path = out_dir / f"{name}.json"
323
+ md_path = out_dir / f"{name}.md"
324
+ json_path.write_text(
325
+ json.dumps(summary_to_dict(summary), indent=2, sort_keys=True),
326
+ encoding="utf-8",
327
+ )
328
+ md_text = render_repo_md_v2(summary)
329
+ md_path.write_text(merge_with_manual(md_path, md_text), encoding="utf-8")
330
+
331
+ families = Counter(n.family for n in summary.nodes)
332
+ return {
333
+ "group": summary.group,
334
+ "name": summary.name,
335
+ "path": summary.path,
336
+ "git_sha": summary.git_sha,
337
+ "schema_version": summary.schema_version,
338
+ "primary_language": summary.primary_language,
339
+ "frameworks": summary.frameworks,
340
+ "nodes": len(summary.nodes),
341
+ "edges": len(summary.edges),
342
+ "sql_sinks": families.get("sql", 0),
343
+ "file_sinks": families.get("file", 0),
344
+ "http_out": families.get("http-out", 0),
345
+ "pii_log": families.get("pii-log", 0),
346
+ "raw_code_payload": families.get("raw-code-payload", 0),
347
+ }
348
+
349
+
350
+ def _worker_init(
351
+ pattern_strings: list[str],
352
+ api_clients_path: str,
353
+ max_files_per_repo: int,
354
+ max_file_bytes: int,
355
+ max_line_bytes: int,
356
+ prescreen_indicators: tuple[str, ...],
357
+ ) -> None:
358
+ """Multiprocessing pool initializer: configure this worker's global scan settings."""
359
+ from .internal_groups import configure_internal_group_patterns
360
+ from .known_api_clients import load_api_client_bindings, configure_api_client_bindings
361
+ from .extractors.http_out import configure_http_out_client_patterns
362
+ global _MAX_FILES_PER_REPO # noqa: PLW0603
363
+ _MAX_FILES_PER_REPO = max_files_per_repo
364
+ _apply_max_file_bytes(max_file_bytes)
365
+ prescreen.configure_max_line_bytes(max_line_bytes)
366
+ prescreen.configure_indicators(prescreen_indicators)
367
+ configure_internal_group_patterns(pattern_strings)
368
+ if api_clients_path:
369
+ bindings = load_api_client_bindings(Path(api_clients_path))
370
+ configure_api_client_bindings(bindings)
371
+ configure_http_out_client_patterns(bindings)
372
+
373
+
374
+ def _tool_version() -> str:
375
+ """Return the installed src2sink package version, or "unknown" if not installed."""
376
+ from importlib.metadata import PackageNotFoundError, version
377
+
378
+ try:
379
+ return version("src2sink")
380
+ except PackageNotFoundError: # pragma: no cover - only when not installed
381
+ return "unknown"
382
+
383
+
384
+ def _write_run_manifest(
385
+ metabase_root: Path,
386
+ args: argparse.Namespace,
387
+ rows: list[dict[str, Any]],
388
+ *,
389
+ skipped: int,
390
+ timed_out: int,
391
+ started_at: str,
392
+ finished_at: str,
393
+ ) -> None:
394
+ """Record run provenance for reproducibility / GDPR Art. 30 (finding R-1).
395
+
396
+ Captures the tool version, a *secret-free* summary of the invocation, the
397
+ per-repo SHAs of what was (re)built this run, and counts. No absolute paths
398
+ or config contents are recorded — only basenames/booleans.
399
+ """
400
+ manifest = {
401
+ "tool": "src2sink",
402
+ "tool_version": _tool_version(),
403
+ "started_at": started_at,
404
+ "finished_at": finished_at,
405
+ "invocation": {
406
+ "repos_root": Path(args.repos_root).name,
407
+ "metabase_root": Path(args.metabase_root).name,
408
+ "workers": args.workers,
409
+ "repo_timeout_s": args.repo_timeout,
410
+ "max_files_per_repo": args.max_files_per_repo,
411
+ "max_file_bytes": args.max_file_bytes,
412
+ "max_line_bytes": args.max_line_bytes,
413
+ "force": bool(args.force),
414
+ "repo_filter": args.repo or None,
415
+ "limit": args.limit or None,
416
+ "api_clients_configured": bool(args.api_clients),
417
+ "prescreen_indicators_configured": bool(args.prescreen_indicators),
418
+ },
419
+ "counts": {
420
+ "updated": len(rows),
421
+ "skipped": skipped,
422
+ "timed_out": timed_out,
423
+ },
424
+ "updated_repos": sorted(
425
+ (
426
+ {"repo": f"{r['group']}/{r['name']}", "git_sha": r.get("git_sha")}
427
+ for r in rows
428
+ ),
429
+ key=lambda e: str(e["repo"]),
430
+ ),
431
+ }
432
+ (metabase_root / "run-manifest.json").write_text(
433
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
434
+ )
435
+
436
+
437
+ def _load_v2_jsons(metabase_root: Path) -> list[Path]:
438
+ """Return paths to every repo JSON under metabase_root matching the current schema version."""
439
+ valid: list[Path] = []
440
+ for jp in sorted(metabase_root.glob("repos/*/*.json")):
441
+ try:
442
+ data = json.loads(jp.read_text(encoding="utf-8"))
443
+ except (OSError, json.JSONDecodeError):
444
+ continue
445
+ if data.get("schema_version") == SCHEMA_VERSION:
446
+ valid.append(jp)
447
+ return valid
448
+
449
+
450
+ def _build_arg_parser() -> argparse.ArgumentParser:
451
+ """Build the CLI argument parser for build_metabase_v2's extraction/aggregation modes."""
452
+ parser = argparse.ArgumentParser(description=__doc__)
453
+ parser.add_argument("--repos-root", required=True)
454
+ parser.add_argument("--metabase-root", required=True)
455
+ parser.add_argument("--repo", default=None)
456
+ parser.add_argument("--limit", type=int, default=0)
457
+ parser.add_argument("--workers", type=int, default=max(1, mp.cpu_count() - 1))
458
+ parser.add_argument(
459
+ "--repo-timeout",
460
+ type=int,
461
+ default=DEFAULT_PER_REPO_TIMEOUT_S,
462
+ help="Per-repo wall-clock timeout in seconds; a repo exceeding it is "
463
+ "killed and skipped (0 disables the timeout).",
464
+ )
465
+ parser.add_argument(
466
+ "--max-files-per-repo",
467
+ type=int,
468
+ default=DEFAULT_MAX_FILES_PER_REPO,
469
+ help="Cap on files scanned per repo; excess files are skipped with a "
470
+ "note (0 disables the cap).",
471
+ )
472
+ parser.add_argument(
473
+ "--max-file-bytes",
474
+ type=int,
475
+ default=MAX_FILE_BYTES,
476
+ help="Per-file size cap in bytes; a larger file is skipped with a note "
477
+ f"(default {MAX_FILE_BYTES}, 0 disables the cap).",
478
+ )
479
+ parser.add_argument(
480
+ "--prescreen-indicators",
481
+ default=None,
482
+ help="Path to a text file of content indicators (one substring per "
483
+ "line, '#' comments); files containing one are skipped before parsing. "
484
+ "Structural checks (binary/minified) always run regardless.",
485
+ )
486
+ parser.add_argument(
487
+ "--max-line-bytes",
488
+ type=int,
489
+ default=prescreen.MAX_LINE_BYTES,
490
+ help="Pre-screen threshold: a file with any single line longer than this "
491
+ "(minified/obfuscated) is skipped with a note "
492
+ f"(default {prescreen.MAX_LINE_BYTES}, 0 disables the check).",
493
+ )
494
+ parser.add_argument(
495
+ "--aggregate-only",
496
+ action="store_true",
497
+ help="Re-render v2 taint catalogues from existing schema_version=2 JSONs only.",
498
+ )
499
+ parser.add_argument(
500
+ "--graphs-only",
501
+ action="store_true",
502
+ help="Regenerate graphs/ from v2 JSONs (implies aggregate-only for graphs).",
503
+ )
504
+ parser.add_argument(
505
+ "--phase3-only",
506
+ action="store_true",
507
+ help="Regenerate Phase 3 outputs only (PII lifecycle, ROPA, auth/crypto cards).",
508
+ )
509
+ parser.add_argument(
510
+ "--no-phase3",
511
+ action="store_true",
512
+ help="Skip Phase 3 aggregators when rebuilding graphs.",
513
+ )
514
+ parser.add_argument(
515
+ "--force",
516
+ action="store_true",
517
+ help="Re-extract all repos even if git SHA matches the last run.",
518
+ )
519
+ parser.add_argument(
520
+ "--fix-source-map",
521
+ action="store_true",
522
+ help="Only resolve flagged library-source-map entries from on-disk "
523
+ "pom.xml groupId/artifactId, then exit (no extraction).",
524
+ )
525
+ add_internal_groups_arguments(parser)
526
+ parser.add_argument("--api-clients", default=None, help="Path to api-clients.json")
527
+ parser.add_argument(
528
+ "--discover-api-clients",
529
+ action="store_true",
530
+ help="After aggregation, draft candidate api-client bindings into "
531
+ "metabase/api-clients.discovered.json for review (never authoritative).",
532
+ )
533
+ parser.add_argument(
534
+ "--promote-api-clients",
535
+ action="store_true",
536
+ help="Only merge 'accepted' candidates from "
537
+ "metabase/api-clients.discovered.json into --api-clients (default "
538
+ "api-clients.json), then exit (no extraction).",
539
+ )
540
+ return parser
541
+
542
+
543
+ def _configure_api_clients(api_clients_path: str | None) -> None:
544
+ """Load and globally configure API-client bindings from an optional config file."""
545
+ if not api_clients_path:
546
+ return
547
+ from .known_api_clients import load_api_client_bindings, configure_api_client_bindings
548
+ from .extractors.http_out import configure_http_out_client_patterns
549
+ # warn=True only here (the single top-level load) so a malformed sensitive
550
+ # config is surfaced once, not silently ignored (I-3) and not per-worker.
551
+ bindings = load_api_client_bindings(Path(api_clients_path), warn=True)
552
+ if not bindings:
553
+ print(
554
+ f"WARNING: api-clients file '{Path(api_clients_path).name}' loaded "
555
+ "0 bindings (missing, unreadable, or malformed)",
556
+ file=sys.stderr,
557
+ )
558
+ configure_api_client_bindings(bindings)
559
+ configure_http_out_client_patterns(bindings)
560
+
561
+
562
+ def _discover_repos(
563
+ repos_root: Path, metabase_root: Path, args: argparse.Namespace, force: bool
564
+ ) -> list[tuple[Path, str, str, Path, bool]]:
565
+ """Enumerate (repo_dir, group, name) tuples under repos_root, applying repo filter and limit."""
566
+ discovered: list[tuple[Path, str, str, Path, bool]] = []
567
+ for group_dir in sorted(repos_root.iterdir()):
568
+ if not group_dir.is_dir() or group_dir.name in SKIP_DIRS:
569
+ continue
570
+ for repo_dir in sorted(group_dir.iterdir()):
571
+ if not repo_dir.is_dir() or repo_dir.name in SKIP_DIRS:
572
+ continue
573
+ rel = f"{group_dir.name}/{repo_dir.name}"
574
+ if args.repo and args.repo not in rel and not rel.endswith(args.repo):
575
+ continue
576
+ discovered.append(
577
+ (repo_dir, group_dir.name, repo_dir.name, metabase_root, force)
578
+ )
579
+ if args.limit:
580
+ discovered = discovered[: args.limit]
581
+ return discovered
582
+
583
+
584
+ def _generate_source_map(
585
+ metabase_root: Path, jsons: list[Path], repos_root: Path, *, prefix: str
586
+ ) -> None:
587
+ """Regenerate the library source map, then resolve any flagged entries from on-disk manifests."""
588
+ from .aggregators.library_source_map import (
589
+ fix_flagged_mappings,
590
+ generate_library_source_map,
591
+ )
592
+
593
+ generate_library_source_map(metabase_root, jsons)
594
+ fixed = fix_flagged_mappings(metabase_root, repos_root)
595
+ if fixed:
596
+ print(f"{prefix}resolved {fixed} flagged source-map entries from pom.xml")
597
+
598
+
599
+ def _run_aggregate_only(
600
+ metabase_root: Path, repos_root: Path, args: argparse.Namespace
601
+ ) -> int:
602
+ """Re-render aggregator outputs from existing v2 JSONs without re-scanning repos."""
603
+ jsons = _load_v2_jsons(metabase_root)
604
+ if not jsons:
605
+ print(
606
+ "No schema_version=2 JSONs found. Run build_metabase_v2.py "
607
+ "without --aggregate-only first.",
608
+ file=sys.stderr,
609
+ )
610
+ return 2
611
+ print(f"Aggregate-only: {len(jsons)} v2 JSONs")
612
+ if args.phase3_only:
613
+ write_pii_flow_v2(metabase_root, jsons)
614
+ aggregate_phase3_v2(metabase_root, jsons)
615
+ else:
616
+ if not args.graphs_only:
617
+ aggregate_taint_catalogs_v2(metabase_root, jsons)
618
+ aggregate_graphs_v2(
619
+ metabase_root, jsons, repos_root=repos_root, phase3=not args.no_phase3
620
+ )
621
+ _generate_source_map(metabase_root, jsons, repos_root, prefix=" ")
622
+ print("Done.")
623
+ return 0
624
+
625
+
626
+ def _classify_result(
627
+ r: dict[str, Any], args: argparse.Namespace
628
+ ) -> str:
629
+ """Print a per-repo result line and return its category for counting."""
630
+ if r.get("_timeout"):
631
+ print(
632
+ f" TIMEOUT {r.get('group', '?')}/{r.get('name', '?')} "
633
+ f"(exceeded {args.repo_timeout}s) — skipped",
634
+ file=sys.stderr,
635
+ )
636
+ return "timed_out"
637
+ if r.get("_skipped"):
638
+ print(f" skip {r['group']}/{r['name']} (unchanged)")
639
+ return "skipped"
640
+ if r.get("_error"):
641
+ print(
642
+ f" ERROR {r.get('group', '?')}/{r.get('name', '?')}: {r['error']}",
643
+ file=sys.stderr,
644
+ )
645
+ return "error"
646
+ print(f" done {r['group']}/{r['name']} nodes={r['nodes']}")
647
+ return "done"
648
+
649
+
650
+ def _dispatch_and_collect(
651
+ discovered: list[tuple[Path, str, str, Path, bool]],
652
+ args: argparse.Namespace,
653
+ pattern_strings: list[str],
654
+ prescreen_indicators: tuple[str, ...],
655
+ ) -> tuple[list[dict[str, Any]], int, int]:
656
+ """Run extraction across repos with the per-repo bulkhead; collect results."""
657
+ rows: list[dict[str, Any]] = []
658
+ skipped = 0
659
+ timed_out = 0
660
+
661
+ def _on_timeout(item: tuple[Path, str, str, Path, bool]) -> dict[str, Any]:
662
+ """Build the timeout-marker result for a repo whose worker was killed."""
663
+ return {"_timeout": True, "group": item[1], "name": item[2]}
664
+
665
+ for r in map_with_timeout(
666
+ process_one_v2,
667
+ discovered,
668
+ workers=args.workers,
669
+ timeout=args.repo_timeout,
670
+ initializer=_worker_init,
671
+ initargs=(
672
+ pattern_strings,
673
+ args.api_clients or "",
674
+ args.max_files_per_repo,
675
+ args.max_file_bytes,
676
+ args.max_line_bytes,
677
+ prescreen_indicators,
678
+ ),
679
+ on_timeout=_on_timeout,
680
+ ):
681
+ if not r:
682
+ continue
683
+ category = _classify_result(r, args)
684
+ if category == "timed_out":
685
+ timed_out += 1
686
+ elif category == "skipped":
687
+ skipped += 1
688
+ elif category == "done":
689
+ rows.append(r)
690
+ return rows, skipped, timed_out
691
+
692
+
693
+ def _limits_hit_summary(json_paths: list[Path], timed_out: int) -> str:
694
+ """Return a one-line hint naming every scan limit that skipped content.
695
+
696
+ Scans the persisted repo ``notes`` for the cap markers plus the timeout
697
+ counter, so the run-end log tells the user content was dropped and which
698
+ knob adjusts each limit. Returns "" when nothing was capped.
699
+ """
700
+ hits: set[str] = set()
701
+ for jp in json_paths:
702
+ try:
703
+ data = json.loads(jp.read_text(encoding="utf-8"))
704
+ except (OSError, json.JSONDecodeError):
705
+ continue
706
+ for note in data.get("notes", []):
707
+ if "file exceeds size cap" in note:
708
+ hits.add("per-file size cap (--max-file-bytes)")
709
+ elif "file cap reached" in note:
710
+ hits.add("per-repo file cap (--max-files-per-repo)")
711
+ elif "oversized line" in note:
712
+ hits.add("minified/oversized-line pre-screen (--max-line-bytes)")
713
+ if timed_out:
714
+ hits.add("per-repo timeout (--repo-timeout)")
715
+ if not hits:
716
+ return ""
717
+ return " ⚠ limits hit — content was skipped: " + "; ".join(sorted(hits))
718
+
719
+
720
+ def main() -> int:
721
+ """CLI entry point: discover repos, extract in parallel, aggregate, and write the run manifest."""
722
+ args = _build_arg_parser().parse_args()
723
+
724
+ repos_root = Path(args.repos_root).resolve()
725
+ metabase_root = Path(args.metabase_root).resolve()
726
+ pattern_strings = apply_internal_groups_from_args(args)
727
+ # Apply the size cap in the parent too, so aggregation-phase manifest reads
728
+ # (identity index) honour a --max-file-bytes override; workers set it again.
729
+ _apply_max_file_bytes(args.max_file_bytes)
730
+ # Skip when promoting: that flow *writes* --api-clients, so pre-loading the
731
+ # not-yet-created file would emit a spurious "0 bindings loaded" warning.
732
+ if not args.promote_api_clients:
733
+ _configure_api_clients(args.api_clients)
734
+
735
+ if not repos_root.is_dir():
736
+ print(f"repos-root not a directory: {repos_root}", file=sys.stderr)
737
+ return 2
738
+ metabase_root.mkdir(parents=True, exist_ok=True)
739
+
740
+ if args.fix_source_map:
741
+ from .aggregators.library_source_map import fix_flagged_mappings
742
+ fixed = fix_flagged_mappings(metabase_root, repos_root)
743
+ print(f"Resolved {fixed} flagged source-map entries from pom.xml")
744
+ return 0
745
+
746
+ if args.promote_api_clients:
747
+ from .aggregators.api_client_discovery import promote_api_clients
748
+ target = Path(args.api_clients) if args.api_clients else Path("api-clients.json")
749
+ merged = promote_api_clients(metabase_root, target)
750
+ print(f"Promoted {merged} accepted candidate binding(s) into {target.name}")
751
+ return 0
752
+
753
+ if args.aggregate_only or args.graphs_only or args.phase3_only:
754
+ return _run_aggregate_only(metabase_root, repos_root, args)
755
+
756
+ started_at = dt.datetime.now(dt.timezone.utc).isoformat()
757
+ force = args.force
758
+ discovered = _discover_repos(repos_root, metabase_root, args, force)
759
+
760
+ # Load pre-screen indicators once in the parent; pass the list to workers.
761
+ prescreen_indicators: tuple[str, ...] = ()
762
+ if args.prescreen_indicators:
763
+ prescreen_indicators = prescreen.load_indicators(Path(args.prescreen_indicators))
764
+ print(
765
+ f"loaded {len(prescreen_indicators)} pre-screen indicator(s) from "
766
+ f"'{Path(args.prescreen_indicators).name}'"
767
+ )
768
+
769
+ print(
770
+ f"v2: {len(discovered)} repos, workers={args.workers}, "
771
+ f"repo-timeout={args.repo_timeout}s{' (force)' if force else ''}"
772
+ )
773
+ rows, skipped, timed_out = _dispatch_and_collect(
774
+ discovered, args, pattern_strings, prescreen_indicators
775
+ )
776
+
777
+ jsons = _load_v2_jsons(metabase_root)
778
+ if jsons:
779
+ aggregate_taint_catalogs_v2(metabase_root, jsons)
780
+ aggregate_graphs_v2(metabase_root, jsons, repos_root=repos_root)
781
+ _generate_source_map(metabase_root, jsons, repos_root, prefix="")
782
+ if args.discover_api_clients:
783
+ from .aggregators.api_client_discovery import (
784
+ DISCOVERED_FILE,
785
+ discover_api_clients,
786
+ )
787
+ n = discover_api_clients(metabase_root, jsons, repos_root)
788
+ print(f"Discovered {n} candidate api-client binding(s) → metabase/{DISCOVERED_FILE}")
789
+ _write_run_manifest(
790
+ metabase_root,
791
+ args,
792
+ rows,
793
+ skipped=skipped,
794
+ timed_out=timed_out,
795
+ started_at=started_at,
796
+ finished_at=dt.datetime.now(dt.timezone.utc).isoformat(),
797
+ )
798
+ print(
799
+ f"Finished {len(rows)} updated, {skipped} skipped, {timed_out} timed out, "
800
+ f"{len(jsons)} v2 JSONs for aggregation."
801
+ f"{_limits_hit_summary(jsons, timed_out)}"
802
+ )
803
+ return 0
804
+
805
+
806
+ if __name__ == "__main__":
807
+ raise SystemExit(main())