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
src2sink/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """src2sink — extract and sync source-repo metabase records."""
@@ -0,0 +1,3 @@
1
+ """Cross-repo aggregators (service calls, queues, data stores) — Phase 2."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,259 @@
1
+ """Discover candidate ``api-clients.json`` bindings from the metabase (plan B11).
2
+
3
+ ``api-clients.json`` maps a first-party HTTP *client library* to the backend
4
+ *service* it talks to (see ``docs/api-clients-json.md``). Those edges cannot be
5
+ inferred from consumer source alone, so the file is normally authored by hand.
6
+
7
+ This module drafts **candidates** — never authoritative entries — by mining data
8
+ the scan already produced:
9
+
10
+ * a consumer repo's ``dependencies_internal`` naming a ``*-client`` / ``*-sdk``
11
+ artifact identifies it as a *consumer* of that client library;
12
+ * the component-identity index (``repo_utils._build_component_identity_index``)
13
+ resolves that artifact's coordinate to the *publishing* repo — the candidate
14
+ ``target_repo``;
15
+ * the target repo's ``http-in`` nodes supply candidate ``paths``.
16
+
17
+ Because it runs in the aggregation phase (whole fleet in memory) it needs no new
18
+ extractor and is unaffected by the worker-init binding-load ordering. Candidates
19
+ are written to ``metabase/api-clients.discovered.json`` with a ``confidence`` and
20
+ an ``evidence`` block. A human sets each candidate's ``status`` to ``accepted``
21
+ or ``rejected``; :func:`promote_api_clients` then merges only the accepted ones
22
+ into the authoritative (gitignored) file. Nothing is ever auto-merged — an
23
+ unverified binding poisons the taint graph with phantom cross-service edges.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from collections import defaultdict
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from ..graph_common import load_v2_repo_records, repo_id
34
+
35
+ DISCOVERED_FILE = "api-clients.discovered.json"
36
+
37
+ # Human decisions on a candidate, preserved verbatim across regeneration.
38
+ STATUS_PENDING = "pending"
39
+ STATUS_ACCEPTED = "accepted"
40
+ STATUS_REJECTED = "rejected"
41
+
42
+ # Fields a reviewer may tune; preserved once they mark a candidate non-pending.
43
+ _TUNABLE_FIELDS = (
44
+ "target_repo",
45
+ "maven_artifact",
46
+ "import_prefix",
47
+ "paths",
48
+ "payload_fields",
49
+ "service_aliases",
50
+ "class_patterns",
51
+ )
52
+
53
+ # Artifact-id suffixes that strongly signal a published HTTP client library.
54
+ _CLIENT_SUFFIXES = (
55
+ "-client",
56
+ "-api-client",
57
+ "-rest-client",
58
+ "-client-java",
59
+ "-java-client",
60
+ "-sdk",
61
+ "-sdk-java",
62
+ )
63
+
64
+
65
+ def _looks_like_client_artifact(artifact: str) -> bool:
66
+ """True if an artifact id looks like a published HTTP client library."""
67
+ a = (artifact or "").lower()
68
+ return any(a.endswith(suffix) for suffix in _CLIENT_SUFFIXES)
69
+
70
+
71
+ def _http_in_paths_by_repo(records: list[dict[str, Any]]) -> dict[str, set[str]]:
72
+ """Map each repo id to the set of ``http-in`` endpoint paths it declares."""
73
+ out: dict[str, set[str]] = defaultdict(set)
74
+ for data in records:
75
+ rid = repo_id(data)
76
+ for node in data.get("nodes", []):
77
+ if node.get("family") != "http-in":
78
+ continue
79
+ path = (node.get("detail") or {}).get("path")
80
+ if isinstance(path, str) and path:
81
+ out[rid].add(path)
82
+ return out
83
+
84
+
85
+ def _key(target_repo: str, artifact: str) -> str:
86
+ """Stable candidate key used for de-dup and for preserving reviewer edits."""
87
+ return f"{target_repo}\t{artifact}"
88
+
89
+
90
+ def _load_discovered(path: Path) -> dict[str, dict[str, Any]]:
91
+ """Return prior candidates keyed by (target_repo, artifact), or {} if absent."""
92
+ try:
93
+ data = json.loads(path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError):
95
+ return {}
96
+ out: dict[str, dict[str, Any]] = {}
97
+ for c in data.get("candidates", []):
98
+ if isinstance(c, dict) and "maven_artifact" in c:
99
+ out[_key(c.get("target_repo", ""), c["maven_artifact"])] = c
100
+ return out
101
+
102
+
103
+ def _confidence(target_repo: str, scanned: set[str], has_paths: bool) -> str:
104
+ """Rate a candidate: resolved-and-in-fleet-with-paths is strongest."""
105
+ if target_repo and target_repo in scanned:
106
+ return "high" if has_paths else "medium"
107
+ if target_repo:
108
+ return "medium" # resolved to a path not present as a scanned repo
109
+ return "low" # artifact looks like a client but coordinate did not resolve
110
+
111
+
112
+ def _collect_candidates(
113
+ records: list[dict[str, Any]],
114
+ resolve: Any,
115
+ ) -> dict[str, dict[str, Any]]:
116
+ """Mine consumer ``dependencies_internal`` into per-(target, artifact) candidates."""
117
+ paths_by_repo = _http_in_paths_by_repo(records)
118
+ cands: dict[str, dict[str, Any]] = {}
119
+ for data in records:
120
+ consumer = repo_id(data)
121
+ for dep in data.get("dependencies_internal", []):
122
+ aid = dep.get("artifactId", "")
123
+ gid = dep.get("groupId", "")
124
+ if not _looks_like_client_artifact(aid):
125
+ continue
126
+ coord = f"{gid}:{aid}" if gid else aid
127
+ target = resolve(coord) or ""
128
+ key = _key(target, aid)
129
+ cand = cands.setdefault(
130
+ key,
131
+ {
132
+ "target_repo": target,
133
+ "maven_artifact": aid,
134
+ "import_prefix": gid, # best-effort; reviewer tightens
135
+ "paths": sorted(paths_by_repo.get(target, ())),
136
+ "payload_fields": ["sql"], # documented default assumption
137
+ "service_aliases": [target.split("/")[-1]] if target else [],
138
+ "class_patterns": [],
139
+ "coordinate": coord,
140
+ "consumers": set(),
141
+ },
142
+ )
143
+ cand["consumers"].add(consumer)
144
+ return cands
145
+
146
+
147
+ def _build_entry(
148
+ cand: dict[str, Any],
149
+ scanned: set[str],
150
+ prev: dict[str, Any] | None,
151
+ ) -> dict[str, Any]:
152
+ """Assemble a candidate entry, preserving a reviewer's non-pending decision."""
153
+ consumers = sorted(cand.pop("consumers"))
154
+ coordinate = cand.pop("coordinate")
155
+ has_paths = bool(cand["paths"])
156
+ evidence = {
157
+ "coordinate": coordinate,
158
+ "consumers": consumers,
159
+ "resolved": bool(cand["target_repo"]),
160
+ "paths_from_target_scan": has_paths and cand["target_repo"] in scanned,
161
+ }
162
+ # A reviewer who set status to accepted/rejected has tuned the binding; keep
163
+ # their edits and status, only refreshing evidence + confidence.
164
+ if prev and prev.get("status") in (STATUS_ACCEPTED, STATUS_REJECTED):
165
+ entry = {k: prev.get(k, cand.get(k)) for k in _TUNABLE_FIELDS}
166
+ entry["status"] = prev["status"]
167
+ else:
168
+ entry = {k: cand[k] for k in _TUNABLE_FIELDS}
169
+ entry["status"] = STATUS_PENDING
170
+ entry["confidence"] = _confidence(
171
+ str(entry["target_repo"] or ""), scanned, bool(entry["paths"])
172
+ )
173
+ entry["evidence"] = evidence
174
+ return entry
175
+
176
+
177
+ def discover_api_clients(
178
+ metabase_root: Path,
179
+ repo_jsons: list[Path],
180
+ repos_root: Path,
181
+ ) -> int:
182
+ """Write ``metabase/api-clients.discovered.json`` and return the candidate count.
183
+
184
+ Regenerates every candidate from the current metabase, preserving the
185
+ ``status`` and tuned fields of any candidate a reviewer has already accepted
186
+ or rejected. Never touches the authoritative ``api-clients.json``.
187
+ """
188
+ from ..repo_utils import _build_component_identity_index
189
+ from .library_source_map import _resolve_clone_path
190
+
191
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
192
+ by_coord, by_name, by_full = _build_component_identity_index(repos_root)
193
+
194
+ def resolve(coord: str) -> str | None:
195
+ return _resolve_clone_path(coord, by_coord, by_name, by_full)
196
+
197
+ scanned = {repo_id(d) for d in records}
198
+ cands = _collect_candidates(records, resolve)
199
+ prev = _load_discovered(metabase_root / DISCOVERED_FILE)
200
+
201
+ entries = [
202
+ _build_entry(cand, scanned, prev.get(key))
203
+ for key, cand in sorted(cands.items())
204
+ ]
205
+ out = {
206
+ "_comment": (
207
+ "CANDIDATE api-client bindings discovered by src2sink-build "
208
+ "--discover-api-clients. NOT authoritative. Review each entry, set "
209
+ "'status' to 'accepted' or 'rejected' (tightening import_prefix/paths/"
210
+ "payload_fields as needed), then run --promote-api-clients to merge the "
211
+ "accepted ones into api-clients.json. Same sensitivity as that file."
212
+ ),
213
+ "candidates": entries,
214
+ }
215
+ (metabase_root / DISCOVERED_FILE).write_text(
216
+ json.dumps(out, indent=2, sort_keys=False) + "\n", encoding="utf-8"
217
+ )
218
+ return len(entries)
219
+
220
+
221
+ def _load_bindings(path: Path) -> list[dict[str, Any]]:
222
+ """Return the authoritative bindings list, or [] if the file is missing/invalid."""
223
+ try:
224
+ data = json.loads(path.read_text(encoding="utf-8"))
225
+ except (OSError, json.JSONDecodeError):
226
+ return []
227
+ bindings = data.get("bindings")
228
+ return [b for b in bindings if isinstance(b, dict)] if isinstance(bindings, list) else []
229
+
230
+
231
+ def promote_api_clients(metabase_root: Path, target_path: Path) -> int:
232
+ """Merge ``accepted`` candidates into the authoritative ``api-clients.json``.
233
+
234
+ Only candidates whose ``status`` a reviewer set to ``accepted`` are merged
235
+ (by ``(target_repo, maven_artifact)``): a new coordinate is appended, an
236
+ existing one is updated in place. Idempotent — re-running merges the same set
237
+ without creating duplicates. Returns the number of accepted candidates merged.
238
+ """
239
+ candidates = _load_discovered(metabase_root / DISCOVERED_FILE).values()
240
+ accepted = [c for c in candidates if c.get("status") == STATUS_ACCEPTED]
241
+ if not accepted:
242
+ return 0
243
+
244
+ bindings = _load_bindings(target_path)
245
+ index = {(b.get("target_repo"), b.get("maven_artifact")): b for b in bindings}
246
+ for c in accepted:
247
+ binding = {k: c[k] for k in _TUNABLE_FIELDS if k in c}
248
+ key = (binding.get("target_repo"), binding.get("maven_artifact"))
249
+ if key in index:
250
+ index[key].update(binding)
251
+ else:
252
+ bindings.append(binding)
253
+ index[key] = binding
254
+
255
+ target_path.write_text(
256
+ json.dumps({"bindings": bindings}, indent=2, sort_keys=False) + "\n",
257
+ encoding="utf-8",
258
+ )
259
+ return len(accepted)
@@ -0,0 +1,116 @@
1
+ """Per-repo auth model cards from v2 auth + http-in nodes (Phase 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..graph_common import iter_nodes, load_v2_repo_records, repo_id
11
+ from ..renderers.markdown import md_table
12
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE
13
+
14
+ MAX_CARD_ROWS = 500
15
+
16
+
17
+ def _auth_maturity(patterns: "Counter[str]", http_in: int) -> str:
18
+ """Classify a repo's auth posture from its pattern counts and http-in count."""
19
+ if patterns.get("secured-authenticated") or patterns.get("spring-pre-authorize"):
20
+ return "authenticated-default"
21
+ if patterns.get("permit-all-config") or patterns.get("is-anonymous"):
22
+ return "anonymous-or-permit-all"
23
+ if http_in and not patterns:
24
+ return "http-without-auth-annotations"
25
+ return "unknown"
26
+
27
+
28
+ def _collect_repo_auth(data: dict[str, Any]) -> dict[str, Any]:
29
+ """Build one repo's auth card from its `auth` and `http-in` flow nodes."""
30
+ rid = repo_id(data)
31
+ patterns: Counter[str] = Counter()
32
+ http_in = 0
33
+ public_paths: list[str] = []
34
+
35
+ for node in iter_nodes(data):
36
+ family = node.get("family", "")
37
+ if family == "auth":
38
+ label = (node.get("detail") or {}).get("pattern", "unknown")
39
+ patterns[label] += 1
40
+ elif family == "http-in":
41
+ http_in += 1
42
+ path = (node.get("detail") or {}).get("path", "")
43
+ is_public = patterns.get("permit-all-config") or patterns.get("is-anonymous")
44
+ if is_public and path and len(public_paths) < 10:
45
+ public_paths.append(path)
46
+
47
+ frameworks = data.get("frameworks") or []
48
+ maturity = _auth_maturity(patterns, http_in)
49
+
50
+ return {
51
+ "repo": rid,
52
+ "frameworks": frameworks,
53
+ "http_in_count": http_in,
54
+ "auth_patterns": dict(patterns.most_common(12)),
55
+ "maturity": maturity,
56
+ "sample_public_paths": public_paths[:5],
57
+ }
58
+
59
+
60
+ def write_auth_models_catalog(
61
+ metabase_root: Path,
62
+ repo_jsons: list[Path] | None = None,
63
+ ) -> list[dict[str, Any]]:
64
+ """Write auth-model cards (JSONL + markdown) to conventions/ and return them."""
65
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
66
+ cards = [_collect_repo_auth(data) for data in records]
67
+
68
+ conv_dir = metabase_root / "conventions"
69
+ conv_dir.mkdir(parents=True, exist_ok=True)
70
+ jsonl_path = conv_dir / "auth-models.jsonl"
71
+ with jsonl_path.open("w", encoding="utf-8") as fh:
72
+ for card in cards:
73
+ fh.write(json.dumps(card, ensure_ascii=False) + "\n")
74
+
75
+ fleet_patterns: Counter[str] = Counter()
76
+ maturity_counts: Counter[str] = Counter()
77
+ for card in cards:
78
+ maturity_counts[card["maturity"]] += 1
79
+ for pat, count in card["auth_patterns"].items():
80
+ fleet_patterns[pat] += count
81
+
82
+ body_parts = [
83
+ "# Auth-model conventions (v2)\n",
84
+ UNTRUSTED_CONTENT_NOTICE,
85
+ "_Per-repo cards from `auth` and `http-in` flow nodes. High counts of "
86
+ "`permit-all` / `is-anonymous` warrant manual review._\n",
87
+ "\n## Fleet pattern frequency\n",
88
+ md_table(
89
+ ["Pattern", "Count"],
90
+ [[p, str(c)] for p, c in fleet_patterns.most_common(30)],
91
+ ),
92
+ "\n## Maturity buckets\n",
93
+ md_table(
94
+ ["Bucket", "Repos"],
95
+ [[k, str(v)] for k, v in maturity_counts.most_common()],
96
+ ),
97
+ "\n## Per-repo cards (sample)\n",
98
+ md_table(
99
+ ["Repo", "Maturity", "HTTP-in", "Top auth patterns"],
100
+ [
101
+ [
102
+ c["repo"],
103
+ c["maturity"],
104
+ str(c["http_in_count"]),
105
+ ", ".join(f"{k}({v})" for k, v in list(c["auth_patterns"].items())[:4]),
106
+ ]
107
+ for c in sorted(cards, key=lambda x: x["repo"])[:MAX_CARD_ROWS]
108
+ ],
109
+ ),
110
+ ]
111
+ tail = max(0, len(cards) - MAX_CARD_ROWS)
112
+ if tail:
113
+ body_parts.append(f"\n_{tail} more in `auth-models.jsonl`._\n")
114
+
115
+ (conv_dir / "auth-models.md").write_text("\n".join(body_parts), encoding="utf-8")
116
+ return cards
@@ -0,0 +1,132 @@
1
+ """Per-repo crypto agility cards from v2 crypto nodes (Phase 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..graph_common import iter_nodes, load_v2_repo_records, repo_id
11
+ from ..renderers.markdown import md_table
12
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE, for_markdown
13
+
14
+ WEAK_ALGORITHMS = frozenset({
15
+ "MD5", "SHA1", "SHA-1", "DES", "RC4", "3DES", "ECB",
16
+ })
17
+ MAX_CARD_ROWS = 500
18
+
19
+
20
+ def _crypto_maturity(has_algorithms: bool, has_key_sources: bool) -> str:
21
+ """Classify a repo's crypto posture from algorithm and key-source presence."""
22
+ if has_algorithms and has_key_sources:
23
+ return "config-driven"
24
+ if has_algorithms:
25
+ return "hardcoded-algorithms"
26
+ if has_key_sources:
27
+ return "config-keys-only"
28
+ return "none-detected"
29
+
30
+
31
+ def _collect_repo_crypto(data: dict[str, Any]) -> dict[str, Any]:
32
+ """Build one repo's crypto card from its crypto-related flow nodes."""
33
+ rid = repo_id(data)
34
+ algorithms: Counter[str] = Counter()
35
+ key_sources: Counter[str] = Counter()
36
+ weak: list[str] = []
37
+
38
+ for node in iter_nodes(data):
39
+ family = node.get("family", "")
40
+ detail = node.get("detail") or {}
41
+ if family == "crypto-algorithm":
42
+ algo = (detail.get("algorithm") or "?").upper()
43
+ algorithms[algo] += 1
44
+ if algo in WEAK_ALGORITHMS or "MD5" in algo or "SHA1" in algo:
45
+ # raw is PII-redacted upstream; neutralise the markdown structure of
46
+ # both the sample and the untrusted file path (SAST finding 1 class).
47
+ weak.append(
48
+ f"{for_markdown(detail.get('raw', algo), max_len=60)} @ "
49
+ f"{for_markdown(node.get('file'), max_len=80)}:{node.get('line')}"
50
+ )
51
+ elif family == "crypto-config":
52
+ src = detail.get("key_source") or detail.get("source") or "config"
53
+ key_sources[str(src)] += 1
54
+ elif family == "config-security":
55
+ key_sources["config-security"] += 1
56
+
57
+ maturity = _crypto_maturity(bool(algorithms), bool(key_sources))
58
+
59
+ return {
60
+ "repo": rid,
61
+ "maturity": maturity,
62
+ "algorithms": dict(algorithms.most_common(15)),
63
+ "key_sources": dict(key_sources.most_common(10)),
64
+ "weak_samples": weak[:5],
65
+ }
66
+
67
+
68
+ def write_crypto_agility_catalog(
69
+ metabase_root: Path,
70
+ repo_jsons: list[Path] | None = None,
71
+ ) -> list[dict[str, Any]]:
72
+ """Write crypto-agility cards (JSONL + markdown) to conventions/ and return them."""
73
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
74
+ cards = [_collect_repo_crypto(data) for data in records]
75
+
76
+ conv_dir = metabase_root / "conventions"
77
+ conv_dir.mkdir(parents=True, exist_ok=True)
78
+ jsonl_path = conv_dir / "crypto-agility.jsonl"
79
+ with jsonl_path.open("w", encoding="utf-8") as fh:
80
+ for card in cards:
81
+ fh.write(json.dumps(card, ensure_ascii=False) + "\n")
82
+
83
+ algo_fleet: Counter[str] = Counter()
84
+ maturity_counts: Counter[str] = Counter()
85
+ weak_repos = 0
86
+ for card in cards:
87
+ maturity_counts[card["maturity"]] += 1
88
+ if card["weak_samples"]:
89
+ weak_repos += 1
90
+ for algo, count in card["algorithms"].items():
91
+ algo_fleet[algo] += count
92
+
93
+ body_parts = [
94
+ "# Crypto agility (v2)\n",
95
+ UNTRUSTED_CONTENT_NOTICE,
96
+ "_Algorithms from `crypto-algorithm` nodes; key sources from "
97
+ "`crypto-config` / `config-security`. Classify repos as hardcoded vs "
98
+ "config-driven for rotation planning._\n",
99
+ "\n## Fleet algorithms\n",
100
+ md_table(
101
+ ["Algorithm", "Count"],
102
+ [[a, str(c)] for a, c in algo_fleet.most_common(25)],
103
+ ),
104
+ "\n## Maturity buckets\n",
105
+ md_table(
106
+ ["Bucket", "Repos"],
107
+ [[k, str(v)] for k, v in maturity_counts.most_common()],
108
+ ),
109
+ f"\n_Repos with weak algorithm hits: **{weak_repos}**._\n",
110
+ "\n## Per-repo cards (sample)\n",
111
+ md_table(
112
+ ["Repo", "Maturity", "Algorithms", "Key sources", "Weak"],
113
+ [
114
+ [
115
+ c["repo"],
116
+ c["maturity"],
117
+ ", ".join(list(c["algorithms"].keys())[:4]),
118
+ ", ".join(list(c["key_sources"].keys())[:3]) or "—",
119
+ "yes" if c["weak_samples"] else "—",
120
+ ]
121
+ for c in sorted(cards, key=lambda x: x["repo"])[:MAX_CARD_ROWS]
122
+ ],
123
+ ),
124
+ ]
125
+ tail = max(0, len(cards) - MAX_CARD_ROWS)
126
+ if tail:
127
+ body_parts.append(f"\n_{tail} more in `crypto-agility.jsonl`._\n")
128
+
129
+ md_path = conv_dir / "crypto-agility.md"
130
+ body = "\n".join(body_parts)
131
+ md_path.write_text(body, encoding="utf-8")
132
+ return cards
@@ -0,0 +1,125 @@
1
+ """Data-store graph aggregator — v2 config + code store nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter, defaultdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..graph_common import iter_nodes, load_v2_repo_records, repo_id, store_key_from_node
11
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE, for_markdown
12
+ from ..renderers.markdown import md_table
13
+
14
+
15
+ def _collect_stores(
16
+ records: list[dict[str, Any]],
17
+ ) -> tuple[dict[str, set[str]], dict[str, dict[str, Any]], "Counter[str]"]:
18
+ """Collect store->repos, store metadata, and per-repo SQL execution sink counts."""
19
+ store_repos: dict[str, set[str]] = defaultdict(set)
20
+ store_meta: dict[str, dict[str, Any]] = {}
21
+ sql_execution_by_repo: Counter[str] = Counter()
22
+ for data in records:
23
+ rid = repo_id(data)
24
+ for node in iter_nodes(data):
25
+ family = node.get("family", "")
26
+ kind = node.get("kind")
27
+ if family == "data-store" and kind == "store":
28
+ key = store_key_from_node(node)
29
+ if not key:
30
+ continue
31
+ store_repos[key].add(rid)
32
+ detail = node.get("detail") or {}
33
+ store_meta.setdefault(key, {
34
+ "vendor": detail.get("vendor", "?"),
35
+ "sample_file": f"{node.get('file')}:{node.get('line')}",
36
+ })
37
+ elif family == "sql" and kind == "sink" and (node.get("detail") or {}).get("execution", True):
38
+ sql_execution_by_repo[rid] += 1
39
+ return store_repos, store_meta, sql_execution_by_repo
40
+
41
+
42
+ def _stores_by_vendor(
43
+ store_repos: dict[str, set[str]], store_meta: dict[str, dict[str, Any]]
44
+ ) -> dict[str, list[tuple[str, list[str]]]]:
45
+ """Group stores by vendor into (store_key, sorted repos) entries."""
46
+ by_vendor: dict[str, list[tuple[str, list[str]]]] = defaultdict(list)
47
+ for key, repos in sorted(store_repos.items(), key=lambda x: (-len(x[1]), x[0])):
48
+ vendor = store_meta.get(key, {}).get("vendor", key.split(":")[0])
49
+ by_vendor[vendor].append((key, sorted(repos)))
50
+ return by_vendor
51
+
52
+
53
+ def _write_data_store_jsonl(
54
+ path: Path, store_repos: dict[str, set[str]], store_meta: dict[str, dict[str, Any]]
55
+ ) -> None:
56
+ """Write one JSON line per store with vendor, referencing repos, and sample file."""
57
+ with path.open("w", encoding="utf-8") as fh:
58
+ for key, repos in sorted(store_repos.items()):
59
+ meta = store_meta.get(key, {})
60
+ fh.write(json.dumps({
61
+ "store_key": key,
62
+ "vendor": meta.get("vendor"),
63
+ "repos": sorted(repos),
64
+ "sample_file": meta.get("sample_file"),
65
+ }, ensure_ascii=False) + "\n")
66
+
67
+
68
+ def write_data_store_graph(metabase_root: Path, repo_jsons: list[Path]) -> None:
69
+ """Write the bipartite data-store graph markdown + jsonl from v2 store nodes."""
70
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
71
+ store_repos, store_meta, sql_execution_by_repo = _collect_stores(records)
72
+
73
+ out_dir = metabase_root / "graphs"
74
+ out_dir.mkdir(parents=True, exist_ok=True)
75
+ by_vendor = _stores_by_vendor(store_repos, store_meta)
76
+
77
+ md: list[str] = [
78
+ "# Data-store graph (v2)\n",
79
+ UNTRUSTED_CONTENT_NOTICE,
80
+ "_Bipartite view: config-discovered stores (`data-store` nodes) and "
81
+ "repos that reference them. SQL execution sink counts show repos with "
82
+ "runtime DB access even when JDBC URL is only in deployed config._\n",
83
+ "\n## Summary\n",
84
+ md_table(
85
+ ["Vendor", "Store count", "Repos touching"],
86
+ [
87
+ [
88
+ v,
89
+ str(len(entries)),
90
+ str(len({r for _, rs in entries for r in rs})),
91
+ ]
92
+ for v, entries in sorted(by_vendor.items())
93
+ ],
94
+ ),
95
+ ]
96
+
97
+ for vendor, entries in sorted(by_vendor.items()):
98
+ # vendor is an untrusted extracted config value written into a heading.
99
+ safe_vendor = for_markdown(vendor, max_len=80)
100
+ md.append(f"\n## {safe_vendor}\n\n")
101
+ md.append(md_table(
102
+ ["Store key", "Repos", "Sample config ref"],
103
+ [
104
+ [
105
+ key,
106
+ ", ".join(repos[:8]) + (" …" if len(repos) > 8 else ""),
107
+ store_meta.get(key, {}).get("sample_file", ""),
108
+ ]
109
+ for key, repos in entries[:80]
110
+ ],
111
+ ))
112
+ if len(entries) > 80:
113
+ md.append(f"\n_{len(entries) - 80} more {safe_vendor} stores in jsonl._\n")
114
+
115
+ md.append("\n## Repos with SQL execution sinks (no config store required)\n\n")
116
+ md.append(md_table(
117
+ ["Repo", "SQL execution sink count"],
118
+ [
119
+ [repo, str(count)]
120
+ for repo, count in sql_execution_by_repo.most_common(100)
121
+ ],
122
+ ))
123
+
124
+ (out_dir / "data-store-graph.md").write_text("\n".join(md), encoding="utf-8")
125
+ _write_data_store_jsonl(out_dir / "data-store-graph.jsonl", store_repos, store_meta)
@@ -0,0 +1,36 @@
1
+ """Orchestrate v2 cross-repo graph generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .data_stores import write_data_store_graph
8
+ from .index_v2 import write_index_v2
9
+ from .openapi_edges import write_openapi_artifacts
10
+ from .payload_producers import write_payload_producer_index
11
+ from .phase3 import aggregate_phase3_v2
12
+ from .pii_flow_v2 import write_pii_flow_v2
13
+ from .queues import write_queue_graph
14
+ from .service_calls import write_service_call_graph
15
+ from .traces_index import write_traces_index
16
+
17
+
18
+ def aggregate_graphs_v2(
19
+ metabase_root: Path,
20
+ repo_jsons: list[Path],
21
+ *,
22
+ repos_root: Path | None = None,
23
+ phase3: bool = True,
24
+ ) -> None:
25
+ """Run all v2 cross-repo graph writers (service, queue, data-store, PII, phase 3)."""
26
+ write_service_call_graph(metabase_root, repo_jsons, repos_root=repos_root)
27
+ if repos_root:
28
+ write_openapi_artifacts(metabase_root, repos_root, repo_jsons)
29
+ write_queue_graph(metabase_root, repo_jsons)
30
+ write_data_store_graph(metabase_root, repo_jsons)
31
+ write_payload_producer_index(metabase_root, repo_jsons, repos_root=repos_root)
32
+ write_index_v2(metabase_root, repo_jsons)
33
+ write_pii_flow_v2(metabase_root, repo_jsons)
34
+ write_traces_index(metabase_root)
35
+ if phase3:
36
+ aggregate_phase3_v2(metabase_root, repo_jsons)