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.
- src2sink/__init__.py +1 -0
- src2sink/aggregators/__init__.py +3 -0
- src2sink/aggregators/api_client_discovery.py +259 -0
- src2sink/aggregators/auth_cards.py +116 -0
- src2sink/aggregators/crypto_cards.py +132 -0
- src2sink/aggregators/data_stores.py +125 -0
- src2sink/aggregators/graphs.py +36 -0
- src2sink/aggregators/index_v2.py +138 -0
- src2sink/aggregators/library_source_map.py +188 -0
- src2sink/aggregators/openapi_discovery.py +113 -0
- src2sink/aggregators/openapi_edges.py +30 -0
- src2sink/aggregators/openapi_match.py +81 -0
- src2sink/aggregators/openapi_models.py +25 -0
- src2sink/aggregators/openapi_report.py +75 -0
- src2sink/aggregators/payload_producers.py +369 -0
- src2sink/aggregators/phase3.py +30 -0
- src2sink/aggregators/pii_cross_repo.py +283 -0
- src2sink/aggregators/pii_flow_v2.py +118 -0
- src2sink/aggregators/pii_lifecycle.py +19 -0
- src2sink/aggregators/pii_lifecycle_report.py +121 -0
- src2sink/aggregators/pii_touchpoint_collect.py +134 -0
- src2sink/aggregators/pii_touchpoint_utils.py +96 -0
- src2sink/aggregators/queues.py +141 -0
- src2sink/aggregators/ropa.py +190 -0
- src2sink/aggregators/service_call_collect.py +240 -0
- src2sink/aggregators/service_call_index.py +31 -0
- src2sink/aggregators/service_call_models.py +17 -0
- src2sink/aggregators/service_call_report.py +118 -0
- src2sink/aggregators/service_calls.py +14 -0
- src2sink/aggregators/taint_buckets.py +74 -0
- src2sink/aggregators/taint_catalogs.py +35 -0
- src2sink/aggregators/taint_writers.py +369 -0
- src2sink/aggregators/traces_index.py +103 -0
- src2sink/build_metabase_v2.py +807 -0
- src2sink/constants.py +44 -0
- src2sink/curate_internal_libraries.py +135 -0
- src2sink/extractors/__init__.py +17 -0
- src2sink/extractors/ast_walk.py +131 -0
- src2sink/extractors/base.py +59 -0
- src2sink/extractors/config.py +224 -0
- src2sink/extractors/file_context.py +35 -0
- src2sink/extractors/go.py +3 -0
- src2sink/extractors/http_out.py +108 -0
- src2sink/extractors/java.py +3 -0
- src2sink/extractors/javascript.py +3 -0
- src2sink/extractors/kotlin.py +3 -0
- src2sink/extractors/node_factory.py +65 -0
- src2sink/extractors/patterns.py +145 -0
- src2sink/extractors/python_extractor.py +3 -0
- src2sink/extractors/regex_extractors.py +378 -0
- src2sink/extractors/ts_extractors.py +123 -0
- src2sink/extractors/unified.py +64 -0
- src2sink/graph_common.py +213 -0
- src2sink/internal_groups.py +142 -0
- src2sink/known_api_clients.py +108 -0
- src2sink/library_taint_java.py +92 -0
- src2sink/limits.py +171 -0
- src2sink/models/__init__.py +3 -0
- src2sink/models/auth_model.py +3 -0
- src2sink/models/crypto_agility.py +3 -0
- src2sink/models/pii_lifecycle.py +92 -0
- src2sink/models/ropa.py +37 -0
- src2sink/prescreen.py +93 -0
- src2sink/py.typed +0 -0
- src2sink/record_fleet_baseline.py +81 -0
- src2sink/renderers/__init__.py +3 -0
- src2sink/renderers/markdown.py +149 -0
- src2sink/renderers/mermaid.py +3 -0
- src2sink/repo_utils.py +885 -0
- src2sink/safe_paths.py +54 -0
- src2sink/sanitize.py +107 -0
- src2sink/schema.py +57 -0
- src2sink/trace.py +558 -0
- src2sink/trace_batch.py +176 -0
- src2sink/vocabulary.py +152 -0
- src2sink-1.0.0.dist-info/METADATA +355 -0
- src2sink-1.0.0.dist-info/RECORD +81 -0
- src2sink-1.0.0.dist-info/WHEEL +5 -0
- src2sink-1.0.0.dist-info/entry_points.txt +6 -0
- src2sink-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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())
|