vexpath 0.1.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.
- vexpath/__init__.py +12 -0
- vexpath/commands/__init__.py +3 -0
- vexpath/commands/advisory.py +305 -0
- vexpath/commands/attest.py +348 -0
- vexpath/commands/auth.py +235 -0
- vexpath/commands/policy.py +179 -0
- vexpath/commands/reach.py +235 -0
- vexpath/commands/report.py +189 -0
- vexpath/commands/sbom.py +173 -0
- vexpath/commands/scan.py +309 -0
- vexpath/commands/upload.py +104 -0
- vexpath/commands/vex.py +249 -0
- vexpath/credentials.py +213 -0
- vexpath/exit_codes.py +28 -0
- vexpath/main.py +120 -0
- vexpath/output.py +96 -0
- vexpath/py.typed +0 -0
- vexpath/sources.py +88 -0
- vexpath-0.1.0.dist-info/METADATA +160 -0
- vexpath-0.1.0.dist-info/RECORD +23 -0
- vexpath-0.1.0.dist-info/WHEEL +4 -0
- vexpath-0.1.0.dist-info/entry_points.txt +2 -0
- vexpath-0.1.0.dist-info/licenses/LICENSE +201 -0
vexpath/commands/auth.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""``vexpath login``, ``whoami`` and ``logout``.
|
|
2
|
+
|
|
3
|
+
Three commands whose whole job is to make it obvious which credential is in
|
|
4
|
+
use. Most authentication confusion is not "my key is wrong", it is "which key
|
|
5
|
+
is this using", and the answer is usually that one source overrode another.
|
|
6
|
+
So every one of these says where the credential came from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from vexpath import credentials
|
|
18
|
+
from vexpath.output import OutputFormat, emit, emit_json, fail, fail_config, note, warn
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Store and check the credential used for uploads.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
_TIMEOUT = 30.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("login")
|
|
26
|
+
def login(
|
|
27
|
+
key: Annotated[
|
|
28
|
+
str | None,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--key",
|
|
31
|
+
help=(
|
|
32
|
+
"The API key. Omitted, it is read from the terminal without "
|
|
33
|
+
"echoing, so it does not land in shell history."
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
] = None,
|
|
37
|
+
api_url: Annotated[
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option("--api-url", help="Platform to talk to, for a self-hosted install."),
|
|
40
|
+
] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Verify an API key and store it.
|
|
43
|
+
|
|
44
|
+
The key is checked against the platform before it is written. Storing an
|
|
45
|
+
unverified credential turns a typo into an authentication failure on some
|
|
46
|
+
later command, with nothing to connect the two.
|
|
47
|
+
"""
|
|
48
|
+
secret = key or typer.prompt("API key", hide_input=True)
|
|
49
|
+
url = (api_url or credentials.DEFAULT_API_URL).rstrip("/")
|
|
50
|
+
|
|
51
|
+
identity = _whoami(url, secret)
|
|
52
|
+
try:
|
|
53
|
+
path = credentials.store(secret, url)
|
|
54
|
+
except credentials.CredentialError as exc:
|
|
55
|
+
fail_config(str(exc))
|
|
56
|
+
|
|
57
|
+
note(f"signed in to {identity['org_name']} ({identity['org_slug']})")
|
|
58
|
+
note(f"key {credentials.display_prefix_of(secret)}")
|
|
59
|
+
note(f"stored in {path}")
|
|
60
|
+
if credentials.KEY_ENVIRONMENT_VARIABLE in _environment():
|
|
61
|
+
# Said now rather than discovered later: the environment wins, so the
|
|
62
|
+
# key just stored will not be the one used.
|
|
63
|
+
warn(
|
|
64
|
+
f"${credentials.KEY_ENVIRONMENT_VARIABLE} is set, and it takes "
|
|
65
|
+
f"precedence over the stored credential. Unset it to use this one."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("whoami")
|
|
70
|
+
def whoami(
|
|
71
|
+
output: Annotated[
|
|
72
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
73
|
+
] = OutputFormat.TABLE,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Show which credential is in use, and for which organisation.
|
|
76
|
+
|
|
77
|
+
Exits 3 when there is none, so a CI step can check for a credential
|
|
78
|
+
before running a scan it intends to upload.
|
|
79
|
+
"""
|
|
80
|
+
credential = _credential()
|
|
81
|
+
identity = _whoami(credential.api_url, credential.secret)
|
|
82
|
+
|
|
83
|
+
if output is OutputFormat.JSON:
|
|
84
|
+
emit_json({**identity, "source": credential.source, "api_url": credential.api_url})
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
emit(
|
|
88
|
+
"\n".join(
|
|
89
|
+
[
|
|
90
|
+
f"{'organisation':<16}{identity['org_name']} ({identity['org_slug']})",
|
|
91
|
+
f"{'projects':<16}{identity['project_count']}",
|
|
92
|
+
f"{'credential':<16}{credential.prefix}",
|
|
93
|
+
# Which source won. This is the question behind most
|
|
94
|
+
# authentication confusion.
|
|
95
|
+
f"{'from':<16}{_source_label(credential.source)}",
|
|
96
|
+
f"{'platform':<16}{credential.api_url}",
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
+ "\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _source_label(source: str) -> str:
|
|
104
|
+
if source == "environment":
|
|
105
|
+
return f"${credentials.KEY_ENVIRONMENT_VARIABLE}"
|
|
106
|
+
return str(credentials.credential_path())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("logout")
|
|
110
|
+
def logout() -> None:
|
|
111
|
+
"""Remove the stored credential.
|
|
112
|
+
|
|
113
|
+
Says plainly when an exported key means you are still authenticated.
|
|
114
|
+
Finding that out later is worse than being told now.
|
|
115
|
+
"""
|
|
116
|
+
removed = credentials.forget()
|
|
117
|
+
if removed:
|
|
118
|
+
note(f"removed {credentials.credential_path()}")
|
|
119
|
+
else:
|
|
120
|
+
note("there was no stored credential")
|
|
121
|
+
|
|
122
|
+
if credentials.KEY_ENVIRONMENT_VARIABLE in _environment():
|
|
123
|
+
warn(
|
|
124
|
+
f"${credentials.KEY_ENVIRONMENT_VARIABLE} is still set, so uploads "
|
|
125
|
+
f"will continue to work. Unset it to sign out completely."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def credential_or_exit() -> credentials.Credential:
|
|
130
|
+
"""The credential to use, or an explanation and exit 3.
|
|
131
|
+
|
|
132
|
+
Public because the scan command needs it before it starts work: finding
|
|
133
|
+
out there is no credential after a four-minute analysis wastes the
|
|
134
|
+
analysis.
|
|
135
|
+
"""
|
|
136
|
+
return _credential()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _credential() -> credentials.Credential:
|
|
140
|
+
try:
|
|
141
|
+
credential = credentials.load()
|
|
142
|
+
except credentials.CredentialError as exc:
|
|
143
|
+
fail_config(str(exc))
|
|
144
|
+
if credential is None:
|
|
145
|
+
fail_config(
|
|
146
|
+
f"no credential. Run 'vexpath login', or set "
|
|
147
|
+
f"${credentials.KEY_ENVIRONMENT_VARIABLE} in CI."
|
|
148
|
+
)
|
|
149
|
+
return credential
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _whoami(api_url: str, secret: str) -> dict[str, Any]:
|
|
153
|
+
"""Ask the platform who this credential belongs to.
|
|
154
|
+
|
|
155
|
+
Exits rather than returning an error, because every caller here needs the
|
|
156
|
+
answer to continue, and each failure has a different remedy worth naming.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
with httpx.Client(timeout=_TIMEOUT, follow_redirects=True) as client:
|
|
160
|
+
response = client.get(f"{api_url}/v1/me", headers={"Authorization": f"Bearer {secret}"})
|
|
161
|
+
except httpx.HTTPError as exc:
|
|
162
|
+
fail(f"{api_url} could not be reached: {exc}")
|
|
163
|
+
|
|
164
|
+
if response.status_code == 401:
|
|
165
|
+
fail_config(
|
|
166
|
+
"the platform rejected that key. Check it was copied whole, and "
|
|
167
|
+
"that it has not been revoked in the dashboard."
|
|
168
|
+
)
|
|
169
|
+
if response.status_code == 404:
|
|
170
|
+
fail_config(
|
|
171
|
+
f"{api_url} does not look like a vexpath platform: /v1/me is not "
|
|
172
|
+
f"there. Check --api-url."
|
|
173
|
+
)
|
|
174
|
+
if response.status_code >= 400:
|
|
175
|
+
fail(f"{api_url} answered {response.status_code}: {response.text[:200]}")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
payload: dict[str, Any] = response.json()
|
|
179
|
+
except ValueError:
|
|
180
|
+
fail(f"{api_url} answered with something that is not JSON")
|
|
181
|
+
return payload
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _environment() -> dict[str, str]:
|
|
185
|
+
import os
|
|
186
|
+
|
|
187
|
+
return dict(os.environ)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def upload_results(
|
|
191
|
+
credential: credentials.Credential, project: str, results: Mapping[str, Any]
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Post a results document, and say what happened.
|
|
194
|
+
|
|
195
|
+
Lives here rather than in the scan command so that the credential handling
|
|
196
|
+
and the error messages are in one place: a failed upload after a
|
|
197
|
+
successful scan is the moment a user most needs to be told which of the
|
|
198
|
+
two went wrong. Every message below therefore begins by saying the scan
|
|
199
|
+
itself completed, because the exit code cannot say it and a CI log read in
|
|
200
|
+
a hurry will otherwise be understood as "the scan failed".
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
with httpx.Client(timeout=_TIMEOUT, follow_redirects=True) as client:
|
|
204
|
+
response = client.post(
|
|
205
|
+
f"{credential.api_url}/v1/scans",
|
|
206
|
+
json={"project": project, "results": dict(results)},
|
|
207
|
+
headers={"Authorization": f"Bearer {credential.secret}"},
|
|
208
|
+
)
|
|
209
|
+
except httpx.HTTPError as exc:
|
|
210
|
+
fail(
|
|
211
|
+
f"the scan completed but could not be uploaded: {credential.api_url} "
|
|
212
|
+
f"could not be reached: {exc}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if response.status_code == 401:
|
|
216
|
+
fail_config("the scan completed but the platform rejected the credential.")
|
|
217
|
+
if response.status_code == 404:
|
|
218
|
+
fail_config(
|
|
219
|
+
f"the scan completed but there is no project {project!r} in this "
|
|
220
|
+
f"organisation. Create it in the dashboard, or check --project."
|
|
221
|
+
)
|
|
222
|
+
if response.status_code >= 400:
|
|
223
|
+
fail(
|
|
224
|
+
f"the scan completed but the upload was refused with "
|
|
225
|
+
f"{response.status_code}: {response.text[:300]}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
stored: dict[str, Any] = response.json()
|
|
230
|
+
except ValueError:
|
|
231
|
+
fail(f"the scan was accepted but {credential.api_url} answered with non-JSON")
|
|
232
|
+
return stored
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["app", "credential_or_exit", "login", "logout", "upload_results", "whoami"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The ``vexpath policy`` command group: read, test and explain the gate.
|
|
2
|
+
|
|
3
|
+
A gate nobody can try out before pushing is a gate that gets disabled. These
|
|
4
|
+
commands exist so a policy can be written, run against a scan that already
|
|
5
|
+
happened, and understood without reading the source.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from vexpath.exit_codes import ExitCode
|
|
16
|
+
from vexpath.output import OutputFormat, emit, emit_json, fail, fail_config, note
|
|
17
|
+
from vexpath_core import policy as policy_module
|
|
18
|
+
from vexpath_core.models.results import ScanResults
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Write, test and explain the build gate.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("list")
|
|
24
|
+
def list_presets(
|
|
25
|
+
output: Annotated[
|
|
26
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
27
|
+
] = OutputFormat.TABLE,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Show the named policies and what each one does."""
|
|
30
|
+
payload = [
|
|
31
|
+
{
|
|
32
|
+
"name": name,
|
|
33
|
+
"default": name == policy_module.DEFAULT_PRESET,
|
|
34
|
+
"description": policy_module.explain(name),
|
|
35
|
+
"expression": expression,
|
|
36
|
+
}
|
|
37
|
+
for name, expression in policy_module.PRESETS.items()
|
|
38
|
+
]
|
|
39
|
+
if output is OutputFormat.JSON:
|
|
40
|
+
emit_json(payload)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
lines: list[str] = []
|
|
44
|
+
for entry in payload:
|
|
45
|
+
marker = " (default)" if entry["default"] else ""
|
|
46
|
+
lines.append(f"{entry['name']}{marker}")
|
|
47
|
+
lines.append(f" {entry['description']}")
|
|
48
|
+
lines.append(f" {entry['expression']}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
lines.append("An expression evaluates to true when the build must fail.")
|
|
51
|
+
emit("\n".join(lines) + "\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("test")
|
|
55
|
+
def test(
|
|
56
|
+
policy: Annotated[
|
|
57
|
+
str,
|
|
58
|
+
typer.Argument(help=("A preset name, a CEL expression, or a path to a file holding one.")),
|
|
59
|
+
],
|
|
60
|
+
results_file: Annotated[
|
|
61
|
+
Path,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--scan",
|
|
64
|
+
exists=True,
|
|
65
|
+
dir_okay=False,
|
|
66
|
+
help="JSON output of a scan to evaluate the policy against.",
|
|
67
|
+
),
|
|
68
|
+
],
|
|
69
|
+
output: Annotated[
|
|
70
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
71
|
+
] = OutputFormat.TABLE,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Evaluate a policy against a scan that already ran.
|
|
74
|
+
|
|
75
|
+
Nothing is re-scanned, so this is fast and gives the same answer CI will:
|
|
76
|
+
the policy reads only what the scan concluded.
|
|
77
|
+
|
|
78
|
+
Exits 1 when the policy fails, 3 when the policy itself cannot be
|
|
79
|
+
compiled. A broken policy is never reported as a passing one.
|
|
80
|
+
"""
|
|
81
|
+
source = _read_policy(policy)
|
|
82
|
+
try:
|
|
83
|
+
compiled = policy_module.compile_policy(source)
|
|
84
|
+
except policy_module.PolicyError as exc:
|
|
85
|
+
fail_config(str(exc))
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
results = ScanResults.model_validate_json(results_file.read_text(encoding="utf-8"))
|
|
89
|
+
except (OSError, ValueError) as exc:
|
|
90
|
+
fail(f"{results_file} is not a vexpath scan result: {exc}")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
outcome = compiled.evaluate(results)
|
|
94
|
+
except policy_module.PolicyError as exc:
|
|
95
|
+
fail_config(str(exc))
|
|
96
|
+
|
|
97
|
+
if output is OutputFormat.JSON:
|
|
98
|
+
emit_json(outcome.model_dump(mode="json"))
|
|
99
|
+
else:
|
|
100
|
+
emit(_render(compiled, outcome, len(results.findings)))
|
|
101
|
+
|
|
102
|
+
if not outcome.passed:
|
|
103
|
+
raise typer.Exit(code=int(ExitCode.POLICY_FAILED))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_policy(source: str) -> str:
|
|
107
|
+
"""Treat the argument as a file when it names one, else as a policy.
|
|
108
|
+
|
|
109
|
+
A preset name wins over a file of the same name, because the presets are a
|
|
110
|
+
closed vocabulary and a file called ``balanced`` sitting in a working
|
|
111
|
+
directory should not silently change what the default means.
|
|
112
|
+
"""
|
|
113
|
+
if source in policy_module.PRESETS:
|
|
114
|
+
return source
|
|
115
|
+
candidate = Path(source)
|
|
116
|
+
if candidate.is_file():
|
|
117
|
+
try:
|
|
118
|
+
return candidate.read_text(encoding="utf-8").strip()
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
fail_config(f"{candidate} could not be read: {exc}")
|
|
121
|
+
return source
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render(compiled: policy_module.CompiledPolicy, outcome: object, finding_count: int) -> str:
|
|
125
|
+
from vexpath_core.models.results import PolicyResult
|
|
126
|
+
|
|
127
|
+
assert isinstance(outcome, PolicyResult) # narrowed for the type checker
|
|
128
|
+
lines = [
|
|
129
|
+
f"policy {compiled.name}",
|
|
130
|
+
f"expression {compiled.expression}",
|
|
131
|
+
f"findings {finding_count}",
|
|
132
|
+
f"result {'PASS' if outcome.passed else 'FAIL'}",
|
|
133
|
+
]
|
|
134
|
+
if outcome.violations:
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append("Why it failed")
|
|
137
|
+
for violation in outcome.violations:
|
|
138
|
+
lines.append(f" {violation.message}")
|
|
139
|
+
if violation.component:
|
|
140
|
+
lines.append(f" {violation.component}")
|
|
141
|
+
return "\n".join(lines) + "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("explain")
|
|
145
|
+
def explain(
|
|
146
|
+
policy: Annotated[str, typer.Argument(help="A preset name or a CEL expression.")],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Show what a policy would gate on, without running it.
|
|
149
|
+
|
|
150
|
+
Useful for checking that an expression compiles before committing it, and
|
|
151
|
+
for seeing which preset an inherited configuration actually resolved to.
|
|
152
|
+
"""
|
|
153
|
+
source = _read_policy(policy)
|
|
154
|
+
try:
|
|
155
|
+
compiled = policy_module.compile_policy(source)
|
|
156
|
+
except policy_module.PolicyError as exc:
|
|
157
|
+
fail_config(str(exc))
|
|
158
|
+
|
|
159
|
+
description = policy_module.explain(compiled.name)
|
|
160
|
+
lines = [f"policy {compiled.name}"]
|
|
161
|
+
if description:
|
|
162
|
+
lines.append(f"intent {description}")
|
|
163
|
+
lines.append(f"expression {compiled.expression}")
|
|
164
|
+
lines.append("")
|
|
165
|
+
lines.append("True means the build fails. Available in an expression:")
|
|
166
|
+
lines.append(" findings[] advisory, component, purl, severity, severity_rank,")
|
|
167
|
+
lines.append(" cvss, cvss_v4, epss, kev, verdict, justification,")
|
|
168
|
+
lines.append(" fixed_in, has_fix, age_days, scope, direct, suppressed,")
|
|
169
|
+
lines.append(" reach{analysed, status, reason, confidence,")
|
|
170
|
+
lines.append(" confidence_rank, reachable, path_length,")
|
|
171
|
+
lines.append(" via_dynamic_edges}")
|
|
172
|
+
lines.append(" scan mode, git_ref, git_sha, reproducible, snapshot")
|
|
173
|
+
lines.append(" project slug")
|
|
174
|
+
lines.append(" counts affected, not_affected, under_investigation, fixed")
|
|
175
|
+
emit("\n".join(lines) + "\n")
|
|
176
|
+
note("compiled successfully")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""The ``vexpath reach`` command: explain one advisory against one project.
|
|
2
|
+
|
|
3
|
+
``vexpath scan`` says what to act on. This says why, for a single advisory,
|
|
4
|
+
with every piece of evidence the verdict rests on. It is the command someone
|
|
5
|
+
runs when they disagree with the scan, which is exactly when a security tool
|
|
6
|
+
has to be able to show its work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from vexpath.exit_codes import ExitCode
|
|
17
|
+
from vexpath.output import OutputFormat, emit, emit_json, fail
|
|
18
|
+
from vexpath.sources import SNAPSHOT_ENV, advisory_source, close_source
|
|
19
|
+
from vexpath_core import report
|
|
20
|
+
from vexpath_core.advisory.source import AdvisorySourceError
|
|
21
|
+
from vexpath_core.enums import Verdict
|
|
22
|
+
from vexpath_core.sbom import LockfileError, UnknownSbomFormatError
|
|
23
|
+
from vexpath_core.sbom.cyclonedx import CycloneDxError
|
|
24
|
+
from vexpath_core.sbom.spdx import SpdxError
|
|
25
|
+
from vexpath_core.scan import ScanOptions
|
|
26
|
+
from vexpath_core.triage import TriageError, triage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def command(
|
|
30
|
+
advisory: Annotated[
|
|
31
|
+
str,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--advisory",
|
|
34
|
+
"-a",
|
|
35
|
+
help="The advisory to explain. Any of its identifiers works.",
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
path: Annotated[
|
|
39
|
+
Path,
|
|
40
|
+
typer.Argument(exists=True, file_okay=False, help="Project directory to analyse."),
|
|
41
|
+
] = Path(),
|
|
42
|
+
symbol: Annotated[
|
|
43
|
+
str | None,
|
|
44
|
+
typer.Option(
|
|
45
|
+
"--symbol",
|
|
46
|
+
"-s",
|
|
47
|
+
help=(
|
|
48
|
+
"Comma-separated symbols to search for in addition to the "
|
|
49
|
+
"advisory's own, as module.path:function or a dotted name. "
|
|
50
|
+
"Useful when an advisory carries no fix commit. Recorded as "
|
|
51
|
+
"manually supplied, which caps the report's confidence."
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
snapshot: Annotated[
|
|
56
|
+
Path | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--snapshot",
|
|
59
|
+
exists=True,
|
|
60
|
+
file_okay=False,
|
|
61
|
+
help=(f"Read advisories from this local snapshot. Defaults to ${SNAPSHOT_ENV}."),
|
|
62
|
+
),
|
|
63
|
+
] = None,
|
|
64
|
+
entry_point: Annotated[
|
|
65
|
+
str | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--entry-point",
|
|
68
|
+
"-e",
|
|
69
|
+
help=(
|
|
70
|
+
"Comma-separated entry points to start from, as "
|
|
71
|
+
"module.path:function. Nothing beats being told how the "
|
|
72
|
+
"application starts."
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
] = None,
|
|
76
|
+
environment: Annotated[
|
|
77
|
+
Path | None,
|
|
78
|
+
typer.Option(
|
|
79
|
+
"--environment",
|
|
80
|
+
exists=True,
|
|
81
|
+
file_okay=False,
|
|
82
|
+
help=(
|
|
83
|
+
"Virtual environment holding the installed dependencies. "
|
|
84
|
+
"Defaults to the one this command is running in."
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
] = None,
|
|
88
|
+
lockfile: Annotated[
|
|
89
|
+
Path | None,
|
|
90
|
+
typer.Option(
|
|
91
|
+
"--lockfile",
|
|
92
|
+
exists=True,
|
|
93
|
+
dir_okay=False,
|
|
94
|
+
help="Use this lockfile instead of searching the directory.",
|
|
95
|
+
),
|
|
96
|
+
] = None,
|
|
97
|
+
download_missing: Annotated[
|
|
98
|
+
bool,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--download-missing",
|
|
101
|
+
help=(
|
|
102
|
+
"Fetch a dependency's source from the package index when it is "
|
|
103
|
+
"not installed. Reaches the network. Without it, a scan of a "
|
|
104
|
+
"lockfile alone can confirm findings but never clear them."
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
] = False,
|
|
108
|
+
no_cache: Annotated[
|
|
109
|
+
bool,
|
|
110
|
+
typer.Option("--no-cache", help="Rebuild dependency graphs instead of reusing them."),
|
|
111
|
+
] = False,
|
|
112
|
+
output_format: Annotated[
|
|
113
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
114
|
+
] = OutputFormat.TABLE,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Explain one advisory's reachability verdict for this project.
|
|
117
|
+
|
|
118
|
+
Prints the symbols searched for and where they came from, which of them
|
|
119
|
+
exist in the installed version, which entry points reach them with the
|
|
120
|
+
call path for each, and the graph the answer rests on.
|
|
121
|
+
"""
|
|
122
|
+
source = advisory_source(snapshot, result_noun="explanation")
|
|
123
|
+
options = ScanOptions(
|
|
124
|
+
lockfile=lockfile,
|
|
125
|
+
entry_points=_split(entry_point),
|
|
126
|
+
environment=environment,
|
|
127
|
+
use_cache=not no_cache,
|
|
128
|
+
download_missing=download_missing,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
result = triage(
|
|
133
|
+
path,
|
|
134
|
+
advisory,
|
|
135
|
+
source,
|
|
136
|
+
options=options,
|
|
137
|
+
extra_symbols=_split(symbol),
|
|
138
|
+
)
|
|
139
|
+
except TriageError as exc:
|
|
140
|
+
fail(str(exc))
|
|
141
|
+
except (LockfileError, UnknownSbomFormatError, CycloneDxError, SpdxError) as exc:
|
|
142
|
+
fail(str(exc))
|
|
143
|
+
except AdvisorySourceError as exc:
|
|
144
|
+
fail(
|
|
145
|
+
f"{exc}. To work without network access, download a snapshot with "
|
|
146
|
+
f"'vexpath advisory sync' and pass --snapshot."
|
|
147
|
+
)
|
|
148
|
+
finally:
|
|
149
|
+
close_source(source)
|
|
150
|
+
|
|
151
|
+
if output_format is OutputFormat.JSON:
|
|
152
|
+
emit_json(_payload(result))
|
|
153
|
+
else:
|
|
154
|
+
emit(report.render_triage(result))
|
|
155
|
+
|
|
156
|
+
# A reachable verdict is the one a caller may want to act on, so it is
|
|
157
|
+
# distinguishable without parsing the output. An admitted unknown is not an
|
|
158
|
+
# analysis failure, so it stays at zero.
|
|
159
|
+
if result.verdict is Verdict.AFFECTED:
|
|
160
|
+
raise typer.Exit(code=int(ExitCode.POLICY_FAILED))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _split(raw: str | None) -> tuple[str, ...]:
|
|
164
|
+
return tuple(entry.strip() for entry in (raw or "").split(",") if entry.strip())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _payload(result: object) -> dict[str, object]:
|
|
168
|
+
"""Serialise a triage report.
|
|
169
|
+
|
|
170
|
+
Written out by hand rather than dumped from the dataclass, because this is
|
|
171
|
+
a contract with whatever consumes it and should not change shape every time
|
|
172
|
+
an internal field is renamed.
|
|
173
|
+
"""
|
|
174
|
+
from vexpath_core.triage import TriageReport
|
|
175
|
+
|
|
176
|
+
assert isinstance(result, TriageReport) # narrowed for the type checker
|
|
177
|
+
reach = result.reach
|
|
178
|
+
return {
|
|
179
|
+
"advisory": {
|
|
180
|
+
"id": result.advisory_id,
|
|
181
|
+
"summary": result.summary,
|
|
182
|
+
"severity": result.severity.value,
|
|
183
|
+
"aliases": list(result.aliases),
|
|
184
|
+
},
|
|
185
|
+
"component": {
|
|
186
|
+
"purl": result.component.purl,
|
|
187
|
+
"ecosystem": result.component.ecosystem,
|
|
188
|
+
"name": result.component.name,
|
|
189
|
+
"version": result.component.version,
|
|
190
|
+
"direct": result.component.direct,
|
|
191
|
+
},
|
|
192
|
+
"version_affected": result.version_affected,
|
|
193
|
+
"verdict": result.verdict.value,
|
|
194
|
+
"advisory_snapshot_id": result.snapshot_id,
|
|
195
|
+
"reproducible": result.is_reproducible,
|
|
196
|
+
"reach": reach.model_dump(mode="json") if reach is not None else None,
|
|
197
|
+
"symbols": [
|
|
198
|
+
{
|
|
199
|
+
"symbol": probe.symbol,
|
|
200
|
+
"kind": probe.kind.value,
|
|
201
|
+
"source": probe.source.value,
|
|
202
|
+
"confidence": probe.confidence.value,
|
|
203
|
+
"defined": probe.defined,
|
|
204
|
+
"referenced": probe.referenced,
|
|
205
|
+
"status": probe.status,
|
|
206
|
+
}
|
|
207
|
+
for probe in result.symbols
|
|
208
|
+
],
|
|
209
|
+
"entry_points": {
|
|
210
|
+
"source": result.entry_point_source.value,
|
|
211
|
+
"count": result.entry_point_count,
|
|
212
|
+
"examined": [
|
|
213
|
+
{
|
|
214
|
+
"entry_point": entry.entry_point,
|
|
215
|
+
"status": entry.status,
|
|
216
|
+
"symbol": entry.symbol,
|
|
217
|
+
"path": [
|
|
218
|
+
{
|
|
219
|
+
"symbol": step.symbol,
|
|
220
|
+
"file": step.file,
|
|
221
|
+
"line": step.line,
|
|
222
|
+
"edge_kind": step.edge_kind.value,
|
|
223
|
+
}
|
|
224
|
+
for step in entry.path
|
|
225
|
+
],
|
|
226
|
+
}
|
|
227
|
+
for entry in result.entry_points
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
"graph": result.graph.model_dump(mode="json") if result.graph is not None else None,
|
|
231
|
+
"notes": list(result.notes),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["command"]
|