sourcecode 4.2.0__py3-none-any.whl → 4.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/architectural_baseline.py +19 -0
- sourcecode/baseline_autocapture.py +375 -0
- sourcecode/cache_model.py +19 -0
- sourcecode/cli.py +552 -23
- sourcecode/client_calls.py +305 -0
- sourcecode/context_graph.py +11 -0
- sourcecode/data_exposure.py +353 -0
- sourcecode/data_labels.py +200 -0
- sourcecode/dynamic_argument_surface.py +12 -5
- sourcecode/format_contract.py +1 -0
- sourcecode/non_coverage.py +45 -0
- sourcecode/openrewrite_recipe.py +278 -0
- sourcecode/posture.py +32 -0
- sourcecode/repository_ir.py +24 -3
- sourcecode/risk.py +453 -92
- sourcecode/sarif.py +618 -0
- sourcecode/validation_inference.py +33 -4
- sourcecode/verify_repo.py +17 -2
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/METADATA +27 -8
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/RECORD +24 -18
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/cli.py
CHANGED
|
@@ -183,7 +183,7 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
|
|
|
183
183
|
"cache", "auth", "mcp", "telemetry",
|
|
184
184
|
)),
|
|
185
185
|
("experimental", "shape may change in a minor — do not gate CI on it", (
|
|
186
|
-
"risk", "posture", "archetype",
|
|
186
|
+
"risk", "enrich", "data-exposure", "migrate-recipe", "posture", "archetype",
|
|
187
187
|
)),
|
|
188
188
|
# `retrieve` publishes 15+ intents whose answers the other commands already
|
|
189
189
|
# give better: measured on the battery, `security-surface` merely re-states
|
|
@@ -814,6 +814,20 @@ def _admit_path(
|
|
|
814
814
|
raise typer.Exit(code=exit_code)
|
|
815
815
|
|
|
816
816
|
|
|
817
|
+
def _profile_set(raw: "Optional[str]") -> "Optional[set[str]]":
|
|
818
|
+
"""`--profile prod,metrics` → `{"prod", "metrics"}`; nothing given → None.
|
|
819
|
+
|
|
820
|
+
None and the empty set are different answers: no profile set was named, versus
|
|
821
|
+
a named set that is empty. The first leaves the access axis on the repository's
|
|
822
|
+
own security surface; the second would resolve the bean graph with nothing
|
|
823
|
+
active. One parser, so `posture`, `risk` and `enrich` cannot disagree about
|
|
824
|
+
what a profile string means.
|
|
825
|
+
"""
|
|
826
|
+
if raw is None:
|
|
827
|
+
return None
|
|
828
|
+
return {part.strip() for part in raw.split(",") if part.strip()}
|
|
829
|
+
|
|
830
|
+
|
|
817
831
|
def _exit_on_unresolved_target(resolution: "Optional[str]") -> None:
|
|
818
832
|
"""Close the run on a target the analysis never resolved — one exit code.
|
|
819
833
|
|
|
@@ -4871,6 +4885,18 @@ def endpoints_cmd(
|
|
|
4871
4885
|
False, "--by-controller",
|
|
4872
4886
|
help="Group endpoints by controller class (structured API surface for C4/Container synthesis).",
|
|
4873
4887
|
),
|
|
4888
|
+
client_usage: bool = typer.Option(
|
|
4889
|
+
False, "--client-usage",
|
|
4890
|
+
help="Join the routes against the HTTP calls written in the TypeScript/"
|
|
4891
|
+
"JavaScript client checked into this repository: which routes anybody "
|
|
4892
|
+
"here calls, which nothing here calls, and which client calls hit no "
|
|
4893
|
+
"route at all.",
|
|
4894
|
+
),
|
|
4895
|
+
client_dir: Optional[Path] = typer.Option(
|
|
4896
|
+
None, "--client-dir",
|
|
4897
|
+
help="Where the client lives, when it is not the whole repository "
|
|
4898
|
+
"(implies --client-usage).",
|
|
4899
|
+
),
|
|
4874
4900
|
) -> None:
|
|
4875
4901
|
"""Extract REST API endpoint surface from Java source files.
|
|
4876
4902
|
|
|
@@ -4974,6 +5000,18 @@ def endpoints_cmd(
|
|
|
4974
5000
|
# rendering: counting the rendered list here would be the defect this block
|
|
4975
5001
|
# exists to remove, one line into its own implementation (R5-R8).
|
|
4976
5002
|
data["exposure"] = _endpoint_exposure_summary(_selected)
|
|
5003
|
+
|
|
5004
|
+
# M10.5 item 4. On a large surface the cheapest reduction of exposure is
|
|
5005
|
+
# deleting API nobody calls, and in a monorepo the caller is checked in next
|
|
5006
|
+
# to the callee. Joined over the population the filters chose, for the same
|
|
5007
|
+
# reason the census is.
|
|
5008
|
+
if client_usage or client_dir is not None:
|
|
5009
|
+
from sourcecode.client_calls import build_client_usage
|
|
5010
|
+
|
|
5011
|
+
data["client_usage"] = build_client_usage(
|
|
5012
|
+
target, _selected,
|
|
5013
|
+
client_dirs=[client_dir] if client_dir is not None else None,
|
|
5014
|
+
)
|
|
4977
5015
|
if compact:
|
|
4978
5016
|
from sourcecode.degradation import cap_effect
|
|
4979
5017
|
|
|
@@ -6100,6 +6138,30 @@ def _require_dir(target_dir: Path, cmd: str) -> None:
|
|
|
6100
6138
|
_admit_path(target_dir, usage=f"ask baseline {cmd} <path> ...")
|
|
6101
6139
|
|
|
6102
6140
|
|
|
6141
|
+
def _resolve_history_dir(target_dir: Path) -> Path:
|
|
6142
|
+
"""A repository path and its history directory are both valid ways to say it.
|
|
6143
|
+
|
|
6144
|
+
Capture writes to `<repo>/.ask/baselines`, so once a history exists the reader
|
|
6145
|
+
commands have to accept the thing the writer was pointed at: `ask trend .` is what
|
|
6146
|
+
someone types after `ask baseline capture .`, and making them name the storage
|
|
6147
|
+
directory is the same avoidable friction that kept the series empty.
|
|
6148
|
+
Directories holding baselines are used as they are, so an explicit `--dir`
|
|
6149
|
+
location keeps working unchanged.
|
|
6150
|
+
"""
|
|
6151
|
+
from sourcecode.architectural_baseline import latest_baseline_path
|
|
6152
|
+
|
|
6153
|
+
if latest_baseline_path(target_dir) is not None:
|
|
6154
|
+
return target_dir
|
|
6155
|
+
nested = target_dir / _bac_history_dirname()
|
|
6156
|
+
return nested if nested.is_dir() else target_dir
|
|
6157
|
+
|
|
6158
|
+
|
|
6159
|
+
def _bac_history_dirname() -> Path:
|
|
6160
|
+
from sourcecode.baseline_autocapture import HISTORY_DIRNAME
|
|
6161
|
+
|
|
6162
|
+
return HISTORY_DIRNAME
|
|
6163
|
+
|
|
6164
|
+
|
|
6103
6165
|
@baseline_app.command("capture")
|
|
6104
6166
|
def baseline_capture_cmd(
|
|
6105
6167
|
path: Path = typer.Argument(
|
|
@@ -6119,6 +6181,12 @@ def baseline_capture_cmd(
|
|
|
6119
6181
|
"with a vendor — so an audit that must leave the tree untouched "
|
|
6120
6182
|
"writes elsewhere with this flag.",
|
|
6121
6183
|
),
|
|
6184
|
+
auto: bool = typer.Option(
|
|
6185
|
+
False, "--auto",
|
|
6186
|
+
help="Pipeline mode: capture this commit once, skip when there is nothing new "
|
|
6187
|
+
"to record (dirty tree, commit already captured), and never fail the run. "
|
|
6188
|
+
"Safe to leave in a CI job that runs on every push.",
|
|
6189
|
+
),
|
|
6122
6190
|
) -> None:
|
|
6123
6191
|
"""Capture a versioned architectural baseline of the current repo state.
|
|
6124
6192
|
|
|
@@ -6129,10 +6197,18 @@ def baseline_capture_cmd(
|
|
|
6129
6197
|
replays it; `baseline trend` series several. Metric content is a pure function of
|
|
6130
6198
|
the repo state; no verdict, no ROI.
|
|
6131
6199
|
|
|
6200
|
+
\b
|
|
6201
|
+
`--auto` is the form for a pipeline: the series is the asset nobody can
|
|
6202
|
+
reconstruct afterwards, and a history that depends on someone remembering to
|
|
6203
|
+
run this command will not exist. It captures one baseline per commit, refuses
|
|
6204
|
+
to attribute a dirty tree to the commit it would be filed under, and exits 0
|
|
6205
|
+
whatever happens — a build must not go red because a snapshot could not be
|
|
6206
|
+
written.
|
|
6132
6207
|
\b
|
|
6133
6208
|
Examples:
|
|
6134
6209
|
ask baseline capture ./repo --ref v2.5.20
|
|
6135
6210
|
ask baseline capture ./repo --dir ./repo/.ask/baselines
|
|
6211
|
+
ask baseline capture . --auto # in CI, on every push
|
|
6136
6212
|
"""
|
|
6137
6213
|
target_dir = path.resolve()
|
|
6138
6214
|
_require_dir(target_dir, "capture")
|
|
@@ -6140,6 +6216,30 @@ def baseline_capture_cmd(
|
|
|
6140
6216
|
from sourcecode.context_graph import ContextGraph
|
|
6141
6217
|
from sourcecode.architectural_baseline import build_baseline, git_commit, write_baseline
|
|
6142
6218
|
|
|
6219
|
+
if auto:
|
|
6220
|
+
from sourcecode import baseline_autocapture as _auto
|
|
6221
|
+
|
|
6222
|
+
# Decide BEFORE building the IR: a skip is the common case in a pipeline
|
|
6223
|
+
# that runs on every push, and it must cost nothing.
|
|
6224
|
+
plan = _auto.plan(target_dir, out_dir=out_dir, explicit=True)
|
|
6225
|
+
if plan.action != _auto.CAPTURED:
|
|
6226
|
+
typer.echo(f"Baseline not captured ({plan.reason}): {plan.statement}")
|
|
6227
|
+
return
|
|
6228
|
+
_prog = Progress()
|
|
6229
|
+
_prog.start("building IR")
|
|
6230
|
+
_cir = ContextGraph.build_from_root(target_dir).cir
|
|
6231
|
+
_prog.update("capturing baseline")
|
|
6232
|
+
done = _auto.run(target_dir, _cir, out_dir=out_dir, ref=ref, decision=plan)
|
|
6233
|
+
_prog.finish()
|
|
6234
|
+
if done.action == _auto.CAPTURED:
|
|
6235
|
+
typer.echo(
|
|
6236
|
+
f"Baseline written to {done.baseline_file} "
|
|
6237
|
+
f"(commit={done.commit}; history holds {done.history_size})"
|
|
6238
|
+
)
|
|
6239
|
+
else:
|
|
6240
|
+
typer.echo(f"Baseline not captured ({done.reason}): {done.statement}")
|
|
6241
|
+
return
|
|
6242
|
+
|
|
6143
6243
|
_prog = Progress()
|
|
6144
6244
|
_prog.start("building IR")
|
|
6145
6245
|
_cir = ContextGraph.build_from_root(target_dir).cir
|
|
@@ -6164,9 +6264,11 @@ def baseline_diff_cmd(
|
|
|
6164
6264
|
Path("."),
|
|
6165
6265
|
help="Repository root to diff against the stored baseline (default: cwd).",
|
|
6166
6266
|
),
|
|
6167
|
-
baseline_file: Path = typer.Option(
|
|
6168
|
-
|
|
6169
|
-
help="Path to a stored architectural-baseline-v1 JSON artifact."
|
|
6267
|
+
baseline_file: Optional[Path] = typer.Option(
|
|
6268
|
+
None, "--baseline", "-b",
|
|
6269
|
+
help="Path to a stored architectural-baseline-v1 JSON artifact. Omit it to "
|
|
6270
|
+
"diff against the most recent baseline in the repository's own history "
|
|
6271
|
+
"(<path>/.ask/baselines) — the question a captured series exists for.",
|
|
6170
6272
|
),
|
|
6171
6273
|
output_path: Optional[Path] = typer.Option(
|
|
6172
6274
|
None, "--output", "-o",
|
|
@@ -6202,12 +6304,32 @@ def baseline_diff_cmd(
|
|
|
6202
6304
|
or pass --allow-incomparable to override.
|
|
6203
6305
|
|
|
6204
6306
|
\b
|
|
6205
|
-
|
|
6307
|
+
Examples:
|
|
6308
|
+
ask baseline diff ./repo
|
|
6206
6309
|
ask baseline diff ./repo --baseline ./repo/.ask/baselines/abc1234.json
|
|
6207
6310
|
"""
|
|
6208
6311
|
_enforce_format("baseline-diff", format)
|
|
6209
6312
|
target_dir = path.resolve()
|
|
6210
6313
|
_require_dir(target_dir, "diff")
|
|
6314
|
+
|
|
6315
|
+
if baseline_file is None:
|
|
6316
|
+
from sourcecode.architectural_baseline import latest_baseline_path
|
|
6317
|
+
|
|
6318
|
+
history = _resolve_history_dir(target_dir)
|
|
6319
|
+
latest = latest_baseline_path(history)
|
|
6320
|
+
if latest is None:
|
|
6321
|
+
_emit_error_json(
|
|
6322
|
+
INVALID_INPUT_CODE,
|
|
6323
|
+
f"No captured baseline found for '{target_dir}'.",
|
|
6324
|
+
path=str(history),
|
|
6325
|
+
hint="Capture one first: ask baseline capture <path> "
|
|
6326
|
+
"(or --auto in the pipeline that already runs).",
|
|
6327
|
+
expected="A stored architectural-baseline-v1 JSON file, or a "
|
|
6328
|
+
"repository whose .ask/baselines holds one.",
|
|
6329
|
+
)
|
|
6330
|
+
raise typer.Exit(code=1)
|
|
6331
|
+
baseline_file = latest
|
|
6332
|
+
|
|
6211
6333
|
if not baseline_file.exists():
|
|
6212
6334
|
_emit_error_json(
|
|
6213
6335
|
INVALID_INPUT_CODE,
|
|
@@ -6262,8 +6384,9 @@ def baseline_diff_cmd(
|
|
|
6262
6384
|
@baseline_app.command("trend")
|
|
6263
6385
|
def baseline_trend_cmd(
|
|
6264
6386
|
directory: Path = typer.Argument(
|
|
6265
|
-
|
|
6266
|
-
help="
|
|
6387
|
+
Path("."),
|
|
6388
|
+
help="A repository whose history to read, or a directory of stored "
|
|
6389
|
+
"architectural-baseline-v1 artifacts (default: current directory).",
|
|
6267
6390
|
),
|
|
6268
6391
|
output_path: Optional[Path] = typer.Option(
|
|
6269
6392
|
None, "--output", "-o",
|
|
@@ -6286,11 +6409,12 @@ def baseline_trend_cmd(
|
|
|
6286
6409
|
fan-in tracks. Reports movement only — no improving/degrading label.
|
|
6287
6410
|
|
|
6288
6411
|
\b
|
|
6289
|
-
|
|
6412
|
+
Examples:
|
|
6413
|
+
ask baseline trend ./repo
|
|
6290
6414
|
ask baseline trend ./repo/.ask/baselines
|
|
6291
6415
|
"""
|
|
6292
6416
|
_enforce_format("baseline-trend", format)
|
|
6293
|
-
trend_dir = directory.resolve()
|
|
6417
|
+
trend_dir = _resolve_history_dir(directory.resolve())
|
|
6294
6418
|
_require_dir(trend_dir, "trend")
|
|
6295
6419
|
|
|
6296
6420
|
from sourcecode.architectural_baseline import build_trend, load_baselines_dir
|
|
@@ -6301,8 +6425,10 @@ def baseline_trend_cmd(
|
|
|
6301
6425
|
INVALID_INPUT_CODE,
|
|
6302
6426
|
f"No architectural baselines found in '{trend_dir}'.",
|
|
6303
6427
|
path=str(trend_dir),
|
|
6304
|
-
hint="Capture some first: ask baseline capture <path>
|
|
6305
|
-
|
|
6428
|
+
hint="Capture some first: ask baseline capture <path> "
|
|
6429
|
+
"(or --auto in the pipeline that already runs).",
|
|
6430
|
+
expected="A repository with a captured history, or a directory "
|
|
6431
|
+
"containing architectural-baseline-v1 JSON files.",
|
|
6306
6432
|
)
|
|
6307
6433
|
raise typer.Exit(code=1)
|
|
6308
6434
|
|
|
@@ -6321,8 +6447,9 @@ def baseline_trend_cmd(
|
|
|
6321
6447
|
@app.command("trend")
|
|
6322
6448
|
def trend_cmd(
|
|
6323
6449
|
directory: Path = typer.Argument(
|
|
6324
|
-
|
|
6325
|
-
help="
|
|
6450
|
+
Path("."),
|
|
6451
|
+
help="A repository whose history to read, or a directory of stored "
|
|
6452
|
+
"architectural-baseline-v1 artifacts (default: current directory).",
|
|
6326
6453
|
),
|
|
6327
6454
|
output_path: Optional[Path] = typer.Option(
|
|
6328
6455
|
None, "--output", "-o",
|
|
@@ -6351,7 +6478,8 @@ def trend_cmd(
|
|
|
6351
6478
|
fan-in tracks. Reports movement only — no improving/degrading label.
|
|
6352
6479
|
|
|
6353
6480
|
\b
|
|
6354
|
-
|
|
6481
|
+
Examples:
|
|
6482
|
+
ask trend ./repo
|
|
6355
6483
|
ask trend ./repo/.ask/baselines
|
|
6356
6484
|
"""
|
|
6357
6485
|
baseline_trend_cmd(
|
|
@@ -6897,6 +7025,15 @@ def verify_cmd(
|
|
|
6897
7025
|
repository could not be analysed — never a silent pass). Same codes as
|
|
6898
7026
|
`verify-edit`.
|
|
6899
7027
|
|
|
7028
|
+
\b
|
|
7029
|
+
Where the repository already keeps an architectural history (`.ask/baselines`),
|
|
7030
|
+
this run adds the current commit to it and reports what it did under
|
|
7031
|
+
`architectural_history` — the gate already built the IR a baseline is extracted
|
|
7032
|
+
from, and a series that depends on somebody remembering will not exist. It never
|
|
7033
|
+
starts a history on its own, never captures from a dirty tree, and never changes
|
|
7034
|
+
this command's verdict. `--capture-baseline` above is a different artifact: the
|
|
7035
|
+
accepted *contract debt*, not the architectural series.
|
|
7036
|
+
|
|
6900
7037
|
\b
|
|
6901
7038
|
Examples:
|
|
6902
7039
|
ask verify --init # derive contracts this repo satisfies
|
|
@@ -6985,8 +7122,24 @@ def verify_cmd(
|
|
|
6985
7122
|
)
|
|
6986
7123
|
return
|
|
6987
7124
|
|
|
7125
|
+
# The architectural history rides along with the gate (M10.5). This is the run
|
|
7126
|
+
# that already happens on every push and already builds the IR a baseline is
|
|
7127
|
+
# extracted from, so the series costs a file write instead of an analysis — and
|
|
7128
|
+
# a series nobody has to remember to capture is the one that exists. It writes
|
|
7129
|
+
# only where a history is already kept (or where the operator opted in), never
|
|
7130
|
+
# from a dirty tree, never twice for a commit, and never changes this command's
|
|
7131
|
+
# verdict or exit code.
|
|
7132
|
+
from sourcecode import baseline_autocapture as _bac
|
|
7133
|
+
|
|
7134
|
+
_history: dict = {}
|
|
7135
|
+
|
|
7136
|
+
def _capture(cir) -> None:
|
|
7137
|
+
_history["decision"] = _bac.run(path, cir)
|
|
7138
|
+
|
|
6988
7139
|
try:
|
|
6989
|
-
report = _vr.verify_repo(
|
|
7140
|
+
report = _vr.verify_repo(
|
|
7141
|
+
path, fail_on=fail_on, baseline_path=baseline, on_cir=_capture
|
|
7142
|
+
)
|
|
6990
7143
|
except _vr.VerifyError as exc:
|
|
6991
7144
|
_emit_error_json(
|
|
6992
7145
|
INVALID_INPUT_CODE,
|
|
@@ -6995,8 +7148,17 @@ def verify_cmd(
|
|
|
6995
7148
|
)
|
|
6996
7149
|
raise typer.Exit(code=2 if ci else 0)
|
|
6997
7150
|
|
|
7151
|
+
payload = report.to_dict()
|
|
7152
|
+
# Published on every run, including the ones that captured nothing: a history
|
|
7153
|
+
# that silently stopped growing is invisible until the day somebody needs the
|
|
7154
|
+
# series (I-8 — a run declares the effect of what it did not do).
|
|
7155
|
+
payload["architectural_history"] = (
|
|
7156
|
+
_history.get("decision")
|
|
7157
|
+
or _bac.CaptureDecision(_bac.SKIPPED, _bac.NO_ANALYSIS)
|
|
7158
|
+
).to_dict()
|
|
7159
|
+
|
|
6998
7160
|
_emit_command_output(
|
|
6999
|
-
_serialize_dict(
|
|
7161
|
+
_serialize_dict(payload, format),
|
|
7000
7162
|
output_path,
|
|
7001
7163
|
False,
|
|
7002
7164
|
success_msg=f"verify report written to {output_path}",
|
|
@@ -7019,6 +7181,12 @@ def risk_cmd(
|
|
|
7019
7181
|
"--min-band",
|
|
7020
7182
|
help="Floor on the composed band: critical | high | medium | low.",
|
|
7021
7183
|
),
|
|
7184
|
+
profile: Optional[str] = typer.Option(
|
|
7185
|
+
None,
|
|
7186
|
+
"--profile",
|
|
7187
|
+
help="Active profile set, comma-separated (e.g. prod or prod,metrics). "
|
|
7188
|
+
"Binds the access axis to what THAT deployment leaves reachable.",
|
|
7189
|
+
),
|
|
7022
7190
|
output_path: Optional[Path] = typer.Option(
|
|
7023
7191
|
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
7024
7192
|
),
|
|
@@ -7035,13 +7203,16 @@ def risk_cmd(
|
|
|
7035
7203
|
commands saying "medium".
|
|
7036
7204
|
|
|
7037
7205
|
\b
|
|
7038
|
-
|
|
7206
|
+
{{RISK_MODEL}}
|
|
7039
7207
|
|
|
7040
7208
|
\b
|
|
7041
7209
|
No new analysis: every factor is read from the command that already publishes
|
|
7042
|
-
it, and every row publishes
|
|
7043
|
-
|
|
7044
|
-
|
|
7210
|
+
it, and every row publishes every factor with the authority each came from, so
|
|
7211
|
+
a reader can disagree with one and keep the rest. An axis that could not be
|
|
7212
|
+
measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`. The
|
|
7213
|
+
two input-path axes — a query built by concatenation, a route accepting an
|
|
7214
|
+
unconstrained body — are adjacencies: no dataflow is followed (NC-001, in the
|
|
7215
|
+
payload's `non_coverage`).
|
|
7045
7216
|
|
|
7046
7217
|
\b
|
|
7047
7218
|
One row is one **defect** — the same identity `spring-audit` groups by, so the
|
|
@@ -7049,10 +7220,18 @@ def risk_cmd(
|
|
|
7049
7220
|
as `witness_count` and `witness_sites`, and `total_findings` publishes the
|
|
7050
7221
|
observation census under its own name.
|
|
7051
7222
|
|
|
7223
|
+
\b
|
|
7224
|
+
`--profile prod` binds the access axis to what THAT deployment leaves
|
|
7225
|
+
reachable: `posture` resolves the conditional bean graph and the request
|
|
7226
|
+
chain, and its decision resolves an endpoint the repository-wide surface left
|
|
7227
|
+
at `coverage_unknown` — in either direction. Without it the axis answers for
|
|
7228
|
+
the repository as configured.
|
|
7229
|
+
|
|
7052
7230
|
\b
|
|
7053
7231
|
Examples:
|
|
7054
7232
|
ask risk .
|
|
7055
7233
|
ask risk . --min-band high
|
|
7234
|
+
ask risk . --profile prod
|
|
7056
7235
|
ask risk . --limit 10 -o risk.json
|
|
7057
7236
|
"""
|
|
7058
7237
|
from sourcecode.risk import build_risk
|
|
@@ -7067,7 +7246,7 @@ def risk_cmd(
|
|
|
7067
7246
|
)
|
|
7068
7247
|
raise typer.Exit(code=1)
|
|
7069
7248
|
|
|
7070
|
-
data = build_risk(path, limit=limit, min_band=min_band)
|
|
7249
|
+
data = build_risk(path, limit=limit, min_band=min_band, profiles=_profile_set(profile))
|
|
7071
7250
|
_emit_command_output(
|
|
7072
7251
|
_serialize_dict(data, format),
|
|
7073
7252
|
output_path,
|
|
@@ -7079,6 +7258,356 @@ def risk_cmd(
|
|
|
7079
7258
|
)
|
|
7080
7259
|
|
|
7081
7260
|
|
|
7261
|
+
@app.command("enrich")
|
|
7262
|
+
def enrich_cmd(
|
|
7263
|
+
path: Path = typer.Argument(
|
|
7264
|
+
Path("."),
|
|
7265
|
+
help="Repository path (default: current directory).",
|
|
7266
|
+
),
|
|
7267
|
+
sarif: Optional[Path] = typer.Option(
|
|
7268
|
+
None,
|
|
7269
|
+
"--sarif",
|
|
7270
|
+
help="SARIF 2.1.0 log from another scanner (Semgrep, CodeQL, Trivy…).",
|
|
7271
|
+
),
|
|
7272
|
+
limit: int = typer.Option(
|
|
7273
|
+
50, "--limit", help="How many enriched findings to publish (highest first)."
|
|
7274
|
+
),
|
|
7275
|
+
min_band: str = typer.Option(
|
|
7276
|
+
"low",
|
|
7277
|
+
"--min-band",
|
|
7278
|
+
help="Floor on the composed band: critical | high | medium | low.",
|
|
7279
|
+
),
|
|
7280
|
+
profile: Optional[str] = typer.Option(
|
|
7281
|
+
None,
|
|
7282
|
+
"--profile",
|
|
7283
|
+
help="Active profile set, comma-separated (e.g. prod or prod,metrics). "
|
|
7284
|
+
"Binds the access axis to what THAT deployment leaves reachable.",
|
|
7285
|
+
),
|
|
7286
|
+
output_path: Optional[Path] = typer.Option(
|
|
7287
|
+
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
7288
|
+
),
|
|
7289
|
+
format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
|
|
7290
|
+
) -> None:
|
|
7291
|
+
"""[EXPERIMENTAL] Somebody else's findings, ranked by what this repository does with them.
|
|
7292
|
+
|
|
7293
|
+
\b
|
|
7294
|
+
This product does not detect injection, CVEs or secrets, and says so in every
|
|
7295
|
+
security payload (NC-001, NC-002). A scanner that does has no bean graph, no
|
|
7296
|
+
profile model and no endpoint id it shares with anything, so its output is a
|
|
7297
|
+
list nobody can order. `enrich` reads that list and answers the question the
|
|
7298
|
+
scanner cannot: which of these findings sits in code an unauthenticated
|
|
7299
|
+
request reaches.
|
|
7300
|
+
|
|
7301
|
+
\b
|
|
7302
|
+
{{RISK_MODEL}}
|
|
7303
|
+
|
|
7304
|
+
\b
|
|
7305
|
+
The same composition `ask risk` runs, with one difference: `defect_severity`
|
|
7306
|
+
is the scanner's own verdict, carried through with the scanner named as its
|
|
7307
|
+
authority. Nothing here re-rates, confirms or contradicts a finding.
|
|
7308
|
+
|
|
7309
|
+
\b
|
|
7310
|
+
A result located on a build manifest is a statement about a **library**, not
|
|
7311
|
+
about a line of this repository's code, so it is joined differently: the
|
|
7312
|
+
coordinate is read back out of the manifest the scanner named, and the reach is
|
|
7313
|
+
the worst of the types that import it. That is the CVE question crossed with
|
|
7314
|
+
exposure — of forty vulnerable dependencies, which ones does an unauthenticated
|
|
7315
|
+
route actually reach. A library nothing imports is `unresolved`, never
|
|
7316
|
+
"unreachable": reflection and service loaders leave no import edge.
|
|
7317
|
+
|
|
7318
|
+
\b
|
|
7319
|
+
A result this build cannot place — a file outside the population it read, a
|
|
7320
|
+
line with no declaration above it — is published under `unresolved` with the
|
|
7321
|
+
reason. It is never dropped, because a count of enriched findings that quietly
|
|
7322
|
+
omitted them is exactly the confident falsehood this product is built against.
|
|
7323
|
+
|
|
7324
|
+
\b
|
|
7325
|
+
Examples:
|
|
7326
|
+
ask enrich . --sarif semgrep.sarif
|
|
7327
|
+
ask enrich . --sarif codeql.sarif --min-band high
|
|
7328
|
+
ask enrich . --sarif trivy.sarif --profile prod
|
|
7329
|
+
ask enrich /path/to/repo --sarif trivy.sarif -o enriched.json
|
|
7330
|
+
"""
|
|
7331
|
+
from sourcecode.sarif import enrich_sarif
|
|
7332
|
+
|
|
7333
|
+
path = _admit_path(path)
|
|
7334
|
+
if sarif is None:
|
|
7335
|
+
_emit_error_json(
|
|
7336
|
+
INVALID_INPUT_CODE,
|
|
7337
|
+
"--sarif is required: this command enriches another tool's findings, "
|
|
7338
|
+
"it does not detect any of its own.",
|
|
7339
|
+
hint="Example: ask enrich . --sarif semgrep.sarif",
|
|
7340
|
+
expected="a path to a SARIF 2.1.0 log",
|
|
7341
|
+
)
|
|
7342
|
+
raise typer.Exit(code=1)
|
|
7343
|
+
if min_band not in ("critical", "high", "medium", "low"):
|
|
7344
|
+
_emit_error_json(
|
|
7345
|
+
INVALID_INPUT_CODE,
|
|
7346
|
+
f"--min-band expects critical | high | medium | low (got {min_band!r}).",
|
|
7347
|
+
hint="Example: --min-band high",
|
|
7348
|
+
expected="critical|high|medium|low",
|
|
7349
|
+
)
|
|
7350
|
+
raise typer.Exit(code=1)
|
|
7351
|
+
if not Path(sarif).is_file():
|
|
7352
|
+
_emit_error_json(
|
|
7353
|
+
INVALID_INPUT_CODE,
|
|
7354
|
+
f"SARIF log not found: {sarif}",
|
|
7355
|
+
hint="Point --sarif at the file the scanner wrote.",
|
|
7356
|
+
expected="an existing SARIF 2.1.0 log",
|
|
7357
|
+
)
|
|
7358
|
+
raise typer.Exit(code=1)
|
|
7359
|
+
|
|
7360
|
+
try:
|
|
7361
|
+
data = enrich_sarif(
|
|
7362
|
+
Path(sarif), path, limit=limit, min_band=min_band,
|
|
7363
|
+
profiles=_profile_set(profile),
|
|
7364
|
+
)
|
|
7365
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
7366
|
+
_emit_error_json(
|
|
7367
|
+
INVALID_INPUT_CODE,
|
|
7368
|
+
f"{sarif} is not a readable SARIF 2.1.0 log: {exc}",
|
|
7369
|
+
hint="Check that the file is the scanner's SARIF output, not its native format.",
|
|
7370
|
+
expected="a SARIF 2.1.0 JSON document with a `runs` array",
|
|
7371
|
+
)
|
|
7372
|
+
raise typer.Exit(code=1)
|
|
7373
|
+
|
|
7374
|
+
_emit_command_output(
|
|
7375
|
+
_serialize_dict(data, format),
|
|
7376
|
+
output_path,
|
|
7377
|
+
False,
|
|
7378
|
+
success_msg=(
|
|
7379
|
+
f"enriched findings written to {output_path} ({data['shown']} of "
|
|
7380
|
+
f"{data['defects']} defects composed, {len(data['unresolved'])} unplaced)"
|
|
7381
|
+
),
|
|
7382
|
+
)
|
|
7383
|
+
|
|
7384
|
+
|
|
7385
|
+
@app.command("migrate-recipe")
|
|
7386
|
+
def migrate_recipe_cmd(
|
|
7387
|
+
path: Path = typer.Argument(
|
|
7388
|
+
Path("."),
|
|
7389
|
+
help="Repository path to scan (default: current directory).",
|
|
7390
|
+
),
|
|
7391
|
+
write: bool = typer.Option(
|
|
7392
|
+
False, "--write",
|
|
7393
|
+
help="Write the recipe to <path>/rewrite.yml instead of only publishing it. "
|
|
7394
|
+
"Read-only by default: an audit of somebody else's repository must be "
|
|
7395
|
+
"able to run this.",
|
|
7396
|
+
),
|
|
7397
|
+
force: bool = typer.Option(
|
|
7398
|
+
False, "--force",
|
|
7399
|
+
help="Overwrite an existing rewrite.yml (only with --write).",
|
|
7400
|
+
),
|
|
7401
|
+
recipe_name: str = typer.Option(
|
|
7402
|
+
None, "--name",
|
|
7403
|
+
help="Name of the generated composite recipe (default: ask.MigrationFromMeasuredFindings).",
|
|
7404
|
+
),
|
|
7405
|
+
min_severity: str = typer.Option(
|
|
7406
|
+
"low", "--min-severity",
|
|
7407
|
+
help="Minimum severity to include: critical | high | medium | low.",
|
|
7408
|
+
),
|
|
7409
|
+
include_hygiene: bool = typer.Option(
|
|
7410
|
+
False, "--include-hygiene",
|
|
7411
|
+
help="Add the advisory hygiene recipes (java.util.Date → java.time and the "
|
|
7412
|
+
"rest of the best-practice bucket). Excluded by default: a migration "
|
|
7413
|
+
"diff that also modernises date handling cannot be reviewed as one "
|
|
7414
|
+
"change.",
|
|
7415
|
+
),
|
|
7416
|
+
target_jdk: Optional[int] = typer.Option(
|
|
7417
|
+
None, "--target-jdk",
|
|
7418
|
+
help="JDK the migration targets (passed through to migrate-check).",
|
|
7419
|
+
),
|
|
7420
|
+
output_path: Optional[Path] = typer.Option(
|
|
7421
|
+
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
7422
|
+
),
|
|
7423
|
+
format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
|
|
7424
|
+
) -> None:
|
|
7425
|
+
"""[EXPERIMENTAL] The migration report, in the form the executor takes.
|
|
7426
|
+
|
|
7427
|
+
\b
|
|
7428
|
+
`migrate-check` already names the OpenRewrite recipe each finding carries —
|
|
7429
|
+
296 of 309 on openmrs-core, 144 of 170 on BroadleafCommerce — and a reader
|
|
7430
|
+
still had to copy them out of a JSON payload by hand. This emits the
|
|
7431
|
+
declarative recipe that runs exactly those, plus the invocation for the build
|
|
7432
|
+
this repository actually declares.
|
|
7433
|
+
|
|
7434
|
+
\b
|
|
7435
|
+
It does not rewrite anything, and that is a position rather than a gap
|
|
7436
|
+
(NC-005): OpenRewrite is the executor, this is its input. The recipe list is
|
|
7437
|
+
derived from findings — never from a catalogue of what OpenRewrite can do,
|
|
7438
|
+
because a recipe nobody's finding asked for would rewrite code nobody
|
|
7439
|
+
measured — and every blocking finding with no recipe is published under
|
|
7440
|
+
`manual_remainder`, because "run this and you are done" is the failure mode a
|
|
7441
|
+
585-blocker report cannot afford twice.
|
|
7442
|
+
|
|
7443
|
+
\b
|
|
7444
|
+
Examples:
|
|
7445
|
+
ask migrate-recipe . # publish the recipe, write nothing
|
|
7446
|
+
ask migrate-recipe . --write # write <path>/rewrite.yml
|
|
7447
|
+
ask migrate-recipe . --min-severity high
|
|
7448
|
+
"""
|
|
7449
|
+
from sourcecode.migrate_check import run_migrate_check
|
|
7450
|
+
from sourcecode.openrewrite_recipe import DEFAULT_FILENAME, DEFAULT_RECIPE_NAME, build_plan
|
|
7451
|
+
from sourcecode.repository_ir import find_java_files
|
|
7452
|
+
|
|
7453
|
+
target = _admit_path(path)
|
|
7454
|
+
_enforce_format("migrate-recipe", format)
|
|
7455
|
+
if min_severity not in ("critical", "high", "medium", "low"):
|
|
7456
|
+
_emit_error_json(
|
|
7457
|
+
INVALID_INPUT_CODE,
|
|
7458
|
+
f"Invalid min-severity '{min_severity}'.",
|
|
7459
|
+
hint="min-severity must be one of: critical, high, medium, low.",
|
|
7460
|
+
expected="critical | high | medium | low",
|
|
7461
|
+
)
|
|
7462
|
+
raise typer.Exit(code=1)
|
|
7463
|
+
|
|
7464
|
+
_prog = Progress()
|
|
7465
|
+
_prog.start("scanning for migration findings")
|
|
7466
|
+
try:
|
|
7467
|
+
report = run_migrate_check(
|
|
7468
|
+
find_java_files(target), target,
|
|
7469
|
+
min_severity=min_severity, target_jdk=target_jdk,
|
|
7470
|
+
)
|
|
7471
|
+
finally:
|
|
7472
|
+
_prog.finish()
|
|
7473
|
+
|
|
7474
|
+
plan = build_plan(
|
|
7475
|
+
report, target,
|
|
7476
|
+
recipe_name=recipe_name or DEFAULT_RECIPE_NAME,
|
|
7477
|
+
include_hygiene=include_hygiene,
|
|
7478
|
+
)
|
|
7479
|
+
data = plan.to_dict()
|
|
7480
|
+
|
|
7481
|
+
destination = target / DEFAULT_FILENAME
|
|
7482
|
+
if write:
|
|
7483
|
+
if destination.exists() and not force:
|
|
7484
|
+
_emit_error_json(
|
|
7485
|
+
INVALID_INPUT_CODE,
|
|
7486
|
+
f"{destination} already exists; --write would replace it.",
|
|
7487
|
+
path=str(destination),
|
|
7488
|
+
hint="Review that file, or re-run with --force to overwrite it.",
|
|
7489
|
+
expected="No rewrite.yml at the repository root, or --force.",
|
|
7490
|
+
)
|
|
7491
|
+
raise typer.Exit(code=1)
|
|
7492
|
+
if not plan.recipes:
|
|
7493
|
+
# Writing an empty recipe list produces a file the executor accepts and
|
|
7494
|
+
# that changes nothing — a green run that proves nothing was migrated.
|
|
7495
|
+
_emit_error_json(
|
|
7496
|
+
INVALID_INPUT_CODE,
|
|
7497
|
+
"No finding in this repository names an OpenRewrite recipe, so there "
|
|
7498
|
+
"is nothing to write.",
|
|
7499
|
+
path=str(target),
|
|
7500
|
+
hint="Run `ask migrate-check` to see what was found; the remainder is "
|
|
7501
|
+
"manual work, listed under `manual_remainder`.",
|
|
7502
|
+
expected="At least one finding carrying a recipe.",
|
|
7503
|
+
)
|
|
7504
|
+
raise typer.Exit(code=1)
|
|
7505
|
+
destination.write_text(plan.document, encoding="utf-8")
|
|
7506
|
+
data["written_to"] = str(destination)
|
|
7507
|
+
else:
|
|
7508
|
+
data["written_to"] = None
|
|
7509
|
+
|
|
7510
|
+
_emit_command_output(
|
|
7511
|
+
_serialize_dict(data, format),
|
|
7512
|
+
output_path,
|
|
7513
|
+
False,
|
|
7514
|
+
success_msg=(
|
|
7515
|
+
f"migration recipe written to {output_path} "
|
|
7516
|
+
f"({plan.covered_findings} of {plan.total_findings} findings covered by "
|
|
7517
|
+
f"{len(plan.recipes)} recipe(s))"
|
|
7518
|
+
),
|
|
7519
|
+
)
|
|
7520
|
+
|
|
7521
|
+
|
|
7522
|
+
@app.command("data-exposure")
|
|
7523
|
+
def data_exposure_cmd(
|
|
7524
|
+
path: Path = typer.Argument(
|
|
7525
|
+
Path("."),
|
|
7526
|
+
help="Repository path (default: current directory).",
|
|
7527
|
+
),
|
|
7528
|
+
profile: Optional[str] = typer.Option(
|
|
7529
|
+
None,
|
|
7530
|
+
"--profile",
|
|
7531
|
+
help="Active profile set, comma-separated (e.g. prod). Says what THAT "
|
|
7532
|
+
"deployment leaves reachable on the routes that carry labelled data.",
|
|
7533
|
+
),
|
|
7534
|
+
output_path: Optional[Path] = typer.Option(
|
|
7535
|
+
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
7536
|
+
),
|
|
7537
|
+
format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
|
|
7538
|
+
) -> None:
|
|
7539
|
+
"""[EXPERIMENTAL] Which routes can carry the data you labelled, and who reaches them.
|
|
7540
|
+
|
|
7541
|
+
\b
|
|
7542
|
+
Nothing here decides what is sensitive: that is a judgement about a domain, and
|
|
7543
|
+
a field called `dni` is a national identifier in one repository and a column
|
|
7544
|
+
name in another. Declare it once in `sourcecode.config.json`:
|
|
7545
|
+
|
|
7546
|
+
\b
|
|
7547
|
+
{"dataLabels": [{"label": "pii", "types": ["com.example.Person"],
|
|
7548
|
+
"fields": ["com.example.Account#iban"]}]}
|
|
7549
|
+
|
|
7550
|
+
\b
|
|
7551
|
+
Everything after the declaration is measured, over authorities that already
|
|
7552
|
+
exist — the same call reach `impact-chain` walks, the same endpoint security
|
|
7553
|
+
surface `risk` weighs, the same profile resolution `posture` publishes. Two
|
|
7554
|
+
evidence classes, published apart and never added together:
|
|
7555
|
+
signature — the handler names the labelled type in its own signature
|
|
7556
|
+
call_reach — the handler's chain reaches it: a supremum, not a proof
|
|
7557
|
+
|
|
7558
|
+
\b
|
|
7559
|
+
A route absent from the answer is not evidence that it carries nothing:
|
|
7560
|
+
reflection, a runtime serializer, an untyped payload and a native query name no
|
|
7561
|
+
type (NC-008). No labels declared is reported as `answered: false` — never as
|
|
7562
|
+
zero exposed routes.
|
|
7563
|
+
|
|
7564
|
+
\b
|
|
7565
|
+
Examples:
|
|
7566
|
+
ask data-exposure .
|
|
7567
|
+
ask data-exposure . --profile prod
|
|
7568
|
+
ask data-exposure /path/to/repo -o exposure.json
|
|
7569
|
+
"""
|
|
7570
|
+
from sourcecode.data_exposure import build_data_exposure
|
|
7571
|
+
|
|
7572
|
+
path = _admit_path(path)
|
|
7573
|
+
_prog = Progress()
|
|
7574
|
+
_prog.start("measuring labelled data exposure")
|
|
7575
|
+
data = build_data_exposure(path, profiles=_profile_set(profile))
|
|
7576
|
+
_prog.finish()
|
|
7577
|
+
|
|
7578
|
+
summary = data["summary"]
|
|
7579
|
+
exposed = summary["endpoints_exposed"]
|
|
7580
|
+
_emit_command_output(
|
|
7581
|
+
_serialize_dict(data, format),
|
|
7582
|
+
output_path,
|
|
7583
|
+
False,
|
|
7584
|
+
success_msg=(
|
|
7585
|
+
f"data exposure written to {output_path} "
|
|
7586
|
+
+ (
|
|
7587
|
+
f"({exposed} route(s) carry {summary['labels']} label(s))"
|
|
7588
|
+
if data["answered"] else "(no labels declared — nothing measured)"
|
|
7589
|
+
)
|
|
7590
|
+
),
|
|
7591
|
+
)
|
|
7592
|
+
|
|
7593
|
+
|
|
7594
|
+
def _render_risk_help(doc: str) -> str:
|
|
7595
|
+
"""Fill the model line from the module that computes it.
|
|
7596
|
+
|
|
7597
|
+
The help screen carried its own copy of the composition and went on
|
|
7598
|
+
advertising four factors after a fifth and a sixth shipped — the same C4
|
|
7599
|
+
shape as `spring-audit`'s rule list, which is why this is rendered and not
|
|
7600
|
+
written twice.
|
|
7601
|
+
"""
|
|
7602
|
+
from sourcecode.risk import RISK_MODEL
|
|
7603
|
+
|
|
7604
|
+
return doc.replace("{{RISK_MODEL}}", RISK_MODEL)
|
|
7605
|
+
|
|
7606
|
+
|
|
7607
|
+
risk_cmd.__doc__ = _render_risk_help(risk_cmd.__doc__ or "")
|
|
7608
|
+
enrich_cmd.__doc__ = _render_risk_help(enrich_cmd.__doc__ or "")
|
|
7609
|
+
|
|
7610
|
+
|
|
7082
7611
|
@app.command("posture")
|
|
7083
7612
|
def posture_cmd(
|
|
7084
7613
|
path: Path = typer.Argument(
|
|
@@ -7182,7 +7711,7 @@ def posture_cmd(
|
|
|
7182
7711
|
path = _admit_path(path)
|
|
7183
7712
|
|
|
7184
7713
|
def _parse_set(raw: str) -> set:
|
|
7185
|
-
return
|
|
7714
|
+
return _profile_set(raw) or set()
|
|
7186
7715
|
|
|
7187
7716
|
overrides: dict = {}
|
|
7188
7717
|
for item in property_overrides or []:
|
|
@@ -10709,7 +11238,7 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
|
|
|
10709
11238
|
"cache", "auth", "mcp", "telemetry", "baseline",
|
|
10710
11239
|
)),
|
|
10711
11240
|
("Experimental — shape may change", (
|
|
10712
|
-
"risk", "archetype", "retrieve",
|
|
11241
|
+
"risk", "enrich", "data-exposure", "migrate-recipe", "archetype", "retrieve",
|
|
10713
11242
|
)),
|
|
10714
11243
|
)
|
|
10715
11244
|
|