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,104 @@
1
+ """``vexpath upload``: send a result document that already exists.
2
+
3
+ ``vexpath scan --upload`` covers the ordinary case. This covers the one that
4
+ turns up in real pipelines: the scan ran in one job and the upload has to
5
+ happen in another, because the first job had no credential, or because the
6
+ result is an artefact several steps consume.
7
+
8
+ Re-running the scan to upload it would be the wrong answer. The analysis is
9
+ the expensive part and its result is a document; a second run against a
10
+ different advisory snapshot would not even produce the same verdicts, so what
11
+ got uploaded would not be what the earlier job reported.
12
+
13
+ The document is validated here, against the same model the analysis produced,
14
+ before anything is sent. A hand-edited or truncated artefact then fails with a
15
+ message about the file, rather than as a 422 from the platform describing a
16
+ field nobody chose.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Annotated
24
+
25
+ import typer
26
+ from pydantic import ValidationError
27
+
28
+ from vexpath.commands import auth
29
+ from vexpath.output import OutputFormat, emit_json, fail, fail_config, note
30
+ from vexpath_core.models.results import ScanResults
31
+
32
+
33
+ def command(
34
+ results: Annotated[
35
+ Path,
36
+ typer.Argument(
37
+ exists=True,
38
+ dir_okay=False,
39
+ help="A document written by 'vexpath scan --format json'.",
40
+ ),
41
+ ],
42
+ project: Annotated[
43
+ str,
44
+ typer.Option("--project", help="Slug of the project to upload to."),
45
+ ],
46
+ output_format: Annotated[
47
+ OutputFormat, typer.Option("--format", "-f", help="Output format.")
48
+ ] = OutputFormat.TABLE,
49
+ ) -> None:
50
+ """Upload a scan result produced earlier.
51
+
52
+ Validates the document before sending it, so a broken artefact is
53
+ described in terms of the file rather than as a rejection from the server.
54
+ """
55
+ credential = auth.credential_or_exit()
56
+ document = _load(results)
57
+ stored = auth.upload_results(credential, project, document)
58
+
59
+ if output_format is OutputFormat.JSON:
60
+ emit_json(stored)
61
+ return
62
+ note(f"uploaded {results} to {credential.api_url} as scan {stored.get('id', 'unknown')}")
63
+
64
+
65
+ def _load(path: Path) -> dict[str, object]:
66
+ """Read and validate the result document.
67
+
68
+ Validated through ``ScanResults`` rather than merely parsed as JSON: the
69
+ platform validates with the same model, so anything accepted here is
70
+ accepted there, and the mismatch cannot show up as a confusing 422 after
71
+ the upload has been attempted.
72
+ """
73
+ try:
74
+ raw = path.read_text(encoding="utf-8")
75
+ except OSError as exc:
76
+ fail(f"{path} could not be read: {exc}")
77
+
78
+ try:
79
+ parsed = json.loads(raw)
80
+ except json.JSONDecodeError as exc:
81
+ fail_config(
82
+ f"{path} is not readable JSON: {exc}. It should be the output of "
83
+ f"'vexpath scan --format json'."
84
+ )
85
+
86
+ if not isinstance(parsed, dict):
87
+ fail_config(f"{path} holds {type(parsed).__name__}, not a scan result document.")
88
+
89
+ try:
90
+ ScanResults.model_validate(parsed)
91
+ except ValidationError as exc:
92
+ fields = ", ".join(
93
+ ".".join(str(part) for part in error["loc"]) for error in exc.errors()[:5]
94
+ )
95
+ fail_config(
96
+ f"{path} is not a scan result this build understands. Problem "
97
+ f"fields: {fields or 'unknown'}. Re-run the scan with this version "
98
+ f"of vexpath."
99
+ )
100
+
101
+ return parsed
102
+
103
+
104
+ __all__ = ["command"]
@@ -0,0 +1,249 @@
1
+ """The ``vexpath vex`` command group: publish the analysis as OpenVEX.
2
+
3
+ A scan's verdicts are an opinion until they are in a form someone else's tools
4
+ read. This is that form.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
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 emit, fail, fail_config, note
17
+ from vexpath.sources import advisory_source, close_source
18
+ from vexpath_core import cdxvex, vex
19
+ from vexpath_core.advisory.source import AdvisorySourceError
20
+ from vexpath_core.enums import ComponentScope
21
+ from vexpath_core.models.results import ScanResults
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.scan import scan as run_scan
27
+
28
+ app = typer.Typer(help="Publish scan conclusions as VEX.", no_args_is_help=True)
29
+
30
+
31
+ @app.command("export")
32
+ def export(
33
+ path: Annotated[
34
+ Path,
35
+ typer.Argument(exists=True, file_okay=False, help="Project directory to scan."),
36
+ ] = Path(),
37
+ author: Annotated[
38
+ str | None,
39
+ typer.Option(
40
+ "--author",
41
+ "-a",
42
+ help=(
43
+ "Who is making these statements, as an organisation or a person. "
44
+ "Required: a VEX document nobody can attribute is one nobody can "
45
+ "decide whether to believe."
46
+ ),
47
+ ),
48
+ ] = None,
49
+ product: Annotated[
50
+ str | None,
51
+ typer.Option(
52
+ "--product",
53
+ "-p",
54
+ help=(
55
+ "Identifier of the product these statements are about, ideally a "
56
+ "purl. Defaults to the project slug. This is your product, not "
57
+ "the vulnerable dependency: the dependency is recorded as a "
58
+ "subcomponent."
59
+ ),
60
+ ),
61
+ ] = None,
62
+ results_file: Annotated[
63
+ Path | None,
64
+ typer.Option(
65
+ "--results",
66
+ exists=True,
67
+ dir_okay=False,
68
+ help=(
69
+ "Use the JSON output of an earlier scan instead of scanning "
70
+ "again. The verdicts are the scan's, so re-deriving them would "
71
+ "risk a document that disagrees with the report it came from."
72
+ ),
73
+ ),
74
+ ] = None,
75
+ output: Annotated[
76
+ Path | None,
77
+ typer.Option("--output", "-o", dir_okay=False, help="Write here instead of stdout."),
78
+ ] = None,
79
+ snapshot: Annotated[
80
+ Path | None,
81
+ typer.Option("--snapshot", exists=True, file_okay=False, help="Advisory snapshot to use."),
82
+ ] = None,
83
+ environment: Annotated[
84
+ Path | None,
85
+ typer.Option(
86
+ "--environment",
87
+ exists=True,
88
+ file_okay=False,
89
+ help="Virtual environment holding the installed dependencies.",
90
+ ),
91
+ ] = None,
92
+ download_missing: Annotated[
93
+ bool,
94
+ typer.Option(
95
+ "--download-missing",
96
+ help="Fetch a dependency's source from the index when it is not installed.",
97
+ ),
98
+ ] = False,
99
+ vex_format: Annotated[
100
+ str,
101
+ typer.Option(
102
+ "--format",
103
+ "-f",
104
+ help=(
105
+ "'openvex' or 'cyclonedx'. The choice is the consumer's: a "
106
+ "conclusion in a format their toolchain cannot load is one they "
107
+ "do not have."
108
+ ),
109
+ ),
110
+ ] = "openvex",
111
+ document_version: Annotated[
112
+ int,
113
+ typer.Option(
114
+ "--document-version",
115
+ min=1,
116
+ help=(
117
+ "Version of this document for this product. Increment it when "
118
+ "publishing a document that supersedes an earlier one."
119
+ ),
120
+ ),
121
+ ] = 1,
122
+ skip_open: Annotated[
123
+ bool,
124
+ typer.Option(
125
+ "--skip-under-investigation",
126
+ help=(
127
+ "Leave out findings still being analysed. Off by default: the "
128
+ "absence of a statement reads as 'not an issue', and that is not "
129
+ "what under investigation means."
130
+ ),
131
+ ),
132
+ ] = False,
133
+ ignore_dev: Annotated[
134
+ bool,
135
+ typer.Option("--ignore-dev", help="Exclude development dependencies."),
136
+ ] = False,
137
+ ) -> None:
138
+ """Write an OpenVEX 0.2.0 document for this project.
139
+
140
+ The document states, for every advisory that matches a dependency, whether
141
+ the vulnerable code can run in your product, with the evidence for each
142
+ answer. A finding is stated as not affected only where reachability proved
143
+ the code is not in the execute path, which is the one justification this
144
+ analysis earns.
145
+
146
+ Byte-identical for identical results, so the document can be diffed between
147
+ scans and its hash is worth attesting.
148
+ """
149
+ if author is None or not author.strip():
150
+ fail(
151
+ "--author is required. A VEX document is believed on the strength of "
152
+ "who stands behind it, so it cannot be published anonymously."
153
+ )
154
+
155
+ results = (
156
+ _load_results(results_file)
157
+ if results_file is not None
158
+ else _scan(
159
+ path, snapshot, environment, download_missing=download_missing, ignore_dev=ignore_dev
160
+ )
161
+ )
162
+
163
+ if vex_format not in {"openvex", "cyclonedx"}:
164
+ fail_config(f"--format {vex_format!r} is not 'openvex' or 'cyclonedx'")
165
+
166
+ try:
167
+ if vex_format == "cyclonedx":
168
+ rendered = cdxvex.render_json(
169
+ results, cdxvex.VexMetadata(author=author.strip(), product_id=product)
170
+ )
171
+ # Validated against the official schema before it leaves, because a
172
+ # document a consumer's tooling rejects is worse than none: they
173
+ # have to work out whether the rejection or the content is wrong.
174
+ cdxvex.validate(rendered)
175
+ else:
176
+ rendered = vex.render_json(
177
+ results,
178
+ vex.VexIdentity(author=author.strip(), product_id=product),
179
+ version=document_version,
180
+ include_under_investigation=not skip_open,
181
+ )
182
+ except (vex.VexError, cdxvex.CycloneDxVexError) as exc:
183
+ fail(str(exc))
184
+
185
+ document = json.loads(rendered)
186
+ if vex_format == "cyclonedx":
187
+ counts = cdxvex.state_counts(document)
188
+ note(
189
+ f"{len(document['vulnerabilities'])} vulnerability record(s): "
190
+ + ", ".join(f"{count} {state}" for state, count in counts.items())
191
+ )
192
+ note(f"serial {document['serialNumber']}")
193
+ note("validated against the official CycloneDX 1.6 schema")
194
+ else:
195
+ counts = vex.statement_counts(document)
196
+ note(
197
+ f"{len(document['statements'])} statement(s): "
198
+ + ", ".join(f"{count} {status}" for status, count in counts.items())
199
+ )
200
+ note(f"document {document['@id']}")
201
+ note(f"sha256 {vex.document_sha256(rendered)}")
202
+
203
+ if output is None:
204
+ emit(rendered)
205
+ return
206
+ output.parent.mkdir(parents=True, exist_ok=True)
207
+ output.write_text(rendered, encoding="utf-8", newline="\n")
208
+ note(f"written to {output}")
209
+
210
+
211
+ def _load_results(path: Path) -> ScanResults:
212
+ try:
213
+ return ScanResults.model_validate_json(path.read_text(encoding="utf-8"))
214
+ except (OSError, ValueError) as exc:
215
+ fail(f"{path} is not a vexpath scan result: {exc}")
216
+ raise # unreachable; fail exits
217
+
218
+
219
+ def _scan(
220
+ path: Path,
221
+ snapshot: Path | None,
222
+ environment: Path | None,
223
+ *,
224
+ download_missing: bool,
225
+ ignore_dev: bool,
226
+ ) -> ScanResults:
227
+ source = advisory_source(snapshot, result_noun="VEX document")
228
+ scopes = (
229
+ frozenset({ComponentScope.RUNTIME, ComponentScope.OPTIONAL})
230
+ if ignore_dev
231
+ else frozenset(ComponentScope)
232
+ )
233
+ options = ScanOptions(
234
+ include_scopes=scopes,
235
+ environment=environment,
236
+ download_missing=download_missing,
237
+ )
238
+ try:
239
+ return run_scan(path, source, options=options)
240
+ except (LockfileError, UnknownSbomFormatError, CycloneDxError, SpdxError) as exc:
241
+ fail(str(exc))
242
+ except AdvisorySourceError as exc:
243
+ fail(f"{exc}. Download a snapshot with 'vexpath advisory sync' and pass --snapshot.")
244
+ finally:
245
+ close_source(source)
246
+ raise typer.Exit(code=int(ExitCode.ANALYSIS_ERROR)) # unreachable
247
+
248
+
249
+ __all__ = ["app"]
vexpath/credentials.py ADDED
@@ -0,0 +1,213 @@
1
+ """Where the command-line tool keeps an API key, and how it finds one.
2
+
3
+ A key is a bearer credential: whoever holds it is the organisation. So the two
4
+ decisions here are about not leaking it and not surprising anyone about which
5
+ one is in use.
6
+
7
+ **The environment wins over the stored file.** CI sets ``VEXPATH_API_KEY`` from
8
+ a secret, and a stored file on a shared build machine must not silently
9
+ override it. A developer who has logged in locally and then exports a key for a
10
+ one-off command gets the exported one, which is what they asked for.
11
+
12
+ **The file is written with no permissions for anybody else**, and the tool says
13
+ where it is. A credential whose location is a mystery is one nobody rotates.
14
+
15
+ **Nothing prints the key.** Not in a confirmation, not in a log line, not in an
16
+ error. The visible prefix is enough to say which key is in use, and is what
17
+ gets shown instead.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import stat
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from vexpath_core.keyformat import KEY_PREFIX, display_prefix, looks_like_key
29
+
30
+ #: Read before the stored file. CI sets this from a secret, and a stored file
31
+ #: must not override it.
32
+ KEY_ENVIRONMENT_VARIABLE = "VEXPATH_API_KEY"
33
+
34
+ #: Where the platform lives, overridable for a self-hosted install.
35
+ URL_ENVIRONMENT_VARIABLE = "VEXPATH_API_URL"
36
+
37
+ DEFAULT_API_URL = "https://api.vexpath.dev"
38
+
39
+ #: Overrides the whole configuration directory, which is what a test needs and
40
+ #: what a container with a read-only home directory needs too.
41
+ HOME_ENVIRONMENT_VARIABLE = "VEXPATH_CONFIG_DIR"
42
+
43
+
44
+ class CredentialError(Exception):
45
+ """Raised when a credential cannot be stored or read."""
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Credential:
50
+ """A key and where it came from.
51
+
52
+ The source is carried because "which key am I using" is the question
53
+ behind most authentication confusion, and the answer is usually that one
54
+ place overrode another.
55
+ """
56
+
57
+ secret: str
58
+ api_url: str
59
+ #: ``environment`` or ``file``.
60
+ source: str
61
+
62
+ @property
63
+ def prefix(self) -> str:
64
+ """What is safe to print."""
65
+ return display_prefix(self.secret)
66
+
67
+ def __repr__(self) -> str:
68
+ """Never render the secret.
69
+
70
+ A credential that appears in a traceback has leaked, and tracebacks
71
+ are produced exactly when attention is elsewhere.
72
+ """
73
+ return f"Credential(prefix={self.prefix!r}, source={self.source!r})"
74
+
75
+
76
+ def config_directory() -> Path:
77
+ """Where the credential file lives.
78
+
79
+ Follows the platform convention, and respects an override so a container
80
+ with a read-only home directory, or a test, can point it elsewhere.
81
+ """
82
+ override = os.environ.get(HOME_ENVIRONMENT_VARIABLE)
83
+ if override:
84
+ return Path(override)
85
+ if os.name == "nt":
86
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
87
+ return Path(base) / "vexpath"
88
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
89
+ return Path(base) / "vexpath"
90
+
91
+
92
+ def credential_path() -> Path:
93
+ return config_directory() / "credentials.json"
94
+
95
+
96
+ def load() -> Credential | None:
97
+ """The credential to use, or None when there is none.
98
+
99
+ The environment is checked first and deliberately: a stored file on a
100
+ shared build machine must not override a key CI supplied from a secret.
101
+ """
102
+ from_environment = os.environ.get(KEY_ENVIRONMENT_VARIABLE, "").strip()
103
+ if from_environment:
104
+ return Credential(secret=from_environment, api_url=_api_url(None), source="environment")
105
+
106
+ path = credential_path()
107
+ if not path.is_file():
108
+ return None
109
+ try:
110
+ stored = json.loads(path.read_text(encoding="utf-8"))
111
+ except (OSError, json.JSONDecodeError) as exc:
112
+ msg = (
113
+ f"{path} is not readable as a credential file: {exc}. Delete it and "
114
+ f"run 'vexpath login' again."
115
+ )
116
+ raise CredentialError(msg) from exc
117
+
118
+ secret = str(stored.get("api_key") or "").strip()
119
+ if not secret:
120
+ return None
121
+ return Credential(secret=secret, api_url=_api_url(stored.get("api_url")), source="file")
122
+
123
+
124
+ def _api_url(stored: object) -> str:
125
+ """Which platform to talk to.
126
+
127
+ The environment wins here too, so a one-off command against a staging
128
+ deployment does not require editing a file and remembering to change it
129
+ back.
130
+ """
131
+ from_environment = os.environ.get(URL_ENVIRONMENT_VARIABLE, "").strip()
132
+ if from_environment:
133
+ return from_environment.rstrip("/")
134
+ if isinstance(stored, str) and stored.strip():
135
+ return stored.strip().rstrip("/")
136
+ return DEFAULT_API_URL
137
+
138
+
139
+ def store(secret: str, api_url: str) -> Path:
140
+ """Write a credential, readable only by its owner.
141
+
142
+ Raises:
143
+ CredentialError: if the key is not shaped like one, or the file cannot
144
+ be written. Storing something that is not a key would turn a
145
+ typo into an authentication failure on the next command, with
146
+ nothing to connect the two.
147
+ """
148
+ candidate = secret.strip()
149
+ if not looks_like_key(candidate):
150
+ msg = (
151
+ f"that does not look like an API key: one starts with "
152
+ f"{KEY_PREFIX!r} and is created in the dashboard"
153
+ )
154
+ raise CredentialError(msg)
155
+
156
+ directory = config_directory()
157
+ path = credential_path()
158
+ try:
159
+ directory.mkdir(parents=True, exist_ok=True)
160
+ # Written through a temporary file with restrictive permissions set
161
+ # before any content, so the secret is never briefly world-readable.
162
+ temporary = path.with_suffix(".partial")
163
+ temporary.write_text(
164
+ json.dumps({"api_key": candidate, "api_url": api_url.rstrip("/")}, indent=2) + "\n",
165
+ encoding="utf-8",
166
+ )
167
+ temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
168
+ temporary.replace(path)
169
+ except OSError as exc:
170
+ msg = f"the credential could not be written to {path}: {exc}"
171
+ raise CredentialError(msg) from exc
172
+
173
+ return path
174
+
175
+
176
+ def display_prefix_of(secret: str) -> str:
177
+ """What is safe to print for a key not yet stored.
178
+
179
+ Exists so no caller is tempted to slice the secret itself and get the
180
+ boundary wrong.
181
+ """
182
+ return display_prefix(secret)
183
+
184
+
185
+ def forget() -> bool:
186
+ """Remove a stored credential.
187
+
188
+ Returns True when there was one. Does not touch the environment variable,
189
+ and the caller says so: a user who logs out and still has a key exported
190
+ is still authenticated, and finding that out later is worse than being
191
+ told now.
192
+ """
193
+ path = credential_path()
194
+ if not path.is_file():
195
+ return False
196
+ path.unlink()
197
+ return True
198
+
199
+
200
+ __all__ = [
201
+ "DEFAULT_API_URL",
202
+ "HOME_ENVIRONMENT_VARIABLE",
203
+ "KEY_ENVIRONMENT_VARIABLE",
204
+ "URL_ENVIRONMENT_VARIABLE",
205
+ "Credential",
206
+ "CredentialError",
207
+ "config_directory",
208
+ "credential_path",
209
+ "display_prefix_of",
210
+ "forget",
211
+ "load",
212
+ "store",
213
+ ]
vexpath/exit_codes.py ADDED
@@ -0,0 +1,28 @@
1
+ """Process exit codes.
2
+
3
+ These are a published contract with CI systems, so they are defined in one
4
+ place and must not be reused for new meanings. The important distinction is
5
+ between code 1 and code 2: a build that fails because the code is vulnerable
6
+ must never look like a build that fails because the scanner broke.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import IntEnum
12
+
13
+
14
+ class ExitCode(IntEnum):
15
+ """Exit status of the ``vexpath`` process."""
16
+
17
+ #: Analysis completed and the policy passed.
18
+ OK = 0
19
+ #: Analysis completed and the policy failed.
20
+ POLICY_FAILED = 1
21
+ #: Analysis could not complete: parse failure, missing lockfile, upstream
22
+ #: outage. The verdicts are unknown, not clean.
23
+ ANALYSIS_ERROR = 2
24
+ #: Bad configuration: unparsable policy, malformed .vexpath.yml, invalid flags.
25
+ CONFIG_ERROR = 3
26
+
27
+
28
+ __all__ = ["ExitCode"]