sourcecode 4.16.0__py3-none-any.whl → 4.17.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/audit_report.py +48 -6
- sourcecode/cli.py +155 -22
- sourcecode/data_exposure.py +5 -1
- sourcecode/data_labels.py +10 -3
- sourcecode/declarations.py +81 -0
- sourcecode/mcp/orchestrator.py +3 -1
- sourcecode/non_coverage.py +86 -0
- sourcecode/partial_contract.py +81 -0
- sourcecode/phased_run.py +14 -0
- sourcecode/posture.py +11 -0
- sourcecode/ris.py +4 -1
- sourcecode/risk.py +135 -28
- sourcecode/rule_pass.py +11 -1
- sourcecode/security_config.py +6 -5
- sourcecode/security_posture.py +43 -3
- sourcecode/spring_findings.py +22 -0
- sourcecode/spring_model.py +22 -1
- sourcecode/spring_security_audit.py +118 -10
- sourcecode/spring_tx_analyzer.py +5 -1
- {sourcecode-4.16.0.dist-info → sourcecode-4.17.0.dist-info}/METADATA +3 -3
- {sourcecode-4.16.0.dist-info → sourcecode-4.17.0.dist-info}/RECORD +25 -23
- {sourcecode-4.16.0.dist-info → sourcecode-4.17.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.16.0.dist-info → sourcecode-4.17.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.16.0.dist-info → sourcecode-4.17.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/audit_report.py
CHANGED
|
@@ -12,9 +12,10 @@ import hmac
|
|
|
12
12
|
import json
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Any, Optional
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
16
|
|
|
17
17
|
from sourcecode import __version__
|
|
18
|
+
from sourcecode.partial_contract import floor_counts, read_count
|
|
18
19
|
|
|
19
20
|
AUDIT_REPORT_SCHEMA = "audit-report-v1"
|
|
20
21
|
|
|
@@ -40,14 +41,30 @@ def build_audit_report(
|
|
|
40
41
|
sign_key: Optional[bytes] = None,
|
|
41
42
|
risk_limit: int = 10,
|
|
42
43
|
risk_payload: Optional[dict[str, Any]] = None,
|
|
44
|
+
progress: "Optional[Callable[..., Any]]" = None,
|
|
45
|
+
checkpoint: "Optional[Callable[[str, dict], None]]" = None,
|
|
46
|
+
partial_status: "Optional[Callable[..., dict]]" = None,
|
|
43
47
|
) -> dict[str, Any]:
|
|
44
|
-
"""Build the buyer-readable audit bundle from existing command payloads.
|
|
48
|
+
"""Build the buyer-readable audit bundle from existing command payloads.
|
|
49
|
+
|
|
50
|
+
C3-89. This command composes the whole of `risk`, and for four releases it did
|
|
51
|
+
so with none of the three arguments above — no observer carrying the deadline,
|
|
52
|
+
no checkpoint, no `partial_status` — while `cli` built no `PhasedRun` for it
|
|
53
|
+
either. The operator's budget therefore did not exist here at all: the field
|
|
54
|
+
measured **2 265 s under `ASK_MAX_ANALYSIS_SECONDS=300`** (7,55×), CPU-bound in
|
|
55
|
+
one worker, with no `partial` and no answer. C3-87 bound the second consumer of
|
|
56
|
+
that fact and moved the composition of the `_partial` keys into
|
|
57
|
+
`PhasedRun.status` so the third would get it by construction; this is the third.
|
|
58
|
+
"""
|
|
45
59
|
from sourcecode.posture import build_posture
|
|
46
60
|
|
|
47
61
|
root = Path(root).resolve()
|
|
48
62
|
if risk_payload is None:
|
|
49
63
|
from sourcecode.risk import build_risk
|
|
50
|
-
risk = build_risk(
|
|
64
|
+
risk = build_risk(
|
|
65
|
+
root, limit=risk_limit, min_band="low", profiles=profiles,
|
|
66
|
+
progress=progress, checkpoint=checkpoint, partial_status=partial_status,
|
|
67
|
+
)
|
|
51
68
|
risk_source = "computed"
|
|
52
69
|
else:
|
|
53
70
|
risk = risk_payload
|
|
@@ -65,8 +82,10 @@ def build_audit_report(
|
|
|
65
82
|
"summary": {
|
|
66
83
|
"risk_model": risk.get("model"),
|
|
67
84
|
"risk_source": risk_source,
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
# F-AV: read under either spelling, and republished under the one
|
|
86
|
+
# the bundle's own `summary.partial` already qualifies.
|
|
87
|
+
"total_defects": read_count(risk, "total_defects"),
|
|
88
|
+
"total_findings": read_count(risk, "total_findings"),
|
|
70
89
|
"risk_bands": risk.get("by_band", {}),
|
|
71
90
|
"top_risks_shown": len(top_risks),
|
|
72
91
|
"endpoint_access": access.get("summary", {}),
|
|
@@ -85,6 +104,28 @@ def build_audit_report(
|
|
|
85
104
|
"non_coverage": (risk.get("non_coverage") or {}).get("items", []),
|
|
86
105
|
},
|
|
87
106
|
}
|
|
107
|
+
# C3-89. A bundle whose evidence was cut says so **before** it is signed, and
|
|
108
|
+
# says it where a reader of the summary sees it — a signature over a truncated
|
|
109
|
+
# payload that does not declare the truncation is the worst artifact this
|
|
110
|
+
# product can produce. The keys come from `risk` itself, whichever way it
|
|
111
|
+
# arrived: a `--from-risk` file that was cut carries its own `_partial`, and
|
|
112
|
+
# packaging it silently would launder a floor into a verdict (C2-31's rule).
|
|
113
|
+
if risk.get("partial"):
|
|
114
|
+
unsigned["summary"]["partial"] = True
|
|
115
|
+
floor_counts(unsigned["summary"], True) # F-AV, on the bundle too
|
|
116
|
+
unsigned["summary"]["counts_are_floor"] = True
|
|
117
|
+
unsigned["summary"]["counts_basis"] = (
|
|
118
|
+
"The risk evidence in this bundle was stopped by the analysis budget. "
|
|
119
|
+
"`total_defects` and `risk_bands` count what the families that ran "
|
|
120
|
+
"measured, and are a floor over the repository."
|
|
121
|
+
)
|
|
122
|
+
_risk_partial = risk.get("_partial")
|
|
123
|
+
if isinstance(_risk_partial, dict):
|
|
124
|
+
unsigned["_partial"] = dict(_risk_partial)
|
|
125
|
+
unsigned["_partial"]["command"] = "audit-report"
|
|
126
|
+
unsigned["_partial"]["cut_in"] = "risk"
|
|
127
|
+
if risk_source == "from-risk":
|
|
128
|
+
unsigned["_partial"]["cut_in"] = "the `--from-risk` payload"
|
|
88
129
|
from sourcecode.provenance import build_evidence_manifest
|
|
89
130
|
unsigned["evidence_manifest"] = build_evidence_manifest(
|
|
90
131
|
unsigned,
|
|
@@ -117,7 +158,8 @@ def render_markdown(report: dict[str, Any]) -> str:
|
|
|
117
158
|
f"- Repository: `{repo}`",
|
|
118
159
|
f"- ASK version: `{(report.get('tool') or {}).get('version', '')}`",
|
|
119
160
|
f"- Profile set: `{profile}`",
|
|
120
|
-
f"- Total defects: `{summary
|
|
161
|
+
f"- Total defects: `{read_count(summary, 'total_defects')}`"
|
|
162
|
+
+ (" **(floor — this run was cut short)**" if summary.get("partial") else ""),
|
|
121
163
|
f"- Risk bands: `{summary.get('risk_bands')}`",
|
|
122
164
|
f"- Endpoint access: `{summary.get('endpoint_access')}`",
|
|
123
165
|
"",
|
sourcecode/cli.py
CHANGED
|
@@ -24,6 +24,8 @@ from sourcecode.output_encoding import json_ensure_ascii as _json_ensure_ascii
|
|
|
24
24
|
from sourcecode.output_encoding import set_ascii_fallback
|
|
25
25
|
from sourcecode.phased_run import WHY_BUDGET as _WHY_BUDGET
|
|
26
26
|
from sourcecode.phased_run import PhasedRun
|
|
27
|
+
from sourcecode.partial_contract import PARTIAL_EXIT_CODE as _PARTIAL_EXIT
|
|
28
|
+
from sourcecode.partial_contract import read_count as _read_count
|
|
27
29
|
from sourcecode import perf
|
|
28
30
|
from sourcecode.caller_metrics import (
|
|
29
31
|
CALLER_METRIC_RECONCILIATION,
|
|
@@ -238,6 +240,16 @@ def _writes_help_block() -> str:
|
|
|
238
240
|
lines.append(f" {name.ljust(width)} {what}")
|
|
239
241
|
lines.append("")
|
|
240
242
|
lines.append(" --no-write (ASK_READONLY=1) refuses all of it and says so on stderr.")
|
|
243
|
+
# F-AX: the other half of an audit that may not write — where the repository's
|
|
244
|
+
# own declaration is allowed to live.
|
|
245
|
+
lines.append(
|
|
246
|
+
" --config <path> (ASK_CONFIG) reads the repository's declaration from "
|
|
247
|
+
"outside it,"
|
|
248
|
+
)
|
|
249
|
+
lines.append(
|
|
250
|
+
" so labels and custom security annotations can be "
|
|
251
|
+
"declared without writing."
|
|
252
|
+
)
|
|
241
253
|
return "\n".join(lines)
|
|
242
254
|
|
|
243
255
|
|
|
@@ -746,7 +758,7 @@ def _preprocess_args(args: list[str]) -> list[str]:
|
|
|
746
758
|
|
|
747
759
|
def _preprocess_argv() -> None:
|
|
748
760
|
"""Apply _preprocess_args to sys.argv in-place (used by main_entry)."""
|
|
749
|
-
modified = _preprocess_args(_apply_no_write(sys.argv[1:]))
|
|
761
|
+
modified = _preprocess_args(_apply_external_config(_apply_no_write(sys.argv[1:])))
|
|
750
762
|
sys.argv = sys.argv[:1] + modified
|
|
751
763
|
|
|
752
764
|
|
|
@@ -2045,6 +2057,57 @@ NO_WRITE_OPTION_HELP = (
|
|
|
2045
2057
|
NO_WRITE_FLAG = "--no-write"
|
|
2046
2058
|
|
|
2047
2059
|
|
|
2060
|
+
#: The one help string for `--config` (F-AX).
|
|
2061
|
+
CONFIG_OPTION_HELP = (
|
|
2062
|
+
"Read this repository's declaration (data labels, custom security "
|
|
2063
|
+
"annotations) from <path> instead of <repo>/sourcecode.config.json — so an "
|
|
2064
|
+
"auditor can declare without writing into somebody else's tree. Env: "
|
|
2065
|
+
"ASK_CONFIG."
|
|
2066
|
+
)
|
|
2067
|
+
|
|
2068
|
+
#: Handled before the parser, for `--no-write`'s reason: a declaration honoured
|
|
2069
|
+
#: by some commands and not others is worse than none, because the reader cannot
|
|
2070
|
+
#: tell which answer used it.
|
|
2071
|
+
CONFIG_FLAG = "--config"
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def _apply_external_config(argv: "list[str]") -> "list[str]":
|
|
2075
|
+
"""Consume `--config <path>`, putting the declaration in force. Never raises.
|
|
2076
|
+
|
|
2077
|
+
F-AX. Under `ASK_READONLY=1` the only way to declare anything used to be a
|
|
2078
|
+
file inside the analysed tree, which left `data-exposure`, the `Verified`
|
|
2079
|
+
upgrade for a custom gate, and everything downstream of them unreachable by
|
|
2080
|
+
construction for the exact user this mode exists for — the auditor of
|
|
2081
|
+
somebody else's repository.
|
|
2082
|
+
"""
|
|
2083
|
+
if CONFIG_FLAG not in argv:
|
|
2084
|
+
return argv
|
|
2085
|
+
out: "list[str]" = []
|
|
2086
|
+
index = 0
|
|
2087
|
+
while index < len(argv):
|
|
2088
|
+
token = argv[index]
|
|
2089
|
+
if token != CONFIG_FLAG:
|
|
2090
|
+
out.append(token)
|
|
2091
|
+
index += 1
|
|
2092
|
+
continue
|
|
2093
|
+
value = argv[index + 1] if index + 1 < len(argv) else ""
|
|
2094
|
+
if not value or value.startswith("-"):
|
|
2095
|
+
print(
|
|
2096
|
+
f"error: {CONFIG_FLAG} needs a path to a declaration file.\n"
|
|
2097
|
+
f" use: ask {CONFIG_FLAG} ./declarations.json <command> …",
|
|
2098
|
+
file=sys.stderr,
|
|
2099
|
+
)
|
|
2100
|
+
raise SystemExit(2)
|
|
2101
|
+
try:
|
|
2102
|
+
from sourcecode import declarations
|
|
2103
|
+
|
|
2104
|
+
declarations.set_config_path(value)
|
|
2105
|
+
except Exception:
|
|
2106
|
+
pass
|
|
2107
|
+
index += 2
|
|
2108
|
+
return out
|
|
2109
|
+
|
|
2110
|
+
|
|
2048
2111
|
def _apply_no_write(argv: "list[str]") -> "list[str]":
|
|
2049
2112
|
"""Consume `--no-write` from *argv*, putting the mode in force. Never raises."""
|
|
2050
2113
|
if NO_WRITE_FLAG not in argv:
|
|
@@ -8395,8 +8458,23 @@ def spring_audit_cmd(
|
|
|
8395
8458
|
else:
|
|
8396
8459
|
output = _serialize_dict(data, format)
|
|
8397
8460
|
|
|
8398
|
-
|
|
8461
|
+
# F-AV: our own readers ask for the count under either spelling — the break
|
|
8462
|
+
# is meant for a consumer that never asked whether the run finished.
|
|
8463
|
+
_total = _read_count(combined.summary, "total_findings") or 0
|
|
8399
8464
|
_partial_msg = " — PARTIAL, budget exhausted" if _stopped_early else ""
|
|
8465
|
+
# CL-19: the payload carries `stack_fit`, and the person who most needs it is
|
|
8466
|
+
# the one who will read the counts and stop. Said once, on stderr, so piped
|
|
8467
|
+
# output stays byte-identical.
|
|
8468
|
+
if not combined.spring_detected:
|
|
8469
|
+
_notice(
|
|
8470
|
+
"[ask] stack fit: no Spring detected in this scope. The access, "
|
|
8471
|
+
"transaction and "
|
|
8472
|
+
"Boot-readiness axes model Spring semantics and are NOT measuring "
|
|
8473
|
+
"this repository — read their answers as `unknown`, never as `none`. "
|
|
8474
|
+
"The structural axes (call graph, endpoint census, coupling, JDK "
|
|
8475
|
+
"inventory) answer here as they do anywhere. See `stack_fit` in the "
|
|
8476
|
+
"payload."
|
|
8477
|
+
)
|
|
8400
8478
|
_emit_command_output(
|
|
8401
8479
|
output, output_path, copy,
|
|
8402
8480
|
success_msg=(
|
|
@@ -8411,8 +8489,10 @@ def spring_audit_cmd(
|
|
|
8411
8489
|
|
|
8412
8490
|
if ci and _stopped_early:
|
|
8413
8491
|
# A gate on incomplete evidence must not read as a pass — the same rule
|
|
8414
|
-
# `pr-impact` holds for UNKNOWN.
|
|
8415
|
-
|
|
8492
|
+
# `pr-impact` holds for UNKNOWN. F-AV: and it must not read as a *failure*
|
|
8493
|
+
# either, because "the gate found something" and "the gate did not finish"
|
|
8494
|
+
# call for different pipeline decisions. 75 is neither 0, 1 nor 2.
|
|
8495
|
+
raise typer.Exit(code=_PARTIAL_EXIT)
|
|
8416
8496
|
if ci and combined.findings:
|
|
8417
8497
|
raise typer.Exit(code=1)
|
|
8418
8498
|
|
|
@@ -8962,7 +9042,7 @@ def risk_cmd(
|
|
|
8962
9042
|
copy,
|
|
8963
9043
|
success_msg=(
|
|
8964
9044
|
f"risk written to {output_path} ({data['shown']} of "
|
|
8965
|
-
f"{data
|
|
9045
|
+
f"{_read_count(data, 'total_defects')} defects composed)"
|
|
8966
9046
|
# C3-87: the terminal says it too. A reader who never opens the payload
|
|
8967
9047
|
# must not take a truncated ranking for the repository's risk.
|
|
8968
9048
|
+ (" — PARTIAL, budget exhausted" if data.get("partial") else "")
|
|
@@ -9256,26 +9336,58 @@ def audit_report_cmd(
|
|
|
9256
9336
|
)
|
|
9257
9337
|
raise typer.Exit(code=1)
|
|
9258
9338
|
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9339
|
+
# C3-89: this command composes the whole of `risk`, and it did so with no
|
|
9340
|
+
# deadline of any kind — no `PhasedRun`, no `stop_when` on the observers, no
|
|
9341
|
+
# checkpoint. The field measured 2 265 s under a 300 s budget, CPU-bound in one
|
|
9342
|
+
# worker, killed by hand with nothing to show. `risk` was bound in 4.16.0
|
|
9343
|
+
# (C3-87) and the composition of the `_partial` keys moved into
|
|
9344
|
+
# `PhasedRun.status` precisely so the next consumer would inherit it; this is
|
|
9345
|
+
# that consumer. The phases are `risk`'s own (it is the analysis being run)
|
|
9346
|
+
# plus this command's packaging step.
|
|
9347
|
+
_phase = "packaging audit evidence"
|
|
9348
|
+
_budget = _analysis_budget("audit-report")
|
|
9349
|
+
_run = PhasedRun(
|
|
9350
|
+
command="audit-report",
|
|
9351
|
+
root=path,
|
|
9352
|
+
phases=["audit", "compose", "bundle"],
|
|
9353
|
+
output_path=output_path,
|
|
9354
|
+
budget_seconds=_budget.get("configured_max_seconds"),
|
|
9355
|
+
budget_source=_budget.get("configured_source"),
|
|
9356
|
+
writer=_safe_write_file,
|
|
9357
|
+
)
|
|
9358
|
+
with _expensive_analysis_scope("audit-report", path, _phase):
|
|
9359
|
+
_prog = Progress()
|
|
9360
|
+
_prog.start(_phase)
|
|
9361
|
+
try:
|
|
9362
|
+
data = build_audit_report(
|
|
9363
|
+
path, profiles=_profile_set(profile), sign_key=key_bytes,
|
|
9364
|
+
risk_payload=risk_payload,
|
|
9365
|
+
progress=lambda stage, unit="rule families": _rule_pass_progress(
|
|
9366
|
+
_prog, stage, unit, stop_when=_run.exhausted
|
|
9367
|
+
),
|
|
9368
|
+
checkpoint=_run.checkpoint,
|
|
9369
|
+
partial_status=lambda **cut: _run.status(_WHY_BUDGET, **cut),
|
|
9370
|
+
)
|
|
9371
|
+
finally:
|
|
9372
|
+
_prog.stop()
|
|
9268
9373
|
output = render_markdown(data) if fmt == "markdown" else _serialize_dict(data, fmt)
|
|
9374
|
+
_summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
|
|
9269
9375
|
_emit_command_output(
|
|
9270
9376
|
output,
|
|
9271
9377
|
output_path,
|
|
9272
9378
|
copy,
|
|
9273
|
-
success_msg=f"audit report written to {output_path}"
|
|
9379
|
+
success_msg=f"audit report written to {output_path}"
|
|
9380
|
+
# The terminal says it too, for the same reason `risk` does: a signed
|
|
9381
|
+
# bundle that was cut must not be read as the repository's audit.
|
|
9382
|
+
+ (" — PARTIAL, budget exhausted" if _summary.get("partial") else ""),
|
|
9274
9383
|
# A signed artifact cannot be mutated after signing. The report carries
|
|
9275
9384
|
# tool/version metadata itself, and `signature.payload_sha256` is over
|
|
9276
9385
|
# the exact JSON payload the user receives.
|
|
9277
9386
|
stamp_envelope=False,
|
|
9278
9387
|
)
|
|
9388
|
+
# The complete bundle is written; the checkpoint that stood in for it must not
|
|
9389
|
+
# outlive it (C3-77's invariant, as `risk` applies it).
|
|
9390
|
+
_run.discard_checkpoint()
|
|
9279
9391
|
|
|
9280
9392
|
|
|
9281
9393
|
@app.command("migrate-recipe")
|
|
@@ -11782,7 +11894,15 @@ def schema_cmd(
|
|
|
11782
11894
|
# through the command that already answers "what shape does this release
|
|
11783
11895
|
# produce?" rather than a command of its own.
|
|
11784
11896
|
names = available_schemas()
|
|
11785
|
-
|
|
11897
|
+
# C4-22: one authority for *what this argument accepts*. The registry names
|
|
11898
|
+
# itself (`registry_version`), so the listing, the dispatch below and the
|
|
11899
|
+
# rejection all read the same value — the field's audit was told
|
|
11900
|
+
# `available: ["envelope-v1"]` by a rejection that knew only half of what the
|
|
11901
|
+
# very next line accepts, and concluded that was the whole published surface.
|
|
11902
|
+
registries = [str(load_registry().get("registry_version") or "")]
|
|
11903
|
+
registries = [r for r in registries if r]
|
|
11904
|
+
accepted = list(names) + registries
|
|
11905
|
+
if name in registries:
|
|
11786
11906
|
_emit_command_output(
|
|
11787
11907
|
json.dumps(load_registry(), indent=2, ensure_ascii=False),
|
|
11788
11908
|
output_path,
|
|
@@ -11797,7 +11917,7 @@ def schema_cmd(
|
|
|
11797
11917
|
# answers have a single authority. Both are contracts this release
|
|
11798
11918
|
# carries, listed apart because they are not the same kind of thing.
|
|
11799
11919
|
json.dumps(
|
|
11800
|
-
{"schemas": names, "registries":
|
|
11920
|
+
{"schemas": names, "registries": registries},
|
|
11801
11921
|
indent=2, ensure_ascii=False,
|
|
11802
11922
|
),
|
|
11803
11923
|
output_path,
|
|
@@ -11810,10 +11930,14 @@ def schema_cmd(
|
|
|
11810
11930
|
except FileNotFoundError:
|
|
11811
11931
|
_emit_error_json(
|
|
11812
11932
|
INVALID_INPUT_CODE,
|
|
11813
|
-
f"
|
|
11814
|
-
available=
|
|
11815
|
-
hint=
|
|
11816
|
-
|
|
11933
|
+
f"'{name}' is not a published schema or registry.",
|
|
11934
|
+
available=accepted,
|
|
11935
|
+
hint=(
|
|
11936
|
+
"Run `ask schema` to list them: "
|
|
11937
|
+
+ ", ".join(accepted)
|
|
11938
|
+
+ "."
|
|
11939
|
+
),
|
|
11940
|
+
expected="One of: " + " | ".join(accepted),
|
|
11817
11941
|
)
|
|
11818
11942
|
raise typer.Exit(code=1)
|
|
11819
11943
|
# A schema document describes the envelope; stamping one into it would put
|
|
@@ -11871,8 +11995,17 @@ def config_cmd(
|
|
|
11871
11995
|
_answer.say(f"Telemetry: {'enabled' if is_enabled() else 'disabled'} (off by default; opt-in)")
|
|
11872
11996
|
_answer.say("")
|
|
11873
11997
|
|
|
11874
|
-
|
|
11998
|
+
# F-AX: the declaration may sit outside the analysed tree, and *which* file
|
|
11999
|
+
# answered is the first thing this command exists to say.
|
|
12000
|
+
from sourcecode.declarations import config_path as _config_path, is_external
|
|
12001
|
+
|
|
12002
|
+
_declaration_path = _config_path(_repo) or (_repo / CONFIG_FILENAME)
|
|
11875
12003
|
_answer.say(f"Repository: {_repo}")
|
|
12004
|
+
if is_external():
|
|
12005
|
+
_answer.say(
|
|
12006
|
+
"Declared with --config / ASK_CONFIG — read from outside the "
|
|
12007
|
+
"repository, which is unchanged by this run."
|
|
12008
|
+
)
|
|
11876
12009
|
if not _declaration_path.is_file():
|
|
11877
12010
|
_answer.say(f"Declaration: none at {_declaration_path}")
|
|
11878
12011
|
_answer.say(
|
sourcecode/data_exposure.py
CHANGED
|
@@ -40,6 +40,7 @@ from pathlib import Path
|
|
|
40
40
|
from typing import TYPE_CHECKING, Any, Optional
|
|
41
41
|
|
|
42
42
|
from sourcecode.data_labels import DataLabel, LabelDeclaration, load_labels
|
|
43
|
+
from sourcecode.declarations import source_note as _declaration_source
|
|
43
44
|
|
|
44
45
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
45
46
|
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
@@ -139,7 +140,10 @@ def build_data_exposure(
|
|
|
139
140
|
|
|
140
141
|
payload: dict = {
|
|
141
142
|
"schema_version": SCHEMA_VERSION,
|
|
142
|
-
|
|
143
|
+
# F-AX: which file answered. A declaration read from outside the analysed
|
|
144
|
+
# tree is the auditor's case, and a payload that does not say so leaves
|
|
145
|
+
# the reader unable to reproduce the answer.
|
|
146
|
+
"declaration": {**decl.to_dict(), "source": _declaration_source()},
|
|
143
147
|
"profiles": sorted(profiles) if profiles else None,
|
|
144
148
|
}
|
|
145
149
|
|
sourcecode/data_labels.py
CHANGED
|
@@ -39,7 +39,9 @@ from pathlib import Path
|
|
|
39
39
|
from typing import Optional
|
|
40
40
|
|
|
41
41
|
#: The same file the custom security annotations are declared in.
|
|
42
|
-
|
|
42
|
+
#: F-AX: the name lives in `declarations`, which also decides *where* it is
|
|
43
|
+
#: looked for. Re-exported so existing importers keep working.
|
|
44
|
+
from sourcecode.declarations import CONFIG_FILENAME # noqa: E402,F401
|
|
43
45
|
|
|
44
46
|
#: The key under which labels are declared.
|
|
45
47
|
CONFIG_KEY = "dataLabels"
|
|
@@ -108,9 +110,14 @@ def load_labels(root: Optional[Path]) -> LabelDeclaration:
|
|
|
108
110
|
Never raises. An absent file is not a problem — it is a repository that has
|
|
109
111
|
declared nothing, and the caller says so in the words of the question.
|
|
110
112
|
"""
|
|
111
|
-
|
|
113
|
+
# F-AX: the path is resolved by one authority, so `--config <external>` is
|
|
114
|
+
# honoured identically here and in `security_config` — a declaration that
|
|
115
|
+
# applied to one loader and not the other would be worse than none.
|
|
116
|
+
from sourcecode.declarations import config_path
|
|
117
|
+
|
|
118
|
+
cfg_path = config_path(root)
|
|
119
|
+
if cfg_path is None:
|
|
112
120
|
return LabelDeclaration()
|
|
113
|
-
cfg_path = Path(root) / CONFIG_FILENAME
|
|
114
121
|
try:
|
|
115
122
|
if not cfg_path.is_file():
|
|
116
123
|
return LabelDeclaration(config_file=None)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""declarations.py — where this repository's declaration lives. One authority.
|
|
2
|
+
|
|
3
|
+
`sourcecode.config.json` is what a repository declares **about itself**: the data
|
|
4
|
+
labels `data-exposure` refuses to invent, and the custom security annotations
|
|
5
|
+
`spring-audit` and `posture` will not guess. Two loaders read it, and until this
|
|
6
|
+
module each of them resolved the path itself, from its own copy of the filename.
|
|
7
|
+
|
|
8
|
+
**F-AX.** The path also has to be allowed to sit *outside* the analysed tree. The
|
|
9
|
+
use case is not hypothetical — it is the one field evaluation #20 executed: an
|
|
10
|
+
auditor running over somebody else's repository under `ASK_READONLY=1`, who found
|
|
11
|
+
four commands unreachable **by construction**, because the only way to declare
|
|
12
|
+
anything was to write a file into a tree they had been told not to touch:
|
|
13
|
+
|
|
14
|
+
"bajo restricción read-only, cuatro comandos son inalcanzables por
|
|
15
|
+
construcción. Falta un modo --config-from <ruta externa>."
|
|
16
|
+
|
|
17
|
+
So the resolution order is: an explicit `--config <path>` (or `ASK_CONFIG`),
|
|
18
|
+
then `<root>/sourcecode.config.json`. The override is a whole-process decision
|
|
19
|
+
like `--no-write` — a declaration that applied to some commands and not others
|
|
20
|
+
would be worse than none, because the reader could not tell which answer used it.
|
|
21
|
+
|
|
22
|
+
Nothing here reads or parses the file. It answers one question: *which path*.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
#: The name a repository's own declaration has when it lives in the repository.
|
|
32
|
+
CONFIG_FILENAME = "sourcecode.config.json"
|
|
33
|
+
|
|
34
|
+
#: The environment channel, equal in force to the flag (the `ASK_READONLY`
|
|
35
|
+
#: precedent): a CI job declares once, in the environment, for every command.
|
|
36
|
+
ENV_CONFIG = "ASK_CONFIG"
|
|
37
|
+
|
|
38
|
+
_OVERRIDE: "Optional[Path]" = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_config_path(path: "Optional[Path | str]") -> None:
|
|
42
|
+
"""Point every loader at *path* for the rest of this process. Never raises."""
|
|
43
|
+
global _OVERRIDE
|
|
44
|
+
_OVERRIDE = Path(str(path)).expanduser() if path else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def override() -> "Optional[Path]":
|
|
48
|
+
"""The declared external path, from the flag or the environment, or None."""
|
|
49
|
+
if _OVERRIDE is not None:
|
|
50
|
+
return _OVERRIDE
|
|
51
|
+
raw = os.environ.get(ENV_CONFIG, "").strip()
|
|
52
|
+
return Path(raw).expanduser() if raw else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def config_path(root: "Optional[Path]") -> "Optional[Path]":
|
|
56
|
+
"""Where this run's declaration is, whether or not it exists.
|
|
57
|
+
|
|
58
|
+
Returns None only when there is no candidate at all — no override and no
|
|
59
|
+
root — so a caller can distinguish *"nothing was declared"* from *"the file
|
|
60
|
+
it would have been in is absent"*, which is the distinction `ask config`
|
|
61
|
+
prints and `data-exposure` refuses on.
|
|
62
|
+
"""
|
|
63
|
+
external = override()
|
|
64
|
+
if external is not None:
|
|
65
|
+
return external
|
|
66
|
+
if root is None:
|
|
67
|
+
return None
|
|
68
|
+
return Path(root) / CONFIG_FILENAME
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_external() -> bool:
|
|
72
|
+
"""True when the declaration in force comes from outside the analysed tree."""
|
|
73
|
+
return override() is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def source_note() -> str:
|
|
77
|
+
"""One clause naming where the declaration came from, for a payload or a line."""
|
|
78
|
+
external = override()
|
|
79
|
+
if external is None:
|
|
80
|
+
return f"<repo>/{CONFIG_FILENAME}"
|
|
81
|
+
return f"{external} (declared with --config / {ENV_CONFIG})"
|
sourcecode/mcp/orchestrator.py
CHANGED
|
@@ -794,7 +794,9 @@ def run_migrate_flow_impl(repo_path: str, min_severity: str = "low") -> dict[str
|
|
|
794
794
|
headline[key] = report[key]
|
|
795
795
|
_summary = report.get("summary", {})
|
|
796
796
|
if isinstance(_summary, dict):
|
|
797
|
-
|
|
797
|
+
from sourcecode.partial_contract import read_count as _read_count
|
|
798
|
+
|
|
799
|
+
headline["total_findings"] = _read_count(_summary, "total_findings")
|
|
798
800
|
headline["affected_files"] = _summary.get("affected_files")
|
|
799
801
|
headline["by_severity"] = _summary.get("by_severity")
|
|
800
802
|
headline["by_target"] = _summary.get("by_target") or _summary.get("by_rule")
|
sourcecode/non_coverage.py
CHANGED
|
@@ -349,6 +349,92 @@ def block(surface: str) -> dict:
|
|
|
349
349
|
return published
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
STACK_FIT_SCHEMA = "stack-fit-v1"
|
|
353
|
+
|
|
354
|
+
#: The axes of this product that model **Spring** semantics, and what each one
|
|
355
|
+
#: reports on a repository where no Spring is detected (CL-19). Every entry is a
|
|
356
|
+
#: fact the payload already contains; what did not exist was a place that adds
|
|
357
|
+
#: them up.
|
|
358
|
+
_SPRING_SHAPED_AXES: "tuple[tuple[str, str], ...]" = (
|
|
359
|
+
(
|
|
360
|
+
"endpoint access policy",
|
|
361
|
+
"every handler is reported `none_detected`, because the policy this axis "
|
|
362
|
+
"reads is a declared annotation and this repository does not declare one",
|
|
363
|
+
),
|
|
364
|
+
(
|
|
365
|
+
"effective access (posture)",
|
|
366
|
+
"every request is `access_not_decided`: the chain this axis resolves is a "
|
|
367
|
+
"Spring Security configuration",
|
|
368
|
+
),
|
|
369
|
+
(
|
|
370
|
+
"annotation-based security findings (SEC-001)",
|
|
371
|
+
"not emitted at all — the rule states that it only applies to an "
|
|
372
|
+
"`annotation_based` security model",
|
|
373
|
+
),
|
|
374
|
+
(
|
|
375
|
+
"declarative transaction boundaries (TX-001..006)",
|
|
376
|
+
"no boundary is found, because the boundary this axis reads is declared "
|
|
377
|
+
"with an annotation",
|
|
378
|
+
),
|
|
379
|
+
(
|
|
380
|
+
"Spring Boot 2 -> 3 readiness",
|
|
381
|
+
"reported `applicable: false` with its reason",
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
#: What keeps working, and is what such a repository is actually measured on.
|
|
386
|
+
_STACK_INDEPENDENT_AXES: "tuple[str, ...]" = (
|
|
387
|
+
"the call graph and blast radius (`impact-chain`, `impact`, `pr-impact`)",
|
|
388
|
+
"the HTTP surface census (`endpoints`) and its request-body validation gaps",
|
|
389
|
+
"coupling, hotspots and dead-code candidates (`modernize`)",
|
|
390
|
+
"the JDK and Jakarta inventories (`migrate-check`)",
|
|
391
|
+
"contract derivation from the measured surface (`verify --init`)",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def stack_fit(spring_detected: bool) -> dict:
|
|
396
|
+
"""Whether this product's Spring-shaped axes model the repository. (CL-19)
|
|
397
|
+
|
|
398
|
+
Every axis publishes its own limits, its own units and its own confidence,
|
|
399
|
+
and the run states `spring_detected` inside each payload — and **no surface
|
|
400
|
+
adds them up**. On a repository where that flag is `false`, the field
|
|
401
|
+
received six axes returning the shape of an answer (614 endpoints
|
|
402
|
+
`none_detected`, 3 152/3 152 `access_not_decided`, `security_model: unknown`,
|
|
403
|
+
TX at zero) and no sentence anywhere saying *this axis does not model your
|
|
404
|
+
stack*. The auditor had to derive it — *"tuve que deducirlo de
|
|
405
|
+
`spring_detected: false` enterrado en el JSON"* — and the verdict that
|
|
406
|
+
followed is the cost: *"una herramienta de inventario estructural muy buena
|
|
407
|
+
vendida como auditor de seguridad"*.
|
|
408
|
+
|
|
409
|
+
Returns `{}` when Spring **is** detected: a repository this product models
|
|
410
|
+
gains no field, and its payload stays byte-identical.
|
|
411
|
+
"""
|
|
412
|
+
if spring_detected:
|
|
413
|
+
return {}
|
|
414
|
+
return {
|
|
415
|
+
"schema_version": STACK_FIT_SCHEMA,
|
|
416
|
+
"spring_detected": False,
|
|
417
|
+
"statement": (
|
|
418
|
+
"No Spring was detected in this scope. The axes listed under "
|
|
419
|
+
"`axes_not_measuring` model Spring semantics, so on this repository "
|
|
420
|
+
"they report an absence of MODEL, never an absence of control: read "
|
|
421
|
+
"every one of their answers as `unknown`, not as `none`. The axes "
|
|
422
|
+
"under `axes_measuring` are structural and answer here exactly as "
|
|
423
|
+
"they do anywhere else."
|
|
424
|
+
),
|
|
425
|
+
"axes_not_measuring": [
|
|
426
|
+
{"axis": axis, "what_it_reports_here": effect}
|
|
427
|
+
for axis, effect in _SPRING_SHAPED_AXES
|
|
428
|
+
],
|
|
429
|
+
"axes_measuring": list(_STACK_INDEPENDENT_AXES),
|
|
430
|
+
"how_to_read": (
|
|
431
|
+
"This block is a statement about the FIT between this product and "
|
|
432
|
+
"this repository. It is not a finding, and it does not lower the "
|
|
433
|
+
"confidence of anything under `axes_measuring`."
|
|
434
|
+
),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
352
438
|
def reference(surface: str, *, published_at: str) -> dict:
|
|
353
439
|
"""A pointer to a block published elsewhere in the same document (C2-28).
|
|
354
440
|
|