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.
@@ -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"]