sourcecode 5.2.0__py3-none-any.whl → 5.3.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cache_model.py +1 -0
- sourcecode/cli.py +257 -5
- sourcecode/consumer_join.py +357 -0
- sourcecode/environment_resolution.py +200 -12
- sourcecode/format_contract.py +1 -0
- sourcecode/non_coverage.py +12 -8
- sourcecode/perf.py +15 -1
- sourcecode/remedies.py +51 -1
- sourcecode/risk.py +92 -2
- sourcecode/spring_findings.py +39 -0
- sourcecode/spring_impact.py +10 -0
- sourcecode/sql_taint.py +362 -0
- sourcecode/timeline.py +459 -0
- sourcecode/verify_rules.py +9 -0
- sourcecode/waivers.py +384 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/METADATA +52 -14
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/RECORD +21 -17
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""consumer_join.py — the consumers live in other repositories (F-BH).
|
|
2
|
+
|
|
3
|
+
One repository is analysed; the system is four. `client_calls` already joins a
|
|
4
|
+
client checked in *beside* the server, and the field's shape is the ordinary one:
|
|
5
|
+
a server repository, and its consumers in separate repositories nobody analyses
|
|
6
|
+
together. So the endpoint census answers *what is exposed* and the question that
|
|
7
|
+
decides the work — *what does anybody call* — has no answer, and every reduction
|
|
8
|
+
plan starts with "protect 2 635 routes".
|
|
9
|
+
|
|
10
|
+
Given the consumers, the plan inverts: of 2 635 routes a readable rule leaves
|
|
11
|
+
open, the ones a known consumer calls are **fix now**, and the ones nobody calls
|
|
12
|
+
are **delete, do not protect** — the cheapest reduction of exposure available on
|
|
13
|
+
a large surface, and one no single-repository analysis can propose.
|
|
14
|
+
|
|
15
|
+
Three rules, and the first is the one that makes the output safe to act on:
|
|
16
|
+
|
|
17
|
+
* **An uncalled route is a candidate, never a proven-dead one.** What was read is
|
|
18
|
+
the consumers the caller named, in the call shapes whose verb and path are
|
|
19
|
+
written at the call site. A route absent from that is absent from *what was
|
|
20
|
+
read*; the payload publishes the direction (`under_reports`) and the
|
|
21
|
+
per-consumer coverage that bounds it.
|
|
22
|
+
* **A client call matching no route is the decisive direction.** It is a request
|
|
23
|
+
a consumer makes that this server does not serve — a broken integration or a
|
|
24
|
+
route that was deleted — and it needs no assumption about coverage to be true.
|
|
25
|
+
* **Route-variable normalisation is confidence, not identity.** `/{bankId}` and
|
|
26
|
+
`${bank-id}` are the same position with two spellings, so the join key
|
|
27
|
+
neutralises variable segments (`client_calls.normalize_path`) and both original
|
|
28
|
+
spellings travel on the row. A match through a normalised segment is reported
|
|
29
|
+
as `normalised`, never as an exact one.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Iterable, Optional
|
|
36
|
+
|
|
37
|
+
from sourcecode.client_calls import (
|
|
38
|
+
ClientCall,
|
|
39
|
+
_iter_client_files,
|
|
40
|
+
_path_from_url,
|
|
41
|
+
extract_client_calls,
|
|
42
|
+
normalize_path,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
SCHEMA_VERSION = "consumer-join-v1"
|
|
46
|
+
|
|
47
|
+
#: Java client shapes whose verb and path are written at the call site — the same
|
|
48
|
+
#: admission rule as the TypeScript side, in the language most server-to-server
|
|
49
|
+
#: consumers are written in. A path assembled from a variable is not a call this
|
|
50
|
+
#: module can see, and it is reported unresolved rather than guessed.
|
|
51
|
+
_JAVA_TEMPLATE_CALL = re.compile(
|
|
52
|
+
r"""\b(?P<receiver>[\w$.]*)\.(?P<op>getFor\w+|postFor\w+|put|patch|delete|exchange)\s*\(\s*"""
|
|
53
|
+
r"""(?P<quote>")(?P<url>[^"]*)(?P=quote)""",
|
|
54
|
+
)
|
|
55
|
+
#: `HttpMethod.GET` inside an `exchange(...)` call, which is where the verb lives
|
|
56
|
+
#: when the method name does not carry it.
|
|
57
|
+
_JAVA_EXCHANGE_VERB = re.compile(r"HttpMethod\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)")
|
|
58
|
+
#: A declarative HTTP client interface: the verb and path are annotation data.
|
|
59
|
+
_JAVA_ANNOTATION_CALL = re.compile(
|
|
60
|
+
r"""@(?P<ann>GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping|RequestLine)\s*"""
|
|
61
|
+
r"""\(\s*(?P<args>[^)]*)\)""",
|
|
62
|
+
)
|
|
63
|
+
_ANN_VERB = {
|
|
64
|
+
"GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT",
|
|
65
|
+
"PatchMapping": "PATCH", "DeleteMapping": "DELETE",
|
|
66
|
+
}
|
|
67
|
+
_STRING_ARG = re.compile(r'"([^"]*)"')
|
|
68
|
+
_REQUEST_LINE = re.compile(r'"\s*(?P<verb>[A-Z]+)\s+(?P<path>/[^"\s]*)')
|
|
69
|
+
_METHOD_ATTR = re.compile(r"RequestMethod\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)")
|
|
70
|
+
#: Only a type that declares itself an HTTP client is read this way — an
|
|
71
|
+
#: annotation-shaped path on a server controller is a route, not a call.
|
|
72
|
+
_DECLARATIVE_CLIENT = re.compile(r"@(\w*FeignClient|HttpExchange|RegisterRestClient)\b")
|
|
73
|
+
|
|
74
|
+
_JAVA_SKIP_DIRS = frozenset({
|
|
75
|
+
"target", "build", "out", ".git", "node_modules", ".gradle",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
_VERB_FROM_OP = {
|
|
79
|
+
"getfor": "GET", "postfor": "POST", "put": "PUT", "patch": "PATCH",
|
|
80
|
+
"delete": "DELETE",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _java_files(base: Path) -> list[Path]:
|
|
85
|
+
out: list[Path] = []
|
|
86
|
+
for path in base.rglob("*.java"):
|
|
87
|
+
if any(part in _JAVA_SKIP_DIRS for part in path.parts):
|
|
88
|
+
continue
|
|
89
|
+
if path.name.endswith("Test.java") or "/src/test/" in path.as_posix():
|
|
90
|
+
continue
|
|
91
|
+
out.append(path)
|
|
92
|
+
return sorted(out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_java_client_calls(base: Path) -> "tuple[list[ClientCall], list[dict]]":
|
|
96
|
+
"""HTTP calls written in Java client code, with the same admission rule as TS/JS."""
|
|
97
|
+
calls: list[ClientCall] = []
|
|
98
|
+
unresolved: list[dict] = []
|
|
99
|
+
base = Path(base)
|
|
100
|
+
for path in _java_files(base):
|
|
101
|
+
try:
|
|
102
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
rel = path.relative_to(base).as_posix()
|
|
106
|
+
for match in _JAVA_TEMPLATE_CALL.finditer(text):
|
|
107
|
+
op = match.group("op").lower()
|
|
108
|
+
verb = next((v for k, v in _VERB_FROM_OP.items() if op.startswith(k)), "")
|
|
109
|
+
if not verb and op == "exchange":
|
|
110
|
+
window = text[match.start(): match.start() + 400]
|
|
111
|
+
verb_match = _JAVA_EXCHANGE_VERB.search(window)
|
|
112
|
+
verb = verb_match.group(1) if verb_match else ""
|
|
113
|
+
route = _path_from_url(match.group("url"))
|
|
114
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
115
|
+
if not verb or route is None:
|
|
116
|
+
unresolved.append({
|
|
117
|
+
"source_file": rel, "line": line,
|
|
118
|
+
"why": (
|
|
119
|
+
"the verb is not written at the call site"
|
|
120
|
+
if not verb else
|
|
121
|
+
"the first argument is not a path this module can resolve"
|
|
122
|
+
),
|
|
123
|
+
})
|
|
124
|
+
continue
|
|
125
|
+
calls.append(ClientCall(
|
|
126
|
+
method=verb, path=route, source_file=rel, line=line,
|
|
127
|
+
receiver=match.group("receiver") or "",
|
|
128
|
+
))
|
|
129
|
+
if not _DECLARATIVE_CLIENT.search(text):
|
|
130
|
+
continue
|
|
131
|
+
for match in _JAVA_ANNOTATION_CALL.finditer(text):
|
|
132
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
133
|
+
ann, args = match.group("ann"), match.group("args")
|
|
134
|
+
if ann == "RequestLine":
|
|
135
|
+
request_line = _REQUEST_LINE.search(args)
|
|
136
|
+
if not request_line:
|
|
137
|
+
unresolved.append({
|
|
138
|
+
"source_file": rel, "line": line,
|
|
139
|
+
"why": "the request line is not a literal `VERB /path`",
|
|
140
|
+
})
|
|
141
|
+
continue
|
|
142
|
+
calls.append(ClientCall(
|
|
143
|
+
method=request_line.group("verb").upper(),
|
|
144
|
+
path=request_line.group("path"),
|
|
145
|
+
source_file=rel, line=line, receiver="declarative client",
|
|
146
|
+
))
|
|
147
|
+
continue
|
|
148
|
+
verb = _ANN_VERB.get(ann, "")
|
|
149
|
+
if not verb:
|
|
150
|
+
method_attr = _METHOD_ATTR.search(args)
|
|
151
|
+
verb = method_attr.group(1) if method_attr else ""
|
|
152
|
+
literal = _STRING_ARG.search(args)
|
|
153
|
+
route = _path_from_url(literal.group(1)) if literal else None
|
|
154
|
+
if not verb or route is None:
|
|
155
|
+
unresolved.append({
|
|
156
|
+
"source_file": rel, "line": line,
|
|
157
|
+
"why": (
|
|
158
|
+
"the verb is not written in the annotation"
|
|
159
|
+
if not verb else
|
|
160
|
+
"the annotation carries no literal path"
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
continue
|
|
164
|
+
calls.append(ClientCall(
|
|
165
|
+
method=verb, path=route, source_file=rel, line=line,
|
|
166
|
+
receiver="declarative client",
|
|
167
|
+
))
|
|
168
|
+
return calls, unresolved
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _consumer_calls(base: Path) -> "tuple[list[ClientCall], list[dict], dict]":
|
|
172
|
+
"""Every legible call in one consumer repository, whatever it is written in."""
|
|
173
|
+
ts_calls, ts_unresolved = extract_client_calls(base, client_dirs=[base])
|
|
174
|
+
java_calls, java_unresolved = extract_java_client_calls(base)
|
|
175
|
+
read = {
|
|
176
|
+
"web_client_files": len(_iter_client_files(base, [base])),
|
|
177
|
+
"java_files": len(_java_files(base)),
|
|
178
|
+
}
|
|
179
|
+
return ts_calls + java_calls, ts_unresolved + java_unresolved, read
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _slug(path: str) -> str:
|
|
183
|
+
"""A path reduced to letters and digits, lowercased — the alias key.
|
|
184
|
+
|
|
185
|
+
`/v1/agrupadorGfh` and `/v1/agrupador-gfh` are two routes with one slug: either
|
|
186
|
+
two spellings of one live integration, or one live route plus debris. Which of
|
|
187
|
+
the two is a question about consumers, and this module has the consumers.
|
|
188
|
+
"""
|
|
189
|
+
return re.sub(r"[^a-z0-9/{}]", "", normalize_path(path).lower())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_consumer_join(
|
|
193
|
+
root: Path,
|
|
194
|
+
endpoints: "Iterable[dict]",
|
|
195
|
+
consumers: "Iterable[Path]",
|
|
196
|
+
*,
|
|
197
|
+
access: "Optional[dict[str, str]]" = None,
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""The surface-reduction plan: what to fix, what to delete, what is undecided.
|
|
200
|
+
|
|
201
|
+
`access` maps `METHOD path` to the access verdict `posture` publishes
|
|
202
|
+
(`open` / `unknown` / `protected`). Absent, the plan still splits called from
|
|
203
|
+
uncalled — it just cannot say which half is urgent.
|
|
204
|
+
"""
|
|
205
|
+
from sourcecode import non_coverage
|
|
206
|
+
|
|
207
|
+
rows = list(endpoints)
|
|
208
|
+
by_key: "dict[str, list[dict]]" = {}
|
|
209
|
+
consumer_rows: list[dict] = []
|
|
210
|
+
unresolved_total = 0
|
|
211
|
+
for base in consumers:
|
|
212
|
+
base = Path(base)
|
|
213
|
+
if not base.is_dir():
|
|
214
|
+
consumer_rows.append({
|
|
215
|
+
"path": base.as_posix(), "read": False,
|
|
216
|
+
"why": "not a readable directory — nothing from it is in this join",
|
|
217
|
+
})
|
|
218
|
+
continue
|
|
219
|
+
calls, unresolved, read = _consumer_calls(base)
|
|
220
|
+
unresolved_total += len(unresolved)
|
|
221
|
+
for call in calls:
|
|
222
|
+
by_key.setdefault(call.key, []).append({
|
|
223
|
+
**call.to_dict(), "consumer": base.name or base.as_posix(),
|
|
224
|
+
})
|
|
225
|
+
consumer_rows.append({
|
|
226
|
+
"path": base.as_posix(),
|
|
227
|
+
"name": base.name or base.as_posix(),
|
|
228
|
+
"read": True,
|
|
229
|
+
"files_read": read,
|
|
230
|
+
"calls_found": len(calls),
|
|
231
|
+
"calls_unresolved": len(unresolved),
|
|
232
|
+
"unresolved_sample": unresolved[:10],
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
matched_keys: set[str] = set()
|
|
236
|
+
routes: list[dict] = []
|
|
237
|
+
for endpoint in rows:
|
|
238
|
+
method = str(endpoint.get("method") or "").upper()
|
|
239
|
+
path = str(endpoint.get("path") or endpoint.get("effective_path") or "")
|
|
240
|
+
key = f"{method} {normalize_path(path)}"
|
|
241
|
+
witnesses = by_key.get(key, [])
|
|
242
|
+
if witnesses:
|
|
243
|
+
matched_keys.add(key)
|
|
244
|
+
verdict = (access or {}).get(f"{method} {path}") or (access or {}).get(key) or "unknown"
|
|
245
|
+
routes.append({
|
|
246
|
+
"method": method,
|
|
247
|
+
"path": path,
|
|
248
|
+
"join_key": key,
|
|
249
|
+
"access": verdict,
|
|
250
|
+
"consumed": bool(witnesses),
|
|
251
|
+
"match": (
|
|
252
|
+
"normalised" if witnesses and "{}" in key else
|
|
253
|
+
"exact" if witnesses else None
|
|
254
|
+
),
|
|
255
|
+
"consumers": sorted({w["consumer"] for w in witnesses}),
|
|
256
|
+
"call_sites": witnesses[:5],
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
def _bucket(consumed: bool, open_only: bool) -> list[dict]:
|
|
260
|
+
return [
|
|
261
|
+
r for r in routes
|
|
262
|
+
if r["consumed"] is consumed
|
|
263
|
+
and ((r["access"] == "open") if open_only else True)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
fix_now = _bucket(True, True)
|
|
267
|
+
delete_candidates = _bucket(False, True)
|
|
268
|
+
unmatched = [
|
|
269
|
+
{"join_key": key, "call_sites": group[:5], "consumers": sorted({g["consumer"] for g in group})}
|
|
270
|
+
for key, group in sorted(by_key.items()) if key not in matched_keys
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
aliases: list[dict] = []
|
|
274
|
+
by_slug: "dict[str, list[dict]]" = {}
|
|
275
|
+
for row in routes:
|
|
276
|
+
by_slug.setdefault(f"{row['method']} {_slug(row['path'])}", []).append(row)
|
|
277
|
+
for slug, group in sorted(by_slug.items()):
|
|
278
|
+
if len(group) < 2:
|
|
279
|
+
continue
|
|
280
|
+
live = [r for r in group if r["consumed"]]
|
|
281
|
+
aliases.append({
|
|
282
|
+
"slug": slug,
|
|
283
|
+
"paths": [r["path"] for r in group],
|
|
284
|
+
"consumed_paths": [r["path"] for r in live],
|
|
285
|
+
"reading": (
|
|
286
|
+
"every spelling is called — these are live aliases"
|
|
287
|
+
if len(live) == len(group) else
|
|
288
|
+
f"{len(live)} of {len(group)} spellings are called; the rest are "
|
|
289
|
+
"candidates for debris"
|
|
290
|
+
if live else
|
|
291
|
+
"no spelling is called by a consumer that was read"
|
|
292
|
+
),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
read_any = any(c.get("read") for c in consumer_rows)
|
|
296
|
+
return {
|
|
297
|
+
"schema_version": SCHEMA_VERSION,
|
|
298
|
+
"consumers": consumer_rows,
|
|
299
|
+
"summary": {
|
|
300
|
+
"routes": len(routes),
|
|
301
|
+
"consumed": sum(1 for r in routes if r["consumed"]),
|
|
302
|
+
"not_consumed": sum(1 for r in routes if not r["consumed"]),
|
|
303
|
+
"open_and_consumed": len(fix_now),
|
|
304
|
+
"open_and_not_consumed": len(delete_candidates),
|
|
305
|
+
"client_calls_matching_no_route": len(unmatched),
|
|
306
|
+
"calls_unresolved": unresolved_total,
|
|
307
|
+
},
|
|
308
|
+
"reduction_plan": {
|
|
309
|
+
"fix_now": {
|
|
310
|
+
"count": len(fix_now),
|
|
311
|
+
"meaning": (
|
|
312
|
+
"a readable rule leaves these reachable without authentication "
|
|
313
|
+
"AND a consumer that was read calls them"
|
|
314
|
+
),
|
|
315
|
+
"routes": [r["path"] for r in fix_now[:50]],
|
|
316
|
+
},
|
|
317
|
+
"delete_candidates": {
|
|
318
|
+
"count": len(delete_candidates),
|
|
319
|
+
"meaning": (
|
|
320
|
+
"a readable rule leaves these open and no consumer that was read "
|
|
321
|
+
"calls them — candidates for deletion, which removes the exposure "
|
|
322
|
+
"instead of guarding it"
|
|
323
|
+
),
|
|
324
|
+
"routes": [r["path"] for r in delete_candidates[:50]],
|
|
325
|
+
"before_acting": (
|
|
326
|
+
"This is a candidate list, not a dead-route proof: only the "
|
|
327
|
+
"consumers named above were read, only in call shapes whose verb "
|
|
328
|
+
"and path are written at the call site. Confirm against traffic "
|
|
329
|
+
"before deleting."
|
|
330
|
+
),
|
|
331
|
+
},
|
|
332
|
+
"broken_integrations": {
|
|
333
|
+
"count": len(unmatched),
|
|
334
|
+
"meaning": (
|
|
335
|
+
"a consumer makes this request and this server serves no route "
|
|
336
|
+
"for it — the one direction that needs no assumption about "
|
|
337
|
+
"coverage to be true"
|
|
338
|
+
),
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
"aliases": aliases,
|
|
342
|
+
"routes": routes,
|
|
343
|
+
"unmatched_client_calls": unmatched,
|
|
344
|
+
"direction": "under_reports",
|
|
345
|
+
"reading": (
|
|
346
|
+
"Of the routes a readable rule leaves open, the consumed ones are work "
|
|
347
|
+
"and the unconsumed ones are surface that can be removed. The population "
|
|
348
|
+
"read is the consumers named on the command line — this is a third "
|
|
349
|
+
"declared population beside the endpoint census and the servlet surface, "
|
|
350
|
+
"and it is never merged into either."
|
|
351
|
+
if read_any else
|
|
352
|
+
"No consumer repository could be read, so nothing was joined: every route "
|
|
353
|
+
"is `consumed: false` because there was nothing to call it. That is an "
|
|
354
|
+
"absence of evidence, published as one."
|
|
355
|
+
),
|
|
356
|
+
"non_coverage": non_coverage.block("client_usage"),
|
|
357
|
+
}
|
|
@@ -363,13 +363,68 @@ def _signal(
|
|
|
363
363
|
)
|
|
364
364
|
|
|
365
365
|
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
#: The XML shape a deployment plan uses to override an environment entry: a name
|
|
367
|
+
#: tag naming the setting, and its value in the next tag. WebLogic deployment
|
|
368
|
+
#: plans (`<variable><name>…</name><value>…</value></variable>`) and servlet
|
|
369
|
+
#: `<context-param>` blocks are the same shape with different tag names.
|
|
370
|
+
_XML_NAME_TAG = re.compile(
|
|
371
|
+
r"<(?:param-)?name>\s*(?:" + re.escape(_PROPERTY_KEY) + r"|" + _ENV_KEY
|
|
372
|
+
+ r")\s*</(?:param-)?name>",
|
|
373
|
+
re.IGNORECASE,
|
|
374
|
+
)
|
|
375
|
+
_XML_VALUE_TAG = re.compile(r"<(?:param-)?value>\s*([^<]*)</(?:param-)?value>")
|
|
376
|
+
|
|
377
|
+
#: The tree scan ends an unquoted `KEY=value` at the first comma, because in a
|
|
378
|
+
#: repository a comma is usually the next item of a flow list rather than the
|
|
379
|
+
#: next profile. A deployment artefact the caller *names* is the opposite case:
|
|
380
|
+
#: `SPRING_PROFILES_ACTIVE=prod,metrics` is one deployment activating two
|
|
381
|
+
#: profiles, and dropping `metrics` would answer under a profile set the
|
|
382
|
+
#: deployment does not run. Same patterns otherwise, comma admitted.
|
|
383
|
+
_DEPLOYMENT_LINE_PATTERNS: tuple[tuple[str, re.Pattern], ...] = tuple(
|
|
384
|
+
(label, re.compile(_ENV_KEY + r"\s*[:=]\s*([^\s\"']+)")) if label == "env"
|
|
385
|
+
else (label, pattern)
|
|
386
|
+
for label, pattern in _LINE_PATTERNS
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _next_xml_value(lines: list[str], after: int) -> Optional[str]:
|
|
391
|
+
"""The `<value>`/`<param-value>` that belongs to the name tag on line *after*.
|
|
392
|
+
|
|
393
|
+
Bounded to the next few lines on purpose: a value that is not adjacent to its
|
|
394
|
+
name is not a pair this reader can prove, and guessing at distance is how a
|
|
395
|
+
deployment artefact starts deciding things it does not say.
|
|
396
|
+
"""
|
|
397
|
+
for line in lines[after - 1:after + 3]:
|
|
398
|
+
match = _XML_VALUE_TAG.search(line)
|
|
399
|
+
if match:
|
|
400
|
+
return _clean(match.group(1))
|
|
401
|
+
return None
|
|
402
|
+
|
|
368
403
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
404
|
+
def collect_deployment_artefact_signals(
|
|
405
|
+
path: Path, *, root: Optional[Path] = None
|
|
406
|
+
) -> list[Signal]:
|
|
407
|
+
"""Signals from ONE deployment artefact the caller names (F-BG).
|
|
408
|
+
|
|
409
|
+
The artefact that decides the profile set lives outside the code — a
|
|
410
|
+
Kubernetes manifest, a compose file, a WebLogic deployment plan, a
|
|
411
|
+
Dockerfile, a dotenv, a launch script — and `posture` used to end at exactly
|
|
412
|
+
that boundary: *"SPRING_PROFILES_ACTIVE in the process environment outranks
|
|
413
|
+
every artefact below it, and this analysis cannot see the process
|
|
414
|
+
environment"*. It still cannot see the process environment; what it can now
|
|
415
|
+
read is the file that fills it, when a reviewer says which one.
|
|
416
|
+
|
|
417
|
+
This is evidence the reviewer explicitly supplies for the deployment being
|
|
418
|
+
audited, so a deciding value here outranks defaults found in the tree. It
|
|
419
|
+
travels as ordinary evidence — `file:line`, the raw text, the same
|
|
420
|
+
placeholder handling — and it decides nothing on its own: a value that is a
|
|
421
|
+
`${...}` placeholder, or absent, leaves the repository's own verdict standing
|
|
422
|
+
rather than inventing one.
|
|
423
|
+
|
|
424
|
+
Every shape is read the same way as the repository scan, because they are the
|
|
425
|
+
same artefact families: the three line patterns (`-D`, `KEY=`, `key: `), the
|
|
426
|
+
two-line Kubernetes `name:`/`value:` pair, and the XML name/value pair used by
|
|
427
|
+
deployment plans and `<context-param>` blocks.
|
|
373
428
|
"""
|
|
374
429
|
source = Path(path).expanduser().resolve()
|
|
375
430
|
root_path = Path(root).resolve() if root is not None else None
|
|
@@ -380,21 +435,154 @@ def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list
|
|
|
380
435
|
rel = source.as_posix()
|
|
381
436
|
|
|
382
437
|
signals: list[Signal] = []
|
|
383
|
-
|
|
438
|
+
lines = text.splitlines()
|
|
439
|
+
for index, line in enumerate(lines, start=1):
|
|
384
440
|
stripped = line.strip()
|
|
441
|
+
# A commented line states nothing: dotenv and YAML both use `#`, and an
|
|
442
|
+
# artefact's history is not its content.
|
|
385
443
|
if not stripped or stripped.startswith("#"):
|
|
386
444
|
continue
|
|
387
|
-
for _label, pattern in
|
|
445
|
+
for _label, pattern in _DEPLOYMENT_LINE_PATTERNS:
|
|
388
446
|
match = pattern.search(line)
|
|
389
|
-
if match:
|
|
447
|
+
if not match:
|
|
448
|
+
continue
|
|
449
|
+
value = _clean(match.group(1))
|
|
450
|
+
if not value and _K8S_NAME.search(stripped):
|
|
451
|
+
value = _next_k8s_value(lines, index)
|
|
452
|
+
signals.append(_signal("deployment_input", rel, index, line, value, explicit=True))
|
|
453
|
+
break
|
|
454
|
+
else:
|
|
455
|
+
if _K8S_NAME.search(stripped):
|
|
390
456
|
signals.append(_signal(
|
|
391
|
-
"deployment_input", rel, index, line,
|
|
392
|
-
explicit=True,
|
|
457
|
+
"deployment_input", rel, index, line,
|
|
458
|
+
_next_k8s_value(lines, index), explicit=True,
|
|
459
|
+
))
|
|
460
|
+
elif _XML_NAME_TAG.search(line):
|
|
461
|
+
signals.append(_signal(
|
|
462
|
+
"deployment_input", rel, index, line,
|
|
463
|
+
_next_xml_value(lines, index), explicit=True,
|
|
393
464
|
))
|
|
394
|
-
break
|
|
395
465
|
return signals
|
|
396
466
|
|
|
397
467
|
|
|
468
|
+
def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list[Signal]:
|
|
469
|
+
"""Signals from a caller-supplied deployment env/dotenv file.
|
|
470
|
+
|
|
471
|
+
Retained name for `--env-file`; the reader is the general one (F-BG), because
|
|
472
|
+
a dotenv is one deployment artefact shape and there was never a reason for it
|
|
473
|
+
to be the only one this product could be handed.
|
|
474
|
+
"""
|
|
475
|
+
return collect_deployment_artefact_signals(path, root=root)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
#: The `--profile-from` block. Versioned separately from the resolution above
|
|
479
|
+
#: because it answers a narrower question — *did the artefacts you named decide
|
|
480
|
+
#: the profile set* — and a consumer may read it without reading anything else.
|
|
481
|
+
PROFILE_FROM_SCHEMA = "profile-from-v1"
|
|
482
|
+
|
|
483
|
+
#: The deciding outcome of `--profile-from`. Deliberately *not* `DECIDED`: that
|
|
484
|
+
#: constant says "this repository decides it", and the whole point of this flag is
|
|
485
|
+
#: that the deciding artefact lives outside the repository.
|
|
486
|
+
DECIDED_BY_ARTEFACT = "decided_by_deployment_artefact"
|
|
487
|
+
|
|
488
|
+
#: What outranks every artefact, said once and reused wherever the block is
|
|
489
|
+
#: published, so the one caveat that matters cannot be dropped by a caller.
|
|
490
|
+
PROCESS_ENV_CAVEAT = (
|
|
491
|
+
"SPRING_PROFILES_ACTIVE in the process environment outranks every artefact, "
|
|
492
|
+
"including this one; what is claimed is what the named artefact sets, not "
|
|
493
|
+
"what the running process was given."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def decide_profiles_from_artefacts(
|
|
498
|
+
paths: Iterable[Path], *, root: Optional[Path] = None
|
|
499
|
+
) -> dict:
|
|
500
|
+
"""Read the named deployment artefacts and say whether they decide the profile set.
|
|
501
|
+
|
|
502
|
+
Three outcomes, and only the first one changes what `posture` answers:
|
|
503
|
+
|
|
504
|
+
* **decided** — exactly one distinct profile set across every artefact named.
|
|
505
|
+
The set is published with the `file:line` that states it.
|
|
506
|
+
* **artefacts_disagree** — two artefacts name different sets. They are two
|
|
507
|
+
deployments, not a contradiction to resolve, so nothing is decided here for
|
|
508
|
+
the same reason the repository scan refuses it.
|
|
509
|
+
* **not_decided** — the artefacts name the setting without a value, or state a
|
|
510
|
+
placeholder resolved elsewhere, or never mention it at all. The repository's
|
|
511
|
+
own verdict stands untouched; this is the undecidable-first contract, and it
|
|
512
|
+
is what makes the flag safe to point at the wrong file.
|
|
513
|
+
"""
|
|
514
|
+
root_path = Path(root).resolve() if root is not None else None
|
|
515
|
+
artefacts: list[dict] = []
|
|
516
|
+
signals: list[Signal] = []
|
|
517
|
+
for raw_path in paths:
|
|
518
|
+
source = Path(raw_path).expanduser()
|
|
519
|
+
found = collect_deployment_artefact_signals(source, root=root_path)
|
|
520
|
+
signals.extend(found)
|
|
521
|
+
artefacts.append({
|
|
522
|
+
"path": source.as_posix(),
|
|
523
|
+
"signals": [s.to_dict() for s in found],
|
|
524
|
+
"states_the_setting": bool(found),
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
deciding = [s for s in signals if s.value]
|
|
528
|
+
distinct = sorted({s.value for s in deciding if s.value})
|
|
529
|
+
|
|
530
|
+
block: dict = {
|
|
531
|
+
"schema_version": PROFILE_FROM_SCHEMA,
|
|
532
|
+
"artefacts": artefacts,
|
|
533
|
+
"outranked_by": PROCESS_ENV_CAVEAT,
|
|
534
|
+
}
|
|
535
|
+
if len(distinct) > 1:
|
|
536
|
+
block.update({
|
|
537
|
+
"decided": False,
|
|
538
|
+
"reason": DISAGREE,
|
|
539
|
+
"profiles": None,
|
|
540
|
+
"decided_by": None,
|
|
541
|
+
"statement": (
|
|
542
|
+
"The artefacts you named set different active profile sets ("
|
|
543
|
+
+ ", ".join(repr(v) for v in distinct)
|
|
544
|
+
+ "). These are different deployments, not a contradiction to "
|
|
545
|
+
"resolve — name the one artefact that describes the deployment "
|
|
546
|
+
"you are auditing. Until then the repository's own verdict stands."
|
|
547
|
+
),
|
|
548
|
+
})
|
|
549
|
+
return block
|
|
550
|
+
if not distinct:
|
|
551
|
+
named = sum(1 for a in artefacts if a["states_the_setting"])
|
|
552
|
+
block.update({
|
|
553
|
+
"decided": False,
|
|
554
|
+
"reason": "no_value_here",
|
|
555
|
+
"profiles": None,
|
|
556
|
+
"decided_by": None,
|
|
557
|
+
"statement": (
|
|
558
|
+
(
|
|
559
|
+
f"{named} artefact(s) name the setting and none of them states a "
|
|
560
|
+
"value here — a placeholder or an empty entry names where the "
|
|
561
|
+
"value comes from, not what it is. "
|
|
562
|
+
if named else
|
|
563
|
+
"The artefact(s) you named do not set `spring.profiles.active` or "
|
|
564
|
+
"`SPRING_PROFILES_ACTIVE`. "
|
|
565
|
+
)
|
|
566
|
+
+ "The repository's own verdict is unchanged."
|
|
567
|
+
),
|
|
568
|
+
})
|
|
569
|
+
return block
|
|
570
|
+
|
|
571
|
+
first = next(s for s in deciding if s.value == distinct[0])
|
|
572
|
+
block.update({
|
|
573
|
+
"decided": True,
|
|
574
|
+
"reason": DECIDED_BY_ARTEFACT,
|
|
575
|
+
"profiles": list(_split_profiles(distinct[0])),
|
|
576
|
+
"decided_by": f"{first.file}:{first.line}",
|
|
577
|
+
"statement": (
|
|
578
|
+
f"{first.file}:{first.line} sets the active profile set to "
|
|
579
|
+
f"{distinct[0]!r}, so that is the profile set this posture answers "
|
|
580
|
+
"under. " + PROCESS_ENV_CAVEAT
|
|
581
|
+
),
|
|
582
|
+
})
|
|
583
|
+
return block
|
|
584
|
+
|
|
585
|
+
|
|
398
586
|
def resolve_environment(
|
|
399
587
|
root: Path,
|
|
400
588
|
deployment_signals: Optional[Iterable[Signal]] = None,
|
sourcecode/format_contract.py
CHANGED
|
@@ -50,6 +50,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
|
|
|
50
50
|
"rename-class": ("json", "yaml"),
|
|
51
51
|
"risk": ("json", "yaml"),
|
|
52
52
|
"trend": ("json", "yaml"),
|
|
53
|
+
"timeline": ("json", "yaml"),
|
|
53
54
|
"verify": ("json", "yaml"),
|
|
54
55
|
"verify-edit": ("json", "yaml"),
|
|
55
56
|
"impact": ("json", "yaml"),
|
sourcecode/non_coverage.py
CHANGED
|
@@ -119,17 +119,21 @@ NON_COVERAGE: tuple[NonCoverage, ...] = (
|
|
|
119
119
|
"General request-input-to-sink taint analysis."
|
|
120
120
|
),
|
|
121
121
|
why=(
|
|
122
|
-
"`ask risk` includes
|
|
123
|
-
"positional calls in repository Java sources
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
122
|
+
"`ask risk` includes two bounded slices for annotated HTTP parameters "
|
|
123
|
+
"and positional calls in repository Java sources: known query sinks, "
|
|
124
|
+
"and MyBatis statements that splice a value into their SQL. Anything "
|
|
125
|
+
"broader needs value flow through a program, which is a different "
|
|
126
|
+
"analysis with a different failure mode: an unsound one produces "
|
|
127
|
+
"confident findings that are wrong, and every claim here is meant to "
|
|
128
|
+
"be checkable against the line that produced it."
|
|
128
129
|
),
|
|
129
130
|
instead=(
|
|
130
131
|
"A dataflow scanner (Semgrep, CodeQL) for complete taint. What this "
|
|
131
|
-
"product adds on top is reachability and, for the bounded
|
|
132
|
-
"whether an annotated HTTP input reaches a known concatenated query
|
|
132
|
+
"product adds on top is reachability and, for the two bounded slices, "
|
|
133
|
+
"whether an annotated HTTP input reaches a known concatenated query "
|
|
134
|
+
"sink or a spliced SQL statement — the latter with a three-state "
|
|
135
|
+
"verdict that says `undecidable` rather than clearing a route it could "
|
|
136
|
+
"not follow."
|
|
133
137
|
),
|
|
134
138
|
),
|
|
135
139
|
NonCoverage(
|
sourcecode/perf.py
CHANGED
|
@@ -460,7 +460,7 @@ RELEASE_GATE_MIN_RUNS = 5
|
|
|
460
460
|
#: what is left out is left out for a reason, and the reason is published below.
|
|
461
461
|
RELEASE_GATE_COMMANDS: tuple[str, ...] = (
|
|
462
462
|
"", "endpoints", "spring-audit", "migrate-check", "validation", "posture",
|
|
463
|
-
"risk", "modernize", "repo-ir",
|
|
463
|
+
"risk", "modernize", "repo-ir",
|
|
464
464
|
)
|
|
465
465
|
|
|
466
466
|
#: Extra arguments a gated command needs to be measured at all — one authority,
|
|
@@ -473,6 +473,13 @@ RELEASE_GATE_COMMANDS: tuple[str, ...] = (
|
|
|
473
473
|
#: form the harness already uses to count symbols.
|
|
474
474
|
RELEASE_GATE_COMMAND_ARGS: dict[str, tuple[str, ...]] = {
|
|
475
475
|
"repo-ir": ("--summary-only",),
|
|
476
|
+
# The first gated release found this one: `ask migrate-check <gate repo>`
|
|
477
|
+
# exits 1 with OUTPUT_TOO_LARGE (~269 K estimated tokens on 2 985 files), and
|
|
478
|
+
# for five releases the harness recorded the timing anyway — the analysis did
|
|
479
|
+
# run, so the number looked plausible (8,72 s) and it was the cost of a run
|
|
480
|
+
# that ended in a refusal. `--compact` is the command's own published remedy
|
|
481
|
+
# ("bounded decision summary, same report") and it measures the same scan.
|
|
482
|
+
"migrate-check": ("--compact",),
|
|
476
483
|
}
|
|
477
484
|
|
|
478
485
|
#: What the gate does **not** cover, and why — published rather than omitted, so
|
|
@@ -491,6 +498,13 @@ RELEASE_GATE_EXCLUSIONS: tuple[tuple[str, str], ...] = (
|
|
|
491
498
|
("pr-impact / review-pr", "diff-dependent — their cost follows a diff the "
|
|
492
499
|
"gate would have to invent, and an invented diff "
|
|
493
500
|
"measures the invention"),
|
|
501
|
+
("data-exposure", "needs `dataLabels` declared in the repository under audit, "
|
|
502
|
+
"and the gate repository declares none: measured at 0,28 s "
|
|
503
|
+
"on the first gated release, which is the cost of the "
|
|
504
|
+
"abstention (`answered: false`) and not of the command. A "
|
|
505
|
+
"cell that times a refusal is worse than no cell — it is a "
|
|
506
|
+
"green light for work nobody did (found by this gate, on "
|
|
507
|
+
"its first run over a release)"),
|
|
494
508
|
)
|
|
495
509
|
|
|
496
510
|
|