sourcecode 4.8.0__py3-none-any.whl → 4.9.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 +147 -0
- sourcecode/cli.py +254 -13
- sourcecode/environment_resolution.py +88 -6
- sourcecode/non_coverage.py +12 -10
- sourcecode/posture.py +2 -1
- sourcecode/risk.py +223 -16
- sourcecode/rule_catalog.py +6 -1
- sourcecode/spring_security_audit.py +338 -0
- {sourcecode-4.8.0.dist-info → sourcecode-4.9.0.dist-info}/METADATA +11 -10
- {sourcecode-4.8.0.dist-info → sourcecode-4.9.0.dist-info}/RECORD +14 -13
- {sourcecode-4.8.0.dist-info → sourcecode-4.9.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.8.0.dist-info → sourcecode-4.9.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.8.0.dist-info → sourcecode-4.9.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Signed audit report over existing ASK evidence.
|
|
2
|
+
|
|
3
|
+
This module packages facts other commands already publish. It does not score ROI,
|
|
4
|
+
choose a fix, or introduce a second judgement layer: the report summary is a
|
|
5
|
+
reader-friendly index over the embedded evidence blocks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from sourcecode import __version__
|
|
18
|
+
|
|
19
|
+
AUDIT_REPORT_SCHEMA = "audit-report-v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _canonical_json(data: dict[str, Any]) -> bytes:
|
|
23
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sign(payload: dict[str, Any], key: bytes) -> dict[str, str]:
|
|
27
|
+
canonical = _canonical_json(payload)
|
|
28
|
+
return {
|
|
29
|
+
"algorithm": "hmac-sha256",
|
|
30
|
+
"payload_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
31
|
+
"key_id": hashlib.sha256(key).hexdigest()[:12],
|
|
32
|
+
"signature": hmac.new(key, canonical, hashlib.sha256).hexdigest(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_audit_report(
|
|
37
|
+
root: Path,
|
|
38
|
+
*,
|
|
39
|
+
profiles: Optional[set[str]] = None,
|
|
40
|
+
sign_key: Optional[bytes] = None,
|
|
41
|
+
risk_limit: int = 10,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Build the buyer-readable audit bundle from existing command payloads."""
|
|
44
|
+
from sourcecode.posture import build_posture
|
|
45
|
+
from sourcecode.risk import build_risk
|
|
46
|
+
|
|
47
|
+
root = Path(root).resolve()
|
|
48
|
+
risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
|
|
49
|
+
posture = build_posture(root, profiles or set())
|
|
50
|
+
top_risks = risk.get("risks", [])[:risk_limit]
|
|
51
|
+
access = ((posture.get("endpoints") or {}).get("effective_access") or {})
|
|
52
|
+
|
|
53
|
+
unsigned: dict[str, Any] = {
|
|
54
|
+
"schema_version": AUDIT_REPORT_SCHEMA,
|
|
55
|
+
"tool": {"name": "ask", "version": __version__},
|
|
56
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
57
|
+
"repository": {"path": str(root)},
|
|
58
|
+
**({"profile_set": sorted(profiles)} if profiles else {}),
|
|
59
|
+
"summary": {
|
|
60
|
+
"risk_model": risk.get("model"),
|
|
61
|
+
"total_defects": risk.get("total_defects"),
|
|
62
|
+
"total_findings": risk.get("total_findings"),
|
|
63
|
+
"risk_bands": risk.get("by_band", {}),
|
|
64
|
+
"top_risks_shown": len(top_risks),
|
|
65
|
+
"endpoint_access": access.get("summary", {}),
|
|
66
|
+
"unresolved_beans": len(posture.get("unresolved") or []),
|
|
67
|
+
},
|
|
68
|
+
"top_risks": top_risks,
|
|
69
|
+
"evidence": {
|
|
70
|
+
"risk": risk,
|
|
71
|
+
"posture": posture,
|
|
72
|
+
},
|
|
73
|
+
"limits": {
|
|
74
|
+
"statement": (
|
|
75
|
+
"This report packages existing ASK evidence. It does not certify "
|
|
76
|
+
"compliance, choose fixes, assign ROI, or replace a human audit decision."
|
|
77
|
+
),
|
|
78
|
+
"non_coverage": (risk.get("non_coverage") or {}).get("items", []),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
# Sign and publish the same JSON-normalized material. Some embedded evidence
|
|
82
|
+
# may contain tuples or other JSON-coercible values; normalizing before signing
|
|
83
|
+
# avoids a signature over an in-memory shape the user never receives.
|
|
84
|
+
normalized = json.loads(_canonical_json(unsigned).decode("utf-8"))
|
|
85
|
+
if sign_key:
|
|
86
|
+
normalized["signature"] = _sign(normalized, sign_key)
|
|
87
|
+
return normalized
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_markdown(report: dict[str, Any]) -> str:
|
|
91
|
+
"""Human report renderer over the JSON contract."""
|
|
92
|
+
summary = report.get("summary") or {}
|
|
93
|
+
repo = (report.get("repository") or {}).get("path", "")
|
|
94
|
+
profile = ", ".join(report.get("profile_set") or []) or "default"
|
|
95
|
+
lines = [
|
|
96
|
+
"# ASK Audit Report",
|
|
97
|
+
"",
|
|
98
|
+
f"- Repository: `{repo}`",
|
|
99
|
+
f"- ASK version: `{(report.get('tool') or {}).get('version', '')}`",
|
|
100
|
+
f"- Profile set: `{profile}`",
|
|
101
|
+
f"- Total defects: `{summary.get('total_defects')}`",
|
|
102
|
+
f"- Risk bands: `{summary.get('risk_bands')}`",
|
|
103
|
+
f"- Endpoint access: `{summary.get('endpoint_access')}`",
|
|
104
|
+
"",
|
|
105
|
+
"## Top Risks",
|
|
106
|
+
"",
|
|
107
|
+
]
|
|
108
|
+
risks = report.get("top_risks") or []
|
|
109
|
+
if not risks:
|
|
110
|
+
lines.append("No composed risks were reported.")
|
|
111
|
+
else:
|
|
112
|
+
lines.extend([
|
|
113
|
+
"| Band | Score | Rules | File | Line | Title |",
|
|
114
|
+
"|---|---:|---|---|---:|---|",
|
|
115
|
+
])
|
|
116
|
+
for row in risks:
|
|
117
|
+
rules = ", ".join(str(r) for r in row.get("rule_ids", []))
|
|
118
|
+
lines.append(
|
|
119
|
+
"| "
|
|
120
|
+
+ " | ".join([
|
|
121
|
+
str(row.get("band", "")),
|
|
122
|
+
str(row.get("severity_effective", "")),
|
|
123
|
+
rules,
|
|
124
|
+
str(row.get("source_file", "")),
|
|
125
|
+
str(row.get("first_line", "")),
|
|
126
|
+
str(row.get("title", "")).replace("|", "\\|"),
|
|
127
|
+
])
|
|
128
|
+
+ " |"
|
|
129
|
+
)
|
|
130
|
+
lines.extend([
|
|
131
|
+
"",
|
|
132
|
+
"## Limits",
|
|
133
|
+
"",
|
|
134
|
+
str((report.get("limits") or {}).get("statement", "")),
|
|
135
|
+
])
|
|
136
|
+
signature = report.get("signature")
|
|
137
|
+
if isinstance(signature, dict):
|
|
138
|
+
lines.extend([
|
|
139
|
+
"",
|
|
140
|
+
"## Signature",
|
|
141
|
+
"",
|
|
142
|
+
f"- Algorithm: `{signature.get('algorithm')}`",
|
|
143
|
+
f"- Payload SHA-256: `{signature.get('payload_sha256')}`",
|
|
144
|
+
f"- Key ID: `{signature.get('key_id')}`",
|
|
145
|
+
f"- Signature: `{signature.get('signature')}`",
|
|
146
|
+
])
|
|
147
|
+
return "\n".join(lines) + "\n"
|
sourcecode/cli.py
CHANGED
|
@@ -184,7 +184,8 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
|
|
|
184
184
|
"cache", "auth", "mcp", "telemetry",
|
|
185
185
|
)),
|
|
186
186
|
("experimental", "shape may change in a minor — do not gate CI on it", (
|
|
187
|
-
"risk", "enrich", "data-exposure", "migrate-recipe",
|
|
187
|
+
"risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
|
|
188
|
+
"posture", "archetype",
|
|
188
189
|
)),
|
|
189
190
|
# `retrieve` publishes 15+ intents whose answers the other commands already
|
|
190
191
|
# give better: measured on the battery, `security-surface` merely re-states
|
|
@@ -332,7 +333,7 @@ def _build_help_text() -> str:
|
|
|
332
333
|
plan_badge = "[yellow]Free[/yellow] · [dim]ask activate <key>[/dim] to unlock Pro"
|
|
333
334
|
|
|
334
335
|
text = f"""\
|
|
335
|
-
[bold]ASK Engine[/bold] [dim]· CLI: ask[/dim] {plan_badge}
|
|
336
|
+
[bold]ASK Engine {__version__}[/bold] [dim]· CLI: ask[/dim] {plan_badge}
|
|
336
337
|
|
|
337
338
|
Deterministic Java/Spring semantics and reusable structural context for AI coding agents.
|
|
338
339
|
|
|
@@ -359,6 +360,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
|
|
|
359
360
|
impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
|
|
360
361
|
pr-impact . --files - [dim]# same, scoped to a diff on stdin; gating codes[/dim]
|
|
361
362
|
posture . --since main [dim]# did this branch open an endpoint? (exp.)[/dim]
|
|
363
|
+
audit-report . --format markdown [dim]# signed human evidence bundle (exp.)[/dim]
|
|
362
364
|
verify . [dim]# contract gate, baseline-relative[/dim]
|
|
363
365
|
verify-edit . [dim]# did working-tree edits change behaviour?[/dim]
|
|
364
366
|
[dim]modernize · explain <Class> · validation · export · repo-ir[/dim]
|
|
@@ -6908,6 +6910,73 @@ def _render_risk_table(
|
|
|
6908
6910
|
return "\n".join(lines)
|
|
6909
6911
|
|
|
6910
6912
|
|
|
6913
|
+
def _render_enrich_table(
|
|
6914
|
+
data: dict[str, Any],
|
|
6915
|
+
*,
|
|
6916
|
+
rule_filter: Optional[str],
|
|
6917
|
+
band_filter: Optional[str],
|
|
6918
|
+
top_n: int,
|
|
6919
|
+
) -> str:
|
|
6920
|
+
"""Render SARIF-enriched findings as a bounded human triage table."""
|
|
6921
|
+
rule = (rule_filter or "").strip().upper()
|
|
6922
|
+
band = (band_filter or "").strip().lower()
|
|
6923
|
+
rows = []
|
|
6924
|
+
for row in data.get("findings", []):
|
|
6925
|
+
if not isinstance(row, dict):
|
|
6926
|
+
continue
|
|
6927
|
+
external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
|
|
6928
|
+
rule_id = str(external.get("rule_id") or ",".join(str(r) for r in row.get("rule_ids", [])))
|
|
6929
|
+
if rule and rule != rule_id.upper():
|
|
6930
|
+
continue
|
|
6931
|
+
if band and str(row.get("band", "")).lower() != band:
|
|
6932
|
+
continue
|
|
6933
|
+
rows.append(row)
|
|
6934
|
+
total = len(rows)
|
|
6935
|
+
if top_n > 0:
|
|
6936
|
+
rows = rows[:top_n]
|
|
6937
|
+
|
|
6938
|
+
def _cell(value: object, width: int) -> str:
|
|
6939
|
+
text = "" if value is None else str(value)
|
|
6940
|
+
text = text.replace("\n", " ")
|
|
6941
|
+
if len(text) > width:
|
|
6942
|
+
text = text[: max(0, width - 1)] + "..."
|
|
6943
|
+
return text
|
|
6944
|
+
|
|
6945
|
+
lines = [
|
|
6946
|
+
(
|
|
6947
|
+
f"Enriched findings table: showing {len(rows)}/{total} finding(s)"
|
|
6948
|
+
f"{' (rule=' + rule + ')' if rule else ''}"
|
|
6949
|
+
f"{' (band=' + band + ')' if band else ''}"
|
|
6950
|
+
f"{' (top-n=' + str(top_n) + ')' if top_n > 0 else ''}"
|
|
6951
|
+
)
|
|
6952
|
+
]
|
|
6953
|
+
if not rows:
|
|
6954
|
+
lines.append("No matching enriched findings.")
|
|
6955
|
+
return "\n".join(lines)
|
|
6956
|
+
|
|
6957
|
+
headers = ("band", "score", "tool", "rule", "reach", "auth", "file", "line", "title")
|
|
6958
|
+
widths = (8, 7, 12, 18, 7, 14, 34, 6, 48)
|
|
6959
|
+
lines.append(" | ".join(_cell(h, w).ljust(w) for h, w in zip(headers, widths)))
|
|
6960
|
+
lines.append("-+-".join("-" * w for w in widths))
|
|
6961
|
+
for row in rows:
|
|
6962
|
+
factors = row.get("factors", {}) if isinstance(row.get("factors"), dict) else {}
|
|
6963
|
+
auth = factors.get("auth_verdict", {}) if isinstance(factors.get("auth_verdict"), dict) else {}
|
|
6964
|
+
external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
|
|
6965
|
+
values = (
|
|
6966
|
+
row.get("band", ""),
|
|
6967
|
+
row.get("severity_effective", ""),
|
|
6968
|
+
external.get("tool", ""),
|
|
6969
|
+
external.get("rule_id", ""),
|
|
6970
|
+
row.get("endpoints_reached", ""),
|
|
6971
|
+
auth.get("value", ""),
|
|
6972
|
+
row.get("source_file", ""),
|
|
6973
|
+
row.get("first_line", "-"),
|
|
6974
|
+
row.get("title", ""),
|
|
6975
|
+
)
|
|
6976
|
+
lines.append(" | ".join(_cell(v, w).ljust(w) for v, w in zip(values, widths)))
|
|
6977
|
+
return "\n".join(lines)
|
|
6978
|
+
|
|
6979
|
+
|
|
6911
6980
|
def _render_migrate_check_table(
|
|
6912
6981
|
data: dict[str, Any],
|
|
6913
6982
|
*,
|
|
@@ -7668,9 +7737,10 @@ def risk_cmd(
|
|
|
7668
7737
|
it, and every row publishes every factor with the authority each came from, so
|
|
7669
7738
|
a reader can disagree with one and keep the rest. An axis that could not be
|
|
7670
7739
|
measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`. The
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7740
|
+
input-path axes are bounded: a query may be merely concatenated, or may carry
|
|
7741
|
+
an annotated HTTP input through positional calls to a known query sink; a
|
|
7742
|
+
reached route may accept an unconstrained body. General taint is still NC-001,
|
|
7743
|
+
in the payload's `non_coverage`.
|
|
7674
7744
|
|
|
7675
7745
|
\b
|
|
7676
7746
|
One row is one **defect** — the same identity `spring-audit` groups by, so the
|
|
@@ -7758,6 +7828,28 @@ def enrich_cmd(
|
|
|
7758
7828
|
limit: int = typer.Option(
|
|
7759
7829
|
50, "--limit", help="How many enriched findings to publish (highest first)."
|
|
7760
7830
|
),
|
|
7831
|
+
table: bool = typer.Option(
|
|
7832
|
+
False,
|
|
7833
|
+
"--table",
|
|
7834
|
+
help="Render a human table instead of JSON/YAML. Use with --rule, --band and --top-n.",
|
|
7835
|
+
),
|
|
7836
|
+
rule_filter: Optional[str] = typer.Option(
|
|
7837
|
+
None,
|
|
7838
|
+
"--rule",
|
|
7839
|
+
"--only",
|
|
7840
|
+
help="With --table, show only enriched findings from one external rule id.",
|
|
7841
|
+
),
|
|
7842
|
+
band_filter: Optional[str] = typer.Option(
|
|
7843
|
+
None,
|
|
7844
|
+
"--band",
|
|
7845
|
+
help="With --table, show only one composed band: critical | high | medium | low.",
|
|
7846
|
+
),
|
|
7847
|
+
top_n: int = typer.Option(
|
|
7848
|
+
0,
|
|
7849
|
+
"--top-n",
|
|
7850
|
+
min=0,
|
|
7851
|
+
help="With --table, show only the top N findings after filtering. 0 means all.",
|
|
7852
|
+
),
|
|
7761
7853
|
min_band: str = typer.Option(
|
|
7762
7854
|
"low",
|
|
7763
7855
|
"--min-band",
|
|
@@ -7779,9 +7871,10 @@ def enrich_cmd(
|
|
|
7779
7871
|
|
|
7780
7872
|
\b
|
|
7781
7873
|
This product does not detect injection, CVEs or secrets, and says so in every
|
|
7782
|
-
security payload (NC-001, NC-002).
|
|
7783
|
-
|
|
7784
|
-
|
|
7874
|
+
security payload (NC-001, NC-002). `risk` contributes one bounded HTTP-input
|
|
7875
|
+
to query-sink factor, not a scanner. A scanner has no bean graph, no profile
|
|
7876
|
+
model and no endpoint id it shares with anything, so its output is a list
|
|
7877
|
+
nobody can order. `enrich` reads that list and answers the question the
|
|
7785
7878
|
scanner cannot: which of these findings sits in code an unauthenticated
|
|
7786
7879
|
request reaches.
|
|
7787
7880
|
|
|
@@ -7813,6 +7906,7 @@ def enrich_cmd(
|
|
|
7813
7906
|
ask enrich . --sarif semgrep.sarif
|
|
7814
7907
|
ask enrich . --sarif codeql.sarif --min-band high
|
|
7815
7908
|
ask enrich . --sarif trivy.sarif --profile prod
|
|
7909
|
+
ask enrich . --sarif semgrep.sarif --table --rule java-sqli --band high --top-n 20
|
|
7816
7910
|
ask enrich /path/to/repo --sarif trivy.sarif -o enriched.json
|
|
7817
7911
|
"""
|
|
7818
7912
|
from sourcecode.sarif import enrich_sarif
|
|
@@ -7835,6 +7929,16 @@ def enrich_cmd(
|
|
|
7835
7929
|
expected="critical|high|medium|low",
|
|
7836
7930
|
)
|
|
7837
7931
|
raise typer.Exit(code=1)
|
|
7932
|
+
if band_filter is not None:
|
|
7933
|
+
band_filter = band_filter.strip().lower()
|
|
7934
|
+
if band_filter is not None and band_filter not in ("critical", "high", "medium", "low"):
|
|
7935
|
+
_emit_error_json(
|
|
7936
|
+
INVALID_INPUT_CODE,
|
|
7937
|
+
f"--band expects critical | high | medium | low (got {band_filter!r}).",
|
|
7938
|
+
hint="Example: --table --band high",
|
|
7939
|
+
expected="critical|high|medium|low",
|
|
7940
|
+
)
|
|
7941
|
+
raise typer.Exit(code=1)
|
|
7838
7942
|
if not Path(sarif).is_file():
|
|
7839
7943
|
_emit_error_json(
|
|
7840
7944
|
INVALID_INPUT_CODE,
|
|
@@ -7844,11 +7948,12 @@ def enrich_cmd(
|
|
|
7844
7948
|
)
|
|
7845
7949
|
raise typer.Exit(code=1)
|
|
7846
7950
|
|
|
7951
|
+
enrich_limit = 100000 if table and (rule_filter or band_filter or top_n) else limit
|
|
7847
7952
|
_prog = Progress()
|
|
7848
7953
|
_prog.start("composing risk factors")
|
|
7849
7954
|
try:
|
|
7850
7955
|
data = enrich_sarif(
|
|
7851
|
-
Path(sarif), path, limit=
|
|
7956
|
+
Path(sarif), path, limit=enrich_limit, min_band=min_band,
|
|
7852
7957
|
profiles=_profile_set(profile),
|
|
7853
7958
|
)
|
|
7854
7959
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
@@ -7863,8 +7968,14 @@ def enrich_cmd(
|
|
|
7863
7968
|
finally:
|
|
7864
7969
|
_prog.stop()
|
|
7865
7970
|
|
|
7971
|
+
if table:
|
|
7972
|
+
output = _render_enrich_table(
|
|
7973
|
+
data, rule_filter=rule_filter, band_filter=band_filter, top_n=top_n
|
|
7974
|
+
)
|
|
7975
|
+
else:
|
|
7976
|
+
output = _serialize_dict(data, format)
|
|
7866
7977
|
_emit_command_output(
|
|
7867
|
-
|
|
7978
|
+
output,
|
|
7868
7979
|
output_path,
|
|
7869
7980
|
copy,
|
|
7870
7981
|
success_msg=(
|
|
@@ -7874,6 +7985,102 @@ def enrich_cmd(
|
|
|
7874
7985
|
)
|
|
7875
7986
|
|
|
7876
7987
|
|
|
7988
|
+
@app.command("audit-report")
|
|
7989
|
+
def audit_report_cmd(
|
|
7990
|
+
path: Path = typer.Argument(
|
|
7991
|
+
Path("."),
|
|
7992
|
+
help="Repository path (default: current directory).",
|
|
7993
|
+
),
|
|
7994
|
+
profile: Optional[str] = typer.Option(
|
|
7995
|
+
None,
|
|
7996
|
+
"--profile",
|
|
7997
|
+
help="Active profile set, comma-separated (e.g. prod or prod,metrics).",
|
|
7998
|
+
),
|
|
7999
|
+
sign_key: Optional[Path] = typer.Option(
|
|
8000
|
+
None,
|
|
8001
|
+
"--sign-key",
|
|
8002
|
+
help="Sign the canonical JSON report with this local HMAC key file.",
|
|
8003
|
+
),
|
|
8004
|
+
output_path: Optional[Path] = typer.Option(
|
|
8005
|
+
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
8006
|
+
),
|
|
8007
|
+
format: str = typer.Option(
|
|
8008
|
+
"json", "--format", "-f", help="Output format: json, yaml or markdown."
|
|
8009
|
+
),
|
|
8010
|
+
copy: bool = _copy_option(),
|
|
8011
|
+
) -> None:
|
|
8012
|
+
"""[EXPERIMENTAL] Human audit bundle over existing ASK evidence, optionally signed.
|
|
8013
|
+
|
|
8014
|
+
\b
|
|
8015
|
+
This packages the same evidence `risk` and `posture` already publish into a
|
|
8016
|
+
report a buyer can read: top risks, effective runtime posture, source-linked
|
|
8017
|
+
evidence and non-coverage. It does not certify compliance, choose fixes or
|
|
8018
|
+
assign ROI.
|
|
8019
|
+
|
|
8020
|
+
\b
|
|
8021
|
+
`--sign-key` adds an HMAC-SHA256 signature over the canonical JSON payload.
|
|
8022
|
+
The key is local and explicit; no network trust service or new PKI is implied.
|
|
8023
|
+
|
|
8024
|
+
\b
|
|
8025
|
+
Examples:
|
|
8026
|
+
ask audit-report .
|
|
8027
|
+
ask audit-report . --profile prod --format markdown
|
|
8028
|
+
ask audit-report . --sign-key audit.key -o audit-report.json
|
|
8029
|
+
"""
|
|
8030
|
+
from sourcecode.audit_report import build_audit_report, render_markdown
|
|
8031
|
+
|
|
8032
|
+
path = _admit_path(path)
|
|
8033
|
+
fmt = format.strip().lower()
|
|
8034
|
+
if fmt not in ("json", "yaml", "markdown"):
|
|
8035
|
+
_emit_error_json(
|
|
8036
|
+
INVALID_INPUT_CODE,
|
|
8037
|
+
f"--format expects json | yaml | markdown (got {format!r}).",
|
|
8038
|
+
hint="Example: ask audit-report . --format markdown",
|
|
8039
|
+
expected="json|yaml|markdown",
|
|
8040
|
+
)
|
|
8041
|
+
raise typer.Exit(code=1)
|
|
8042
|
+
key_bytes = None
|
|
8043
|
+
if sign_key is not None:
|
|
8044
|
+
key_path = Path(sign_key).expanduser()
|
|
8045
|
+
if not key_path.is_file():
|
|
8046
|
+
_emit_error_json(
|
|
8047
|
+
INVALID_INPUT_CODE,
|
|
8048
|
+
f"--sign-key must name a readable file (got {str(sign_key)!r}).",
|
|
8049
|
+
hint="Example: ask audit-report . --sign-key audit.key",
|
|
8050
|
+
expected="an existing key file",
|
|
8051
|
+
)
|
|
8052
|
+
raise typer.Exit(code=1)
|
|
8053
|
+
key_bytes = key_path.read_bytes()
|
|
8054
|
+
if not key_bytes:
|
|
8055
|
+
_emit_error_json(
|
|
8056
|
+
INVALID_INPUT_CODE,
|
|
8057
|
+
"--sign-key file is empty; refusing to publish an unverifiable signature.",
|
|
8058
|
+
hint="Write a random local secret to the key file first.",
|
|
8059
|
+
expected="a non-empty key file",
|
|
8060
|
+
)
|
|
8061
|
+
raise typer.Exit(code=1)
|
|
8062
|
+
|
|
8063
|
+
_prog = Progress()
|
|
8064
|
+
_prog.start("packaging audit evidence")
|
|
8065
|
+
try:
|
|
8066
|
+
data = build_audit_report(
|
|
8067
|
+
path, profiles=_profile_set(profile), sign_key=key_bytes,
|
|
8068
|
+
)
|
|
8069
|
+
finally:
|
|
8070
|
+
_prog.stop()
|
|
8071
|
+
output = render_markdown(data) if fmt == "markdown" else _serialize_dict(data, fmt)
|
|
8072
|
+
_emit_command_output(
|
|
8073
|
+
output,
|
|
8074
|
+
output_path,
|
|
8075
|
+
copy,
|
|
8076
|
+
success_msg=f"audit report written to {output_path}",
|
|
8077
|
+
# A signed artifact cannot be mutated after signing. The report carries
|
|
8078
|
+
# tool/version metadata itself, and `signature.payload_sha256` is over
|
|
8079
|
+
# the exact JSON payload the user receives.
|
|
8080
|
+
stamp_envelope=False,
|
|
8081
|
+
)
|
|
8082
|
+
|
|
8083
|
+
|
|
7877
8084
|
@app.command("migrate-recipe")
|
|
7878
8085
|
def migrate_recipe_cmd(
|
|
7879
8086
|
path: Path = typer.Argument(
|
|
@@ -8145,6 +8352,14 @@ def posture_cmd(
|
|
|
8145
8352
|
"by what it leaves unauthenticated."
|
|
8146
8353
|
),
|
|
8147
8354
|
),
|
|
8355
|
+
env_file: Optional[Path] = typer.Option(
|
|
8356
|
+
None,
|
|
8357
|
+
"--env-file",
|
|
8358
|
+
help=(
|
|
8359
|
+
"With --resolve-environments, read SPRING_PROFILES_ACTIVE or "
|
|
8360
|
+
"spring.profiles.active from an explicit deployment env/dotenv file."
|
|
8361
|
+
),
|
|
8362
|
+
),
|
|
8148
8363
|
property_overrides: Optional[list[str]] = typer.Option(
|
|
8149
8364
|
None,
|
|
8150
8365
|
"--property",
|
|
@@ -8192,6 +8407,7 @@ def posture_cmd(
|
|
|
8192
8407
|
ask posture . --since origin/main --fail-on opened
|
|
8193
8408
|
ask posture . --profile prod --property app.security.enabled=true
|
|
8194
8409
|
ask posture . --resolve-environments
|
|
8410
|
+
ask posture . --resolve-environments --env-file prod.env
|
|
8195
8411
|
"""
|
|
8196
8412
|
from sourcecode.posture import (
|
|
8197
8413
|
FAIL_ON_CHOICES,
|
|
@@ -8266,6 +8482,14 @@ def posture_cmd(
|
|
|
8266
8482
|
hint="ask posture . --profile prod --diff-ref origin/main:HEAD",
|
|
8267
8483
|
)
|
|
8268
8484
|
raise typer.Exit(code=1)
|
|
8485
|
+
if env_file and not resolve_environments_flag:
|
|
8486
|
+
_emit_error_json(
|
|
8487
|
+
INVALID_INPUT_CODE,
|
|
8488
|
+
"--env-file supplies deployment evidence for --resolve-environments; it cannot "
|
|
8489
|
+
"be used with a single profile, profile diff or ref comparison.",
|
|
8490
|
+
hint="ask posture . --resolve-environments --env-file prod.env",
|
|
8491
|
+
)
|
|
8492
|
+
raise typer.Exit(code=1)
|
|
8269
8493
|
|
|
8270
8494
|
# One spinner over the whole dispatch: every branch below resolves the
|
|
8271
8495
|
# conditional bean graph, and `--diff-ref` resolves it twice (once per ref).
|
|
@@ -8280,10 +8504,26 @@ def posture_cmd(
|
|
|
8280
8504
|
INVALID_INPUT_CODE,
|
|
8281
8505
|
"--resolve-environments answers which profile set runs; it cannot be "
|
|
8282
8506
|
"combined with --profile, --diff or --diff-ref/--since, which state one.",
|
|
8283
|
-
hint="Run `ask posture . --resolve-environments` on its own
|
|
8507
|
+
hint="Run `ask posture . --resolve-environments` on its own, or add "
|
|
8508
|
+
"`--env-file prod.env` as deployment evidence.",
|
|
8284
8509
|
)
|
|
8285
8510
|
raise typer.Exit(code=1)
|
|
8286
|
-
|
|
8511
|
+
deployment_signals = None
|
|
8512
|
+
if env_file is not None:
|
|
8513
|
+
from sourcecode.environment_resolution import collect_env_file_signals
|
|
8514
|
+
|
|
8515
|
+
env_path = Path(env_file).expanduser()
|
|
8516
|
+
if not env_path.is_file():
|
|
8517
|
+
_emit_error_json(
|
|
8518
|
+
INVALID_INPUT_CODE,
|
|
8519
|
+
f"--env-file must name a readable file (got {str(env_file)!r}).",
|
|
8520
|
+
hint="ask posture . --resolve-environments --env-file prod.env",
|
|
8521
|
+
)
|
|
8522
|
+
raise typer.Exit(code=1)
|
|
8523
|
+
deployment_signals = collect_env_file_signals(env_path, root=path)
|
|
8524
|
+
data = resolve_environments(
|
|
8525
|
+
path, overrides, deployment_signals=deployment_signals,
|
|
8526
|
+
)
|
|
8287
8527
|
elif ref_comparison:
|
|
8288
8528
|
from sourcecode.git_checkout import RefError
|
|
8289
8529
|
|
|
@@ -11913,7 +12153,8 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
|
|
|
11913
12153
|
"cache", "auth", "mcp", "telemetry", "baseline",
|
|
11914
12154
|
)),
|
|
11915
12155
|
("Experimental — shape may change", (
|
|
11916
|
-
"risk", "enrich", "data-exposure", "migrate-recipe",
|
|
12156
|
+
"risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
|
|
12157
|
+
"archetype", "retrieve",
|
|
11917
12158
|
)),
|
|
11918
12159
|
)
|
|
11919
12160
|
|
|
@@ -335,25 +335,107 @@ def _next_param_value(lines: list[str], after: int) -> Optional[str]:
|
|
|
335
335
|
return None
|
|
336
336
|
|
|
337
337
|
|
|
338
|
-
def _signal(
|
|
338
|
+
def _signal(
|
|
339
|
+
kind: str,
|
|
340
|
+
rel: str,
|
|
341
|
+
line: int,
|
|
342
|
+
raw: str,
|
|
343
|
+
value: Optional[str],
|
|
344
|
+
*,
|
|
345
|
+
explicit: bool = False,
|
|
346
|
+
) -> Signal:
|
|
347
|
+
prefix = "explicit deployment input " if explicit else ""
|
|
339
348
|
if value and _PLACEHOLDER.search(value):
|
|
340
349
|
return Signal(
|
|
341
350
|
kind, rel, line, raw, None,
|
|
342
|
-
f"declares {value!r} — a placeholder resolved outside this repository, "
|
|
351
|
+
f"{prefix}declares {value!r} — a placeholder resolved outside this repository, "
|
|
343
352
|
"so this artefact names where the value comes from, not what it is",
|
|
344
353
|
)
|
|
345
354
|
if not value:
|
|
346
355
|
return Signal(
|
|
347
356
|
kind, rel, line, raw, None,
|
|
348
|
-
"names the setting without a value here — it defers to whatever "
|
|
357
|
+
f"{prefix}names the setting without a value here — it defers to whatever "
|
|
349
358
|
"supplies it at run time",
|
|
350
359
|
)
|
|
351
|
-
return Signal(
|
|
360
|
+
return Signal(
|
|
361
|
+
kind, rel, line, raw, value,
|
|
362
|
+
f"{prefix}sets the active profile set to {value!r}",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list[Signal]:
|
|
367
|
+
"""Signals from a caller-supplied deployment env/dotenv file.
|
|
368
|
+
|
|
369
|
+
Unlike repository artefacts, this is evidence the reviewer explicitly supplies
|
|
370
|
+
for the deployment being audited, so a deciding value outranks defaults found
|
|
371
|
+
in the tree. It still travels as ordinary evidence: file, line, raw text and
|
|
372
|
+
placeholder handling are the same as repository scans.
|
|
373
|
+
"""
|
|
374
|
+
source = Path(path).expanduser().resolve()
|
|
375
|
+
root_path = Path(root).resolve() if root is not None else None
|
|
376
|
+
text = source.read_text(encoding="utf-8", errors="ignore")
|
|
377
|
+
try:
|
|
378
|
+
rel = source.relative_to(root_path).as_posix() if root_path else source.as_posix()
|
|
379
|
+
except ValueError:
|
|
380
|
+
rel = source.as_posix()
|
|
352
381
|
|
|
382
|
+
signals: list[Signal] = []
|
|
383
|
+
for index, line in enumerate(text.splitlines(), start=1):
|
|
384
|
+
stripped = line.strip()
|
|
385
|
+
if not stripped or stripped.startswith("#"):
|
|
386
|
+
continue
|
|
387
|
+
for _label, pattern in _LINE_PATTERNS:
|
|
388
|
+
match = pattern.search(line)
|
|
389
|
+
if match:
|
|
390
|
+
signals.append(_signal(
|
|
391
|
+
"deployment_input", rel, index, line, _clean(match.group(1)),
|
|
392
|
+
explicit=True,
|
|
393
|
+
))
|
|
394
|
+
break
|
|
395
|
+
return signals
|
|
353
396
|
|
|
354
|
-
|
|
397
|
+
|
|
398
|
+
def resolve_environment(
|
|
399
|
+
root: Path,
|
|
400
|
+
deployment_signals: Optional[Iterable[Signal]] = None,
|
|
401
|
+
) -> EnvironmentResolution:
|
|
355
402
|
"""What decides the active profile set in *root* — or the fact that nothing does."""
|
|
356
|
-
|
|
403
|
+
supplied = list(deployment_signals or [])
|
|
404
|
+
repo_signals = collect_signals(root)
|
|
405
|
+
supplied_deciding = [s for s in supplied if s.value and s.activates]
|
|
406
|
+
supplied_deferred = [s for s in supplied if not (s.value and s.activates)]
|
|
407
|
+
if supplied_deciding:
|
|
408
|
+
distinct_supplied = sorted({s.value for s in supplied_deciding if s.value})
|
|
409
|
+
if len(distinct_supplied) > 1:
|
|
410
|
+
return EnvironmentResolution(
|
|
411
|
+
verdict=DISAGREE,
|
|
412
|
+
profile_source="; ".join(
|
|
413
|
+
f"{s.file}:{s.line} → {s.value}" for s in supplied_deciding[:6]
|
|
414
|
+
),
|
|
415
|
+
statement=(
|
|
416
|
+
"The supplied deployment inputs set different active profile sets "
|
|
417
|
+
f"({', '.join(repr(v) for v in distinct_supplied)}). ASK will not "
|
|
418
|
+
"choose between deployment descriptions that disagree."
|
|
419
|
+
),
|
|
420
|
+
signals=supplied_deciding,
|
|
421
|
+
deferred=supplied_deferred + repo_signals,
|
|
422
|
+
)
|
|
423
|
+
values = _split_profiles(distinct_supplied[0])
|
|
424
|
+
first = supplied_deciding[0]
|
|
425
|
+
return EnvironmentResolution(
|
|
426
|
+
verdict=DECIDED,
|
|
427
|
+
profile_source=f"{first.kind} ({first.file}:{first.line})",
|
|
428
|
+
statement=(
|
|
429
|
+
f"The supplied deployment input says {', '.join(repr(v) for v in values)} "
|
|
430
|
+
f"is the active profile set, from {first.file}:{first.line}. Repository "
|
|
431
|
+
"defaults are still published below as lower-priority evidence."
|
|
432
|
+
),
|
|
433
|
+
signals=supplied_deciding,
|
|
434
|
+
deferred=supplied_deferred + repo_signals,
|
|
435
|
+
values=values,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
signals = supplied + repo_signals
|
|
357
439
|
deciding = [s for s in signals if s.value and s.activates]
|
|
358
440
|
deferred = [s for s in signals if not (s.value and s.activates)]
|
|
359
441
|
inert = [s for s in signals if s.value and not s.activates]
|