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
src2sink/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""src2sink — extract and sync source-repo metabase records."""
|
|
@@ -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)
|