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
|
@@ -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"]
|
vexpath/commands/vex.py
ADDED
|
@@ -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"]
|