sourcecode 3.1.0__py3-none-any.whl → 3.2.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.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +128 -9
- sourcecode/detectors/java.py +10 -5
- sourcecode/integration_coordinates.py +175 -0
- sourcecode/mcp/registry.py +3 -1
- sourcecode/mcp/server.py +5 -2
- sourcecode/migrate_check.py +22 -1
- sourcecode/migration_blast.py +232 -0
- sourcecode/repository_ir.py +25 -17
- sourcecode/retrieval/steps_graph.py +10 -8
- sourcecode/retrieval/steps_intf.py +23 -1
- sourcecode/security_posture.py +165 -19
- sourcecode/semantic_integration_engine.py +54 -2
- sourcecode/spring_impact.py +124 -7
- sourcecode/spring_semantic.py +56 -4
- {sourcecode-3.1.0.dist-info → sourcecode-3.2.0.dist-info}/METADATA +3 -3
- {sourcecode-3.1.0.dist-info → sourcecode-3.2.0.dist-info}/RECORD +20 -18
- {sourcecode-3.1.0.dist-info → sourcecode-3.2.0.dist-info}/WHEEL +0 -0
- {sourcecode-3.1.0.dist-info → sourcecode-3.2.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.1.0.dist-info → sourcecode-3.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Migration blast radius — which request surface a migration finding sits under.
|
|
2
|
+
|
|
3
|
+
`migrate-check` answers *what must change*: the files whose `javax.*` imports, XML
|
|
4
|
+
descriptors and build coordinates block a Spring Boot 2→3 upgrade. It does not answer
|
|
5
|
+
*what has to be re-tested when that change lands*, which is the question a migration
|
|
6
|
+
plan is actually built around.
|
|
7
|
+
|
|
8
|
+
This module answers it as a rule over the existing IR — no new detector, no second
|
|
9
|
+
traversal engine. Each affected file resolves to the types it declares, those types are
|
|
10
|
+
run through the same caller BFS `impact-chain` uses (`spring_impact._bfs_callers` +
|
|
11
|
+
`_collect_endpoints`), and the reachable HTTP endpoints become that file's re-test set.
|
|
12
|
+
|
|
13
|
+
Honesty contract:
|
|
14
|
+
* A `javax→jakarta` finding breaks compilation whether or not an endpoint reaches it.
|
|
15
|
+
`endpoints_reached` ranks *regression scope*, never "what fails to build".
|
|
16
|
+
* A file whose declared types the IR never resolved is reported in `files_unresolved`
|
|
17
|
+
with reach `unknown` — never folded into the zero-reach bucket.
|
|
18
|
+
* A repository the IR sees no endpoints in gets `request_surface_share: null` and a
|
|
19
|
+
note saying ranking fell back to caller counts, never a manufactured 0.0.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
25
|
+
|
|
26
|
+
from sourcecode.spring_impact import _bfs_callers, _collect_endpoints
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
29
|
+
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
30
|
+
from sourcecode.migrate_check import MigrationFinding
|
|
31
|
+
from sourcecode.spring_model import SpringSemanticModel
|
|
32
|
+
|
|
33
|
+
SCHEMA_VERSION = "1.0"
|
|
34
|
+
|
|
35
|
+
DEFAULT_DEPTH = 4
|
|
36
|
+
DEFAULT_TOP_N = 25
|
|
37
|
+
_ENDPOINTS_PER_FILE = 10
|
|
38
|
+
|
|
39
|
+
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
40
|
+
_TYPE_KINDS = ("class", "interface", "enum", "annotation")
|
|
41
|
+
|
|
42
|
+
_REACH_NOTE = (
|
|
43
|
+
"endpoints_reached ranks REGRESSION SCOPE — the requests whose call path runs "
|
|
44
|
+
"through the file — not what fails to build. A javax→jakarta finding breaks "
|
|
45
|
+
"compilation whether or not any endpoint reaches it."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _types_by_file(cir: "CanonicalRepositoryIR") -> dict[str, list[str]]:
|
|
50
|
+
"""Map source file → the type FQNs declared in it, from the IR's graph nodes."""
|
|
51
|
+
raw = getattr(cir, "_raw_ir", None) or {}
|
|
52
|
+
nodes = (raw.get("graph") or {}).get("nodes") or []
|
|
53
|
+
out: dict[str, list[str]] = {}
|
|
54
|
+
for node in nodes:
|
|
55
|
+
if not isinstance(node, dict):
|
|
56
|
+
continue
|
|
57
|
+
src = node.get("source_file") or ""
|
|
58
|
+
fqn = node.get("fqn") or ""
|
|
59
|
+
if not src or not fqn or "#" in fqn:
|
|
60
|
+
continue
|
|
61
|
+
if (node.get("symbol_kind") or node.get("type")) not in _TYPE_KINDS:
|
|
62
|
+
continue
|
|
63
|
+
out.setdefault(src, []).append(fqn)
|
|
64
|
+
for src in out:
|
|
65
|
+
out[src] = sorted(set(out[src]))
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _max_severity(findings: list["MigrationFinding"]) -> str:
|
|
70
|
+
return min(
|
|
71
|
+
(f.severity for f in findings),
|
|
72
|
+
key=lambda s: _SEVERITY_ORDER.get(s, 3),
|
|
73
|
+
default="low",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_migration_blast(
|
|
78
|
+
cir: "CanonicalRepositoryIR",
|
|
79
|
+
model: "SpringSemanticModel",
|
|
80
|
+
findings: list["MigrationFinding"],
|
|
81
|
+
*,
|
|
82
|
+
depth: int = DEFAULT_DEPTH,
|
|
83
|
+
top_n: int = DEFAULT_TOP_N,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Resolve the request surface behind each migration finding.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
cir: CanonicalRepositoryIR for the same repository that was scanned.
|
|
89
|
+
model: SpringSemanticModel built from that CIR (endpoint index).
|
|
90
|
+
findings: MigrationFinding list from `run_migrate_check`.
|
|
91
|
+
depth: Caller BFS depth, the same knob `impact-chain --depth` exposes.
|
|
92
|
+
top_n: Files retained in `files`; the rest are counted in `files_omitted`.
|
|
93
|
+
|
|
94
|
+
Returns a JSON-serializable dict. Never raises on a partial IR: anything it
|
|
95
|
+
could not resolve is reported as unresolved rather than dropped.
|
|
96
|
+
"""
|
|
97
|
+
by_file: dict[str, list["MigrationFinding"]] = {}
|
|
98
|
+
non_source = 0
|
|
99
|
+
non_product = 0
|
|
100
|
+
for f in findings:
|
|
101
|
+
src = getattr(f, "source_file", "") or ""
|
|
102
|
+
if not src.endswith(".java"):
|
|
103
|
+
# XML descriptors and build coordinates carry no type the call graph
|
|
104
|
+
# can reach; they are counted, never ranked.
|
|
105
|
+
non_source += 1
|
|
106
|
+
continue
|
|
107
|
+
if getattr(f, "code_context", "main") != "main":
|
|
108
|
+
# Test and generated sources are outside the product blast radius, the
|
|
109
|
+
# same boundary blocking_count draws.
|
|
110
|
+
non_product += 1
|
|
111
|
+
continue
|
|
112
|
+
by_file.setdefault(src, []).append(f)
|
|
113
|
+
|
|
114
|
+
declared = _types_by_file(cir)
|
|
115
|
+
impl_graph = getattr(cir, "implementation_graph", None)
|
|
116
|
+
reverse_graph = getattr(cir, "reverse_graph", None) or {}
|
|
117
|
+
endpoints_in_repo = len(getattr(cir, "endpoints", None) or [])
|
|
118
|
+
|
|
119
|
+
rows: list[dict[str, Any]] = []
|
|
120
|
+
unresolved: list[str] = []
|
|
121
|
+
reached_ids: set[str] = set()
|
|
122
|
+
|
|
123
|
+
for src in sorted(by_file):
|
|
124
|
+
seeds = declared.get(src) or []
|
|
125
|
+
if not seeds:
|
|
126
|
+
unresolved.append(src)
|
|
127
|
+
continue
|
|
128
|
+
direct, indirect, truncated, *_rest = _bfs_callers(
|
|
129
|
+
seeds, reverse_graph, depth, impl_graph=impl_graph,
|
|
130
|
+
)
|
|
131
|
+
endpoints = _collect_endpoints(list(direct) + list(indirect), seeds, model)
|
|
132
|
+
for ep in endpoints:
|
|
133
|
+
reached_ids.add(ep.endpoint_id)
|
|
134
|
+
file_findings = by_file[src]
|
|
135
|
+
shown = sorted(
|
|
136
|
+
(e.to_dict() for e in endpoints),
|
|
137
|
+
key=lambda e: (e["path"], e["method"], e["endpoint_id"]),
|
|
138
|
+
)
|
|
139
|
+
rows.append({
|
|
140
|
+
"file": src,
|
|
141
|
+
"max_severity": _max_severity(file_findings),
|
|
142
|
+
"finding_count": len(file_findings),
|
|
143
|
+
"rule_ids": sorted({f.rule_id for f in file_findings}),
|
|
144
|
+
"types": seeds,
|
|
145
|
+
"direct_callers": len(direct),
|
|
146
|
+
"indirect_callers": len(indirect),
|
|
147
|
+
"traversal_truncated": bool(truncated),
|
|
148
|
+
"endpoints_reached": len(endpoints),
|
|
149
|
+
"endpoints": shown[:_ENDPOINTS_PER_FILE],
|
|
150
|
+
"endpoints_omitted": max(0, len(shown) - _ENDPOINTS_PER_FILE),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
rows.sort(key=lambda r: (
|
|
154
|
+
-r["endpoints_reached"],
|
|
155
|
+
-(r["direct_callers"] + r["indirect_callers"]),
|
|
156
|
+
_SEVERITY_ORDER.get(r["max_severity"], 3),
|
|
157
|
+
r["file"],
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
on_path = sum(1 for r in rows if r["endpoints_reached"] > 0)
|
|
161
|
+
notes: list[str] = [_REACH_NOTE]
|
|
162
|
+
if endpoints_in_repo:
|
|
163
|
+
share = round(len(reached_ids) / endpoints_in_repo, 4)
|
|
164
|
+
else:
|
|
165
|
+
share = None
|
|
166
|
+
notes.append(
|
|
167
|
+
"The IR resolved no HTTP endpoints in this repository, so no request "
|
|
168
|
+
"surface can be attributed — files are ranked by caller count alone."
|
|
169
|
+
)
|
|
170
|
+
if unresolved:
|
|
171
|
+
notes.append(
|
|
172
|
+
f"{len(unresolved)} file(s) carry a finding but declare no type the IR "
|
|
173
|
+
"resolved (parse gap or non-type source); their reach is unknown, not zero."
|
|
174
|
+
)
|
|
175
|
+
if any(r["traversal_truncated"] for r in rows):
|
|
176
|
+
notes.append(
|
|
177
|
+
"At least one traversal hit the hub-class guard and was capped — those "
|
|
178
|
+
"files' reach is a lower bound."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"schema_version": SCHEMA_VERSION,
|
|
183
|
+
"depth": depth,
|
|
184
|
+
"endpoints_in_repo": endpoints_in_repo,
|
|
185
|
+
"endpoints_reached": len(reached_ids),
|
|
186
|
+
"request_surface_share": share,
|
|
187
|
+
"files_analyzed": len(rows),
|
|
188
|
+
"files_on_request_path": on_path,
|
|
189
|
+
"files_off_request_path": len(rows) - on_path,
|
|
190
|
+
"files_unresolved": sorted(unresolved),
|
|
191
|
+
"findings_not_source": non_source,
|
|
192
|
+
"findings_not_product": non_product,
|
|
193
|
+
"files": rows[:top_n],
|
|
194
|
+
"files_omitted": max(0, len(rows) - top_n),
|
|
195
|
+
"notes": notes,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_blast_text(blast: Optional[dict], *, top_n: int = 10) -> str:
|
|
200
|
+
"""Render the blast-radius block as the text-format section."""
|
|
201
|
+
if not blast:
|
|
202
|
+
return ""
|
|
203
|
+
lines: list[str] = ["Migration blast radius (regression scope)"]
|
|
204
|
+
share = blast.get("request_surface_share")
|
|
205
|
+
if share is None:
|
|
206
|
+
lines.append(
|
|
207
|
+
f" {blast.get('endpoints_in_repo', 0)} endpoints in IR — "
|
|
208
|
+
"no request surface attributable; ranked by callers."
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
lines.append(
|
|
212
|
+
f" {blast.get('endpoints_reached', 0)}/{blast.get('endpoints_in_repo', 0)} "
|
|
213
|
+
f"endpoints reached ({share * 100:.1f}% of the request surface)"
|
|
214
|
+
)
|
|
215
|
+
lines.append(
|
|
216
|
+
f" Files on a request path: {blast.get('files_on_request_path', 0)}"
|
|
217
|
+
f" of {blast.get('files_analyzed', 0)} analyzed"
|
|
218
|
+
+ (f" [{len(blast.get('files_unresolved') or [])} unresolved]"
|
|
219
|
+
if blast.get("files_unresolved") else "")
|
|
220
|
+
)
|
|
221
|
+
for row in (blast.get("files") or [])[:top_n]:
|
|
222
|
+
lines.append(
|
|
223
|
+
f" {row['endpoints_reached']:>4} endpoints "
|
|
224
|
+
f"[{row['max_severity'].upper()}] {row['file']} "
|
|
225
|
+
f"({row['direct_callers']}+{row['indirect_callers']} callers)"
|
|
226
|
+
)
|
|
227
|
+
omitted = blast.get("files_omitted", 0) + max(0, len(blast.get("files") or []) - top_n)
|
|
228
|
+
if omitted:
|
|
229
|
+
lines.append(f" +{omitted} more file(s)")
|
|
230
|
+
for note in blast.get("notes") or []:
|
|
231
|
+
lines.append(f" note: {note}")
|
|
232
|
+
return "\n".join(lines)
|
sourcecode/repository_ir.py
CHANGED
|
@@ -26,6 +26,10 @@ from typing import Any, Iterable, Optional
|
|
|
26
26
|
from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
|
|
27
27
|
from sourcecode.endpoint_metrics import ENDPOINT_SURFACE_RECONCILIATION
|
|
28
28
|
from sourcecode.fqn_utils import normalize_owner_fqn as _normalize_owner_fqn
|
|
29
|
+
from sourcecode.spring_semantic import (
|
|
30
|
+
boundaries_declared_within as _boundaries_declared_within,
|
|
31
|
+
build_tx_index_from_ir as _build_tx_index_from_ir,
|
|
32
|
+
)
|
|
29
33
|
from sourcecode.path_filters import (
|
|
30
34
|
is_test_path as _is_test_path,
|
|
31
35
|
is_test_fixture_module_path as _is_test_fixture_module_path,
|
|
@@ -6803,6 +6807,7 @@ def compute_blast_radius(
|
|
|
6803
6807
|
"security_surface_affected": [],
|
|
6804
6808
|
"cross_module_impact": [],
|
|
6805
6809
|
"transactional_boundaries_touched": [],
|
|
6810
|
+
"transactional_boundaries_reaching": [],
|
|
6806
6811
|
"risk_score": 0.0,
|
|
6807
6812
|
"risk_level": "unknown",
|
|
6808
6813
|
"confidence_score": 0.0,
|
|
@@ -6828,6 +6833,7 @@ def compute_blast_radius(
|
|
|
6828
6833
|
"security_surface_affected": [],
|
|
6829
6834
|
"cross_module_impact": [],
|
|
6830
6835
|
"transactional_boundaries_touched": [],
|
|
6836
|
+
"transactional_boundaries_reaching": [],
|
|
6831
6837
|
"risk_score": 0.0,
|
|
6832
6838
|
"risk_level": "unknown",
|
|
6833
6839
|
"confidence_score": 0.0,
|
|
@@ -7134,23 +7140,19 @@ def compute_blast_radius(
|
|
|
7134
7140
|
})
|
|
7135
7141
|
cross_module_impact = cross_module_impact[:10]
|
|
7136
7142
|
|
|
7137
|
-
# ── 7. Transactional boundaries
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
txn_nodes.append(fqn)
|
|
7151
|
-
break
|
|
7152
|
-
|
|
7153
|
-
txn_nodes = sorted(set(txn_nodes))
|
|
7143
|
+
# ── 7. Transactional boundaries ───────────────────────────────────────────
|
|
7144
|
+
# Both figures come from the one transaction-boundary authority (ADR-0008 R1);
|
|
7145
|
+
# they differ only in which symbols are asked about:
|
|
7146
|
+
# touched — the target and everything that calls it (what this change sits in)
|
|
7147
|
+
# reaching — the callers alone (what would run this change inside a transaction)
|
|
7148
|
+
# This used to be derived here from a graph node `role == "transaction_boundary"`
|
|
7149
|
+
# that no producer ever assigns, so `impact` reported 0 boundaries on repos where
|
|
7150
|
+
# `retrieve transactions-reaching` reported dozens, and `n_txn` never contributed
|
|
7151
|
+
# to the risk score or its escalation rules.
|
|
7152
|
+
_tx_index = _build_tx_index_from_ir(ir)
|
|
7153
|
+
_caller_symbols = set(direct_callers) | set(indirect_callers)
|
|
7154
|
+
txn_nodes = sorted({b.symbol for b in _boundaries_declared_within(_tx_index, affected_classes)})
|
|
7155
|
+
txn_reaching = sorted({b.symbol for b in _boundaries_declared_within(_tx_index, _caller_symbols)})
|
|
7154
7156
|
|
|
7155
7157
|
# ── 8. Risk score ─────────────────────────────────────────────────────────
|
|
7156
7158
|
n_direct = len(direct_callers)
|
|
@@ -7288,6 +7290,11 @@ def compute_blast_radius(
|
|
|
7288
7290
|
"security_surface_affected": security_surface_affected,
|
|
7289
7291
|
"cross_module_impact": cross_module_impact,
|
|
7290
7292
|
"transactional_boundaries_touched": txn_nodes,
|
|
7293
|
+
# The callers-only subset: the boundaries that would run this change inside a
|
|
7294
|
+
# transaction. Same authority, narrower question — and the figure
|
|
7295
|
+
# `retrieve transactions-reaching` answers, so the two commands agree by
|
|
7296
|
+
# construction rather than by coincidence.
|
|
7297
|
+
"transactional_boundaries_reaching": txn_reaching,
|
|
7291
7298
|
"depth_reached": _effective_depth, # actual BFS depth used, not the requested max
|
|
7292
7299
|
"bfs_truncated": _bfs_truncated,
|
|
7293
7300
|
"stats": {
|
|
@@ -7297,6 +7304,7 @@ def compute_blast_radius(
|
|
|
7297
7304
|
"indirect_callers_sampled": _indirect_sampled,
|
|
7298
7305
|
"endpoints_affected_count": n_ep,
|
|
7299
7306
|
"transactional_boundaries_count": n_txn,
|
|
7307
|
+
"transactional_boundaries_reaching_count": len(txn_reaching),
|
|
7300
7308
|
"mappers_affected_count": n_mappers,
|
|
7301
7309
|
"modules_affected_count": n_modules,
|
|
7302
7310
|
"security_surface_count": n_sec,
|
|
@@ -93,16 +93,18 @@ def resolve_execution_paths(step: Step, ctx: "StepContext") -> None:
|
|
|
93
93
|
# ── transactions reaching (impact chain callers ∩ tx_index) ──────────────────────
|
|
94
94
|
def resolve_transactions_reaching(step: Step, ctx: "StepContext") -> None:
|
|
95
95
|
"""The @Transactional boundaries whose enclosing class can reach the target through the
|
|
96
|
-
call chain. Reuses run_impact_chain (reachability) + the tx boundary index.
|
|
96
|
+
call chain. Reuses run_impact_chain (reachability) + the tx boundary index.
|
|
97
|
+
|
|
98
|
+
Membership is decided by the one transaction-boundary authority (ADR-0008 R1), the
|
|
99
|
+
same call `impact` makes for `transactional_boundaries_reaching` — so the two
|
|
100
|
+
commands answer this question with one number instead of two."""
|
|
97
101
|
assert isinstance(step, ResolveTransactionsReaching)
|
|
102
|
+
from sourcecode.spring_semantic import boundaries_declared_within
|
|
103
|
+
|
|
98
104
|
result = _impact_chain(ctx)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
boundaries = [
|
|
103
|
-
b for b in ctx.knowledge.spring_model.tx_index.all_declared
|
|
104
|
-
if _enclosing_class(b.symbol) in caller_classes
|
|
105
|
-
]
|
|
105
|
+
callers = set(result.direct_callers) | set(result.indirect_callers)
|
|
106
|
+
caller_classes = {_enclosing_class(c) for c in callers}
|
|
107
|
+
boundaries = boundaries_declared_within(ctx.knowledge.spring_model.tx_index, callers)
|
|
106
108
|
ctx.put(
|
|
107
109
|
SLOT_ENTITIES,
|
|
108
110
|
[
|
|
@@ -123,7 +123,29 @@ def resolve_integration_inventory(step: Step, ctx: "StepContext") -> None:
|
|
|
123
123
|
],
|
|
124
124
|
)
|
|
125
125
|
ctx.put(SLOT_EVIDENCE, [_integration_evidence(i) for i in integrations])
|
|
126
|
-
|
|
126
|
+
observations = _kind_histogram(integrations, key=lambda i: i.kind, label="integration")
|
|
127
|
+
# A client library the build declares but whose construct nothing imports is not an
|
|
128
|
+
# entry in this inventory — it is not an observed integration — but leaving it unsaid
|
|
129
|
+
# is how a `count: 0` came to sit in the same document as a directory client library
|
|
130
|
+
# in the stack block (ADR-0008 R12). Same coordinate authority `export --integrations`
|
|
131
|
+
# reads, so both surfaces recognize the same libraries.
|
|
132
|
+
from sourcecode.integration_coordinates import declared_integration_coordinates
|
|
133
|
+
|
|
134
|
+
_declared = declared_integration_coordinates(getattr(ctx.knowledge, "root", None))
|
|
135
|
+
_observed_kinds = {i.kind for i in integrations}
|
|
136
|
+
for d in _declared:
|
|
137
|
+
if d.kind in _observed_kinds:
|
|
138
|
+
continue
|
|
139
|
+
observations.append(Observation(
|
|
140
|
+
name=f"declared_not_observed:{d.kind}",
|
|
141
|
+
value=d.coordinate,
|
|
142
|
+
detail=(
|
|
143
|
+
f"the build declares this client at {d.evidence}; no client construct "
|
|
144
|
+
"found in source (a properties/YAML/XML-wired client has none) — "
|
|
145
|
+
"declared, not observed"
|
|
146
|
+
),
|
|
147
|
+
))
|
|
148
|
+
ctx.put(SLOT_OBSERVATIONS, observations)
|
|
127
149
|
ctx.put(SLOT_CONFIDENCE, "high" if integrations else "medium")
|
|
128
150
|
_add_service(ctx, "SemanticIntegrationEngine")
|
|
129
151
|
|
sourcecode/security_posture.py
CHANGED
|
@@ -228,14 +228,18 @@ def _collect_usage(
|
|
|
228
228
|
cir: "CanonicalRepositoryIR",
|
|
229
229
|
) -> tuple[dict[str, _AnnotationUsage], int, int, set[str]]:
|
|
230
230
|
"""Scan existing per-symbol annotations. Returns (usage_by_simple,
|
|
231
|
-
|
|
231
|
+
handler_declarations, non_handler_declarations, handler_symbols).
|
|
232
232
|
|
|
233
233
|
Controller-handler methods = the handler symbols of `cir.endpoints`; this is
|
|
234
234
|
the authoritative endpoint surface (already structurally derived by the IR).
|
|
235
|
+
|
|
236
|
+
The two integers count **method declarations**, not distinct symbols: overloaded
|
|
237
|
+
methods share one FQN and are separate annotation sites, so on a real monolith 75
|
|
238
|
+
distinct handler symbols produced 90 declarations. Coverage below is measured
|
|
239
|
+
against declarations because that is what was scanned — the payload says so rather
|
|
240
|
+
than letting a reader assume it was endpoints (3574) or distinct methods (75).
|
|
235
241
|
"""
|
|
236
|
-
handler_symbols: set[str] =
|
|
237
|
-
ep.handler_symbol for ep in cir.endpoints if getattr(ep, "handler_symbol", "")
|
|
238
|
-
}
|
|
242
|
+
handler_symbols: set[str] = set(_handler_symbols(cir))
|
|
239
243
|
controller_classes: set[str] = {
|
|
240
244
|
ep.controller_class for ep in cir.endpoints if getattr(ep, "controller_class", "")
|
|
241
245
|
}
|
|
@@ -389,6 +393,9 @@ def _detect_custom_gates(
|
|
|
389
393
|
usage, total_handler, total_non_handler, _ = _collect_usage(cir)
|
|
390
394
|
if total_handler == 0:
|
|
391
395
|
return []
|
|
396
|
+
_population = endpoint_population(cir)
|
|
397
|
+
population_handler_methods = _population["handler_methods"]
|
|
398
|
+
population_endpoints = _population["endpoints"]
|
|
392
399
|
|
|
393
400
|
verdicts: list[Verdict] = []
|
|
394
401
|
for simple, u in sorted(usage.items()):
|
|
@@ -415,9 +422,22 @@ def _detect_custom_gates(
|
|
|
415
422
|
# concrete name lives ONLY here, as evidence (VAI §0.5)
|
|
416
423
|
"annotation": u.token,
|
|
417
424
|
"coverage": round(coverage, 4),
|
|
418
|
-
"coverage_pct": f"{round(coverage * 100)}% of controller
|
|
425
|
+
"coverage_pct": f"{round(coverage * 100)}% of controller handler method declarations",
|
|
426
|
+
# The denominator is stated in its own unit: handler *methods*, not
|
|
427
|
+
# endpoints. A repo with 936 handler methods can expose several thousand
|
|
428
|
+
# endpoints, so this percentage must never be read against an endpoint
|
|
429
|
+
# count (ADR-0008 — the payload states the unit, the reader does not guess).
|
|
430
|
+
"denominator": "controller handler method declarations",
|
|
419
431
|
"handler_hits": u.handler_hits,
|
|
420
|
-
|
|
432
|
+
# What the percentage was actually measured against: annotation sites on
|
|
433
|
+
# handler methods. Overloads share an FQN, so this is ≥ the distinct-method
|
|
434
|
+
# count and unrelated to the endpoint count.
|
|
435
|
+
"handler_declarations_total": total_handler,
|
|
436
|
+
# The other two units, stated beside it so no reader has to guess which one
|
|
437
|
+
# a percentage used — the exact confusion this payload caused in the field.
|
|
438
|
+
"handler_methods_total": population_handler_methods,
|
|
439
|
+
"endpoints_total": population_endpoints,
|
|
440
|
+
"handler_total": total_handler, # deprecated alias of handler_declarations_total
|
|
421
441
|
"non_handler_hits": u.non_handler_hits,
|
|
422
442
|
"specificity_ratio": (
|
|
423
443
|
round(handler_rate / non_handler_rate, 2)
|
|
@@ -489,6 +509,106 @@ def gate_bearing_handlers(
|
|
|
489
509
|
_GATE_COVERAGE_LIST_CAP = 200
|
|
490
510
|
|
|
491
511
|
|
|
512
|
+
def _handler_symbols(cir: "CanonicalRepositoryIR") -> set[str]:
|
|
513
|
+
"""The distinct handler-method symbols behind the endpoint surface — the one place
|
|
514
|
+
this set is derived, so a count of it means the same thing everywhere."""
|
|
515
|
+
return {ep.handler_symbol for ep in cir.endpoints if getattr(ep, "handler_symbol", "")}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def endpoint_population(cir: "CanonicalRepositoryIR") -> dict:
|
|
519
|
+
"""THE population every posture partition is measured against (ADR-0008 R1).
|
|
520
|
+
|
|
521
|
+
Two units live in this projection and they are not interchangeable: a repository
|
|
522
|
+
has more *endpoints* than *handler methods*, because one method can answer several
|
|
523
|
+
mappings. Reporting a count of one next to a count of the other under names that
|
|
524
|
+
both read as "handlers" is how a payload came to show 698 + 2635 against a total of
|
|
525
|
+
3574 — three figures, no stated unit, and no way for a reader to tell which
|
|
526
|
+
denominator any percentage used.
|
|
527
|
+
|
|
528
|
+
Every count derived from either population MUST come from here and MUST say which
|
|
529
|
+
one it is.
|
|
530
|
+
"""
|
|
531
|
+
return {
|
|
532
|
+
"endpoints": len(cir.endpoints),
|
|
533
|
+
"handler_methods": len(_handler_symbols(cir)),
|
|
534
|
+
"note": (
|
|
535
|
+
"Two units. 'endpoints' counts mapped routes (the denominator for every "
|
|
536
|
+
"endpoint partition below); 'handler_methods' counts the distinct methods "
|
|
537
|
+
"serving them. One method can serve several endpoints — measured on a real "
|
|
538
|
+
"monolith, 220 endpoints came from 75 methods — so the two never have to "
|
|
539
|
+
"match, and a figure is only comparable to others in its own unit."
|
|
540
|
+
),
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def unknown_endpoint_surface(reason: str, declared_policy: str = "") -> dict:
|
|
545
|
+
"""The honest answer for an endpoint this authority cannot speak about.
|
|
546
|
+
|
|
547
|
+
Absence of a posture entry is absence of evidence: it is `coverage_unknown`
|
|
548
|
+
with the reason, never a coverage claim (ADR-0008 R9). The declared per-method
|
|
549
|
+
policy, if known, travels along as evidence — it is not the answer.
|
|
550
|
+
"""
|
|
551
|
+
return {
|
|
552
|
+
"verdict": "coverage_unknown",
|
|
553
|
+
"confidence": Confidence.UNKNOWN.value,
|
|
554
|
+
"declared_policy": declared_policy or "unknown",
|
|
555
|
+
"reason": reason,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def endpoint_security_surface(
|
|
560
|
+
cir: "CanonicalRepositoryIR",
|
|
561
|
+
*,
|
|
562
|
+
root: Optional[Path] = None,
|
|
563
|
+
posture: Optional[Any] = None,
|
|
564
|
+
) -> dict[str, dict]:
|
|
565
|
+
"""THE per-endpoint security answer, keyed by ``endpoint_id`` (ADR-0008 R1).
|
|
566
|
+
|
|
567
|
+
One question — "what is known about the security of this endpoint?" — had two
|
|
568
|
+
producers: this module's four-way verdict, and `impact-chain`, which printed the
|
|
569
|
+
raw per-method policy under the name `security_policy`. Measured on Keycloak
|
|
570
|
+
(2026-07-28): the posture projection called 406 endpoints ``protected_custom``,
|
|
571
|
+
and **324 of those carry the declared policy ``none_detected``** because their
|
|
572
|
+
gate is a bespoke annotation with no standard vocabulary. `impact-chain` reported
|
|
573
|
+
exactly that string for the same `endpoint_id` — so one run of one tool answered
|
|
574
|
+
"guarded by a custom mechanism (Likely)" and "no security detected" about
|
|
575
|
+
``DELETE /admin/realms/{realm}``. The declared policy is a fact about an
|
|
576
|
+
annotation; it was never an answer about coverage.
|
|
577
|
+
|
|
578
|
+
Consumers bind here instead of re-deriving. ``posture`` accepts an already-computed
|
|
579
|
+
:class:`SecurityPostureResult` or its ``to_dict()`` form, so a caller that has run
|
|
580
|
+
the security audit reuses that derivation rather than paying for a second one.
|
|
581
|
+
|
|
582
|
+
Never raises: any structural gap yields :func:`unknown_endpoint_surface`.
|
|
583
|
+
"""
|
|
584
|
+
if posture is None:
|
|
585
|
+
try:
|
|
586
|
+
posture = infer_security_posture(cir, root=root)
|
|
587
|
+
except Exception:
|
|
588
|
+
return {}
|
|
589
|
+
|
|
590
|
+
verdicts: Any
|
|
591
|
+
if isinstance(posture, dict):
|
|
592
|
+
verdicts = posture.get("endpoints") or []
|
|
593
|
+
else:
|
|
594
|
+
verdicts = getattr(posture, "endpoint_verdicts", None) or []
|
|
595
|
+
|
|
596
|
+
out: dict[str, dict] = {}
|
|
597
|
+
for v in verdicts:
|
|
598
|
+
if not isinstance(v, dict):
|
|
599
|
+
continue
|
|
600
|
+
ep_id = v.get("endpoint_id") or ""
|
|
601
|
+
if not ep_id:
|
|
602
|
+
continue
|
|
603
|
+
out[ep_id] = {
|
|
604
|
+
"verdict": v.get("verdict") or "coverage_unknown",
|
|
605
|
+
"confidence": v.get("confidence") or Confidence.UNKNOWN.value,
|
|
606
|
+
# Evidence, not answer: the guard vocabulary declared on the handler.
|
|
607
|
+
"declared_policy": v.get("policy") or "unknown",
|
|
608
|
+
}
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
|
|
492
612
|
@dataclass
|
|
493
613
|
class SecurityPostureResult:
|
|
494
614
|
"""Repo-level security-posture projection (P1-A)."""
|
|
@@ -499,6 +619,9 @@ class SecurityPostureResult:
|
|
|
499
619
|
endpoint_verdicts: list[dict] = field(default_factory=list)
|
|
500
620
|
rollup: dict = field(default_factory=dict)
|
|
501
621
|
limitations: list[str] = field(default_factory=list)
|
|
622
|
+
# The population every partition in this projection is measured against, stated
|
|
623
|
+
# once so a reader never has to infer a denominator from a percentage.
|
|
624
|
+
population: dict = field(default_factory=dict)
|
|
502
625
|
# Gate-coverage projection (P1-A follow-up): when a custom gate IS the detected
|
|
503
626
|
# mechanism, which controller handlers do NOT carry it and are not standard-guarded
|
|
504
627
|
# — the residue that relies solely on a centralized filter, if one exists. None when
|
|
@@ -522,6 +645,7 @@ class SecurityPostureResult:
|
|
|
522
645
|
self.interception.to_dict() if self.interception else None
|
|
523
646
|
),
|
|
524
647
|
"endpoints": self.endpoint_verdicts,
|
|
648
|
+
"population": dict(self.population),
|
|
525
649
|
"rollup": self.rollup,
|
|
526
650
|
**({"gate_coverage": self.gate_coverage} if self.gate_coverage else {}),
|
|
527
651
|
"limitations": list(self.limitations),
|
|
@@ -552,6 +676,7 @@ def infer_security_posture(
|
|
|
552
676
|
Never raises; never emits a categorical "unsecured" verdict.
|
|
553
677
|
"""
|
|
554
678
|
security_model = str(cir.metadata.get("security_model", "unknown"))
|
|
679
|
+
population = endpoint_population(cir)
|
|
555
680
|
|
|
556
681
|
# (0) config: names never drive detection — only upgrade confidence.
|
|
557
682
|
configured: dict[str, Any] = {}
|
|
@@ -588,6 +713,11 @@ def infer_security_posture(
|
|
|
588
713
|
# escape set: handlers that do NOT carry the detected custom gate and have no
|
|
589
714
|
# standard guard of their own — the residue that relies solely on a filter.
|
|
590
715
|
escaping: list[dict] = []
|
|
716
|
+
# Three-way partition of the SAME population (endpoints), counted in the one loop
|
|
717
|
+
# that visits every endpoint exactly once, so the parts sum to the whole by
|
|
718
|
+
# construction rather than by three producers agreeing after the fact.
|
|
719
|
+
n_gated = 0 # carries the detected custom gate
|
|
720
|
+
n_standard_only = 0 # no custom gate, but a standard guard of its own
|
|
591
721
|
|
|
592
722
|
for ep in cir.endpoints:
|
|
593
723
|
policy = ep.security.policy if ep.security is not None else "none_detected"
|
|
@@ -640,12 +770,12 @@ def infer_security_posture(
|
|
|
640
770
|
|
|
641
771
|
# escape set (only meaningful when a custom gate is the mechanism): a handler
|
|
642
772
|
# that neither carries the gate nor a standard guard nor a config-custom policy.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
):
|
|
773
|
+
_gated = carries_gate or policy == "custom"
|
|
774
|
+
if _gated:
|
|
775
|
+
n_gated += 1
|
|
776
|
+
elif _is_standard_guard_policy(policy):
|
|
777
|
+
n_standard_only += 1
|
|
778
|
+
if has_custom_gate and not _gated and not _is_standard_guard_policy(policy):
|
|
649
779
|
escaping.append({
|
|
650
780
|
"endpoint_id": ep.id,
|
|
651
781
|
"method": ep.method,
|
|
@@ -692,13 +822,26 @@ def infer_security_posture(
|
|
|
692
822
|
else:
|
|
693
823
|
no_pattern += 1
|
|
694
824
|
|
|
695
|
-
total_handlers =
|
|
825
|
+
total_handlers = population["endpoints"]
|
|
696
826
|
not_covered = len(escaping)
|
|
697
827
|
_patterns = (
|
|
698
828
|
filter_surface.patterns if filter_surface and not filter_surface.is_empty() else []
|
|
699
829
|
)
|
|
700
830
|
gate_coverage = {
|
|
701
831
|
"gate_annotations": sorted(gate_names),
|
|
832
|
+
# ── the endpoint partition: three parts, one population, they sum ──────
|
|
833
|
+
"population": "endpoints",
|
|
834
|
+
"endpoints_total": population["endpoints"],
|
|
835
|
+
"endpoints_carrying_gate": n_gated,
|
|
836
|
+
"endpoints_standard_guarded": n_standard_only,
|
|
837
|
+
"endpoints_not_carrying_gate": not_covered,
|
|
838
|
+
# ── a different unit, named as one ────────────────────────────────────
|
|
839
|
+
"handler_methods_total": population["handler_methods"],
|
|
840
|
+
"handler_methods_carrying_gate": len(gate_bearers),
|
|
841
|
+
# ── deprecated aliases (two-minor window) ─────────────────────────────
|
|
842
|
+
# `total_controller_handlers` always counted endpoints, and `gate_bearing`
|
|
843
|
+
# counted handler methods, so the two never belonged in one sum. Kept so no
|
|
844
|
+
# consumer breaks; read the explicit keys above.
|
|
702
845
|
"total_controller_handlers": total_handlers,
|
|
703
846
|
"gate_bearing": len(gate_bearers),
|
|
704
847
|
"not_carrying_gate": not_covered,
|
|
@@ -713,12 +856,14 @@ def infer_security_posture(
|
|
|
713
856
|
escaping, key=lambda d: (d["path"] or "", d["method"] or "")
|
|
714
857
|
)[:_GATE_COVERAGE_LIST_CAP],
|
|
715
858
|
"note": (
|
|
716
|
-
"
|
|
717
|
-
"guard. 'filter_pattern_match' marks those a declarative servlet
|
|
718
|
-
"(@WebFilter / web.xml) pattern covers — still review, pattern
|
|
719
|
-
"not proof of enforcement. Spring FilterRegistrationBean /
|
|
720
|
-
"/ AspectJ pointcuts are NOT reconstructed, so a missing
|
|
721
|
-
"of exposure."
|
|
859
|
+
"Endpoints whose handler does not carry the detected custom gate and has no "
|
|
860
|
+
"standard guard. 'filter_pattern_match' marks those a declarative servlet "
|
|
861
|
+
"filter (@WebFilter / web.xml) pattern covers — still review, pattern "
|
|
862
|
+
"presence is not proof of enforcement. Spring FilterRegistrationBean / "
|
|
863
|
+
"HttpSecurity DSL / AspectJ pointcuts are NOT reconstructed, so a missing "
|
|
864
|
+
"match is not proof of exposure. Counts marked endpoints_* partition the "
|
|
865
|
+
"endpoint population and sum to endpoints_total; handler_methods_* count a "
|
|
866
|
+
"different unit and are not comparable to them."
|
|
722
867
|
),
|
|
723
868
|
"truncated": not_covered > _GATE_COVERAGE_LIST_CAP,
|
|
724
869
|
}
|
|
@@ -730,6 +875,7 @@ def infer_security_posture(
|
|
|
730
875
|
endpoint_verdicts=verdicts,
|
|
731
876
|
rollup=rollup,
|
|
732
877
|
limitations=limitations,
|
|
878
|
+
population=population,
|
|
733
879
|
gate_coverage=gate_coverage,
|
|
734
880
|
)
|
|
735
881
|
# keep reference to suppress-set so callers (SEC-001) can dedupe false alarms
|