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,189 @@
|
|
|
1
|
+
"""``vexpath report``: render a scan that already ran, in another format.
|
|
2
|
+
|
|
3
|
+
The analysis is the expensive part. A cold scan of a real project takes minutes
|
|
4
|
+
because it downloads and parses every dependency's source; the result is a
|
|
5
|
+
document. Re-running it to get the same conclusions as SARIF rather than as a
|
|
6
|
+
table is pure waste, and worse than waste: a second run against a corpus that
|
|
7
|
+
has moved on produces *different* verdicts, so the SARIF a pipeline uploads
|
|
8
|
+
would not match the table a developer read.
|
|
9
|
+
|
|
10
|
+
So this reads the document and re-renders it. Nothing is re-analysed, nothing
|
|
11
|
+
can disagree. It exists because the GitHub Action needs the same scan three
|
|
12
|
+
ways -- a table in the log, markdown in the step summary, SARIF for code
|
|
13
|
+
scanning -- and three scans would be three answers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from vexpath.output import OutputFormat, emit, fail, note
|
|
24
|
+
from vexpath_core import report as renderers
|
|
25
|
+
from vexpath_core.models.results import ScanResults
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def command(
|
|
29
|
+
results_file: Annotated[
|
|
30
|
+
Path,
|
|
31
|
+
typer.Argument(
|
|
32
|
+
exists=True,
|
|
33
|
+
dir_okay=False,
|
|
34
|
+
help="A document written by 'vexpath scan --format json'.",
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
output_format: Annotated[
|
|
38
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
39
|
+
] = OutputFormat.TABLE,
|
|
40
|
+
output: Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--output",
|
|
44
|
+
"-o",
|
|
45
|
+
dir_okay=False,
|
|
46
|
+
help="Write here instead of to standard output.",
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
show_cleared: Annotated[
|
|
50
|
+
bool,
|
|
51
|
+
typer.Option("--show-cleared", help="List findings that were cleared, too."),
|
|
52
|
+
] = False,
|
|
53
|
+
summary: Annotated[
|
|
54
|
+
bool,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--summary",
|
|
57
|
+
help=(
|
|
58
|
+
"Print the counts as key=value lines instead of a report, for "
|
|
59
|
+
"a CI step to read. The counts are computed by the same code "
|
|
60
|
+
"that renders every other format, so a workflow cannot "
|
|
61
|
+
"disagree with the report beside it."
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
] = False,
|
|
65
|
+
lockfile: Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--lockfile",
|
|
69
|
+
help=(
|
|
70
|
+
"Path to record as the location of each SARIF result. Code "
|
|
71
|
+
"scanning anchors an alert to a file, and a lockfile is the "
|
|
72
|
+
"file a dependency finding is actually about."
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Re-render a scan result without re-running the analysis.
|
|
78
|
+
|
|
79
|
+
Exits 2 if the document is not one this build understands. It is validated
|
|
80
|
+
through the same model the analysis produced, so a hand-edited file is
|
|
81
|
+
refused here rather than rendering into a report that looks authoritative.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
results = ScanResults.model_validate_json(results_file.read_text(encoding="utf-8"))
|
|
85
|
+
except (OSError, ValueError) as exc:
|
|
86
|
+
fail(f"{results_file} is not a vexpath scan result: {exc}")
|
|
87
|
+
|
|
88
|
+
if summary:
|
|
89
|
+
emit(_summary(results))
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
rendered = _render(
|
|
93
|
+
results,
|
|
94
|
+
output_format,
|
|
95
|
+
show_cleared=show_cleared,
|
|
96
|
+
lockfile=lockfile or _lockfile_of(results),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if output is None:
|
|
100
|
+
emit(rendered)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
output.write_text(rendered, encoding="utf-8")
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
fail(f"{output} could not be written: {exc}")
|
|
107
|
+
# To standard error, so a caller redirecting standard output into a file
|
|
108
|
+
# does not find this line in it.
|
|
109
|
+
note(f"wrote {output}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render(
|
|
113
|
+
results: ScanResults,
|
|
114
|
+
output_format: OutputFormat,
|
|
115
|
+
*,
|
|
116
|
+
show_cleared: bool,
|
|
117
|
+
lockfile: str,
|
|
118
|
+
) -> str:
|
|
119
|
+
if output_format is OutputFormat.JSON:
|
|
120
|
+
# Re-serialised through the model rather than copied byte for byte: a
|
|
121
|
+
# document that round-trips is one this build genuinely understood.
|
|
122
|
+
import json
|
|
123
|
+
|
|
124
|
+
return json.dumps(results.model_dump(mode="json", by_alias=True), indent=2)
|
|
125
|
+
if output_format is OutputFormat.SARIF:
|
|
126
|
+
return renderers.render_sarif_json(results, lockfile_path=lockfile)
|
|
127
|
+
if output_format is OutputFormat.MARKDOWN:
|
|
128
|
+
return renderers.render_markdown(results, show_cleared=show_cleared)
|
|
129
|
+
return renderers.render_text(results, show_cleared=show_cleared)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _summary(results: ScanResults) -> str:
|
|
133
|
+
"""The counts a CI step needs, as ``key=value`` lines.
|
|
134
|
+
|
|
135
|
+
This shape so a GitHub Actions step can append it straight to
|
|
136
|
+
``$GITHUB_OUTPUT``, and any other shell can ``eval`` it. Computed here
|
|
137
|
+
rather than in YAML by whoever writes the workflow: the definitions of
|
|
138
|
+
"needs attention" and "reachable" are decisions this project has already
|
|
139
|
+
made, and a second implementation in a shell script would eventually
|
|
140
|
+
disagree with the report printed beside it.
|
|
141
|
+
"""
|
|
142
|
+
from vexpath_core.enums import Verdict
|
|
143
|
+
|
|
144
|
+
by_verdict = results.counts_by_verdict()
|
|
145
|
+
lines = [
|
|
146
|
+
f"total={len(results.findings)}",
|
|
147
|
+
f"needs_attention={len(results.findings_needing_attention())}",
|
|
148
|
+
f"cleared={by_verdict[Verdict.NOT_AFFECTED]}",
|
|
149
|
+
f"reachable={len(results.reachable_findings())}",
|
|
150
|
+
f"under_investigation={by_verdict[Verdict.UNDER_INVESTIGATION]}",
|
|
151
|
+
# Empty rather than "true" when no policy ran: a workflow must be able
|
|
152
|
+
# to tell "the gate passed" from "there was no gate".
|
|
153
|
+
f"policy_passed={'' if results.policy is None else str(results.policy.passed).lower()}",
|
|
154
|
+
f"reproducible={str(results.is_reproducible).lower()}",
|
|
155
|
+
# Whether reachability ran at all. A cleared count means nothing
|
|
156
|
+
# without it: zero cleared because everything is reachable is a
|
|
157
|
+
# different situation from zero cleared because nobody looked.
|
|
158
|
+
f"reachability_analysed={str(_analysed(results)).lower()}",
|
|
159
|
+
]
|
|
160
|
+
return "\n".join(lines) + "\n"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _analysed(results: ScanResults) -> bool:
|
|
164
|
+
"""Whether the reachability search ran at all.
|
|
165
|
+
|
|
166
|
+
True when the scan recorded the entry points it started from, or when any
|
|
167
|
+
finding carries a reachability result. Derived rather than stored, because
|
|
168
|
+
the document already says both of those things and a third field could
|
|
169
|
+
contradict them.
|
|
170
|
+
"""
|
|
171
|
+
if results.entry_points is not None:
|
|
172
|
+
return True
|
|
173
|
+
return any(finding.reach is not None for finding in results.findings)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _lockfile_of(results: ScanResults) -> str:
|
|
177
|
+
"""What the scan said it read, for the SARIF location.
|
|
178
|
+
|
|
179
|
+
Taken from the document rather than guessed from the working directory,
|
|
180
|
+
because this command may run in a different checkout from the scan -- a
|
|
181
|
+
release job rendering a scan an earlier job produced, for instance. A
|
|
182
|
+
document written by an older build carries no such field, which is why
|
|
183
|
+
``--lockfile`` exists and why "unknown" is the last resort rather than an
|
|
184
|
+
error.
|
|
185
|
+
"""
|
|
186
|
+
return results.project.lockfile or "unknown"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = ["command"]
|
vexpath/commands/sbom.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The ``vexpath sbom`` command group: generate and validate bills of materials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from vexpath.output import OutputFormat, emit, emit_json, fail, fail_config, note, warn
|
|
12
|
+
from vexpath_core.sbom import (
|
|
13
|
+
LockfileError,
|
|
14
|
+
UnknownSbomFormatError,
|
|
15
|
+
cyclonedx,
|
|
16
|
+
find_lockfiles,
|
|
17
|
+
generate_sbom,
|
|
18
|
+
load,
|
|
19
|
+
minimum_elements,
|
|
20
|
+
spdx,
|
|
21
|
+
)
|
|
22
|
+
from vexpath_core.sbom.cyclonedx import CycloneDxError
|
|
23
|
+
from vexpath_core.sbom.spdx import SpdxError
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Generate and validate software bills of materials.", no_args_is_help=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SbomFormatChoice(StrEnum):
|
|
29
|
+
"""Output format for a generated document."""
|
|
30
|
+
|
|
31
|
+
CYCLONEDX = "cyclonedx"
|
|
32
|
+
SPDX = "spdx"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("generate")
|
|
36
|
+
def generate(
|
|
37
|
+
path: Annotated[
|
|
38
|
+
Path,
|
|
39
|
+
typer.Argument(exists=True, file_okay=False, help="Project directory to inventory."),
|
|
40
|
+
] = Path(),
|
|
41
|
+
output: Annotated[
|
|
42
|
+
Path | None,
|
|
43
|
+
typer.Option("--output", "-o", help="Write the document here instead of stdout."),
|
|
44
|
+
] = None,
|
|
45
|
+
document_format: Annotated[
|
|
46
|
+
SbomFormatChoice,
|
|
47
|
+
typer.Option("--format", "-f", help="Document format to write."),
|
|
48
|
+
] = SbomFormatChoice.CYCLONEDX,
|
|
49
|
+
lockfile: Annotated[
|
|
50
|
+
Path | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--lockfile",
|
|
53
|
+
exists=True,
|
|
54
|
+
dir_okay=False,
|
|
55
|
+
help="Use this lockfile instead of searching the directory.",
|
|
56
|
+
),
|
|
57
|
+
] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Generate an SBOM from a project's lockfile.
|
|
60
|
+
|
|
61
|
+
The document is deterministic: an unchanged project produces byte-identical
|
|
62
|
+
output, including its serial number, so two runs can be compared.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
sbom = generate_sbom(path, lockfile=lockfile)
|
|
66
|
+
except LockfileError as exc:
|
|
67
|
+
fail(str(exc))
|
|
68
|
+
|
|
69
|
+
gaps = minimum_elements.check(sbom)
|
|
70
|
+
sbom = sbom.model_copy(update={"minimum_element_gaps": gaps})
|
|
71
|
+
|
|
72
|
+
text = (
|
|
73
|
+
cyclonedx.to_json(sbom)
|
|
74
|
+
if document_format is SbomFormatChoice.CYCLONEDX
|
|
75
|
+
else spdx.to_json(sbom)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for item in sbom.notes:
|
|
79
|
+
note(item)
|
|
80
|
+
|
|
81
|
+
if output is None:
|
|
82
|
+
emit(text)
|
|
83
|
+
else:
|
|
84
|
+
output.write_text(text, encoding="utf-8", newline="\n")
|
|
85
|
+
note(f"wrote {sbom.component_count} components to {output}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("validate")
|
|
89
|
+
def validate(
|
|
90
|
+
file: Annotated[
|
|
91
|
+
Path,
|
|
92
|
+
typer.Argument(exists=True, dir_okay=False, help="SBOM document to validate."),
|
|
93
|
+
],
|
|
94
|
+
output_format: Annotated[
|
|
95
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
96
|
+
] = OutputFormat.TABLE,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Validate a document and report its minimum-element gaps.
|
|
99
|
+
|
|
100
|
+
Schema problems are errors. Minimum-element gaps are reported but do not
|
|
101
|
+
fail the command: an incomplete document is still analysable, and whether
|
|
102
|
+
the gaps matter is the reader's call, not this tool's.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
sbom = load(file)
|
|
106
|
+
except (UnknownSbomFormatError, CycloneDxError, SpdxError) as exc:
|
|
107
|
+
fail(str(exc))
|
|
108
|
+
|
|
109
|
+
gaps = minimum_elements.check(sbom)
|
|
110
|
+
|
|
111
|
+
if output_format is OutputFormat.JSON:
|
|
112
|
+
emit_json(
|
|
113
|
+
{
|
|
114
|
+
"file": str(file),
|
|
115
|
+
"format": sbom.format.value,
|
|
116
|
+
"spec_version": sbom.spec_version,
|
|
117
|
+
"valid": True,
|
|
118
|
+
"component_count": sbom.component_count,
|
|
119
|
+
"dependency_edges": len(sbom.edges),
|
|
120
|
+
"generated_by": sbom.generated_by.value,
|
|
121
|
+
"minimum_element_gaps": list(gaps),
|
|
122
|
+
"notes": list(sbom.notes),
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
lines = [
|
|
128
|
+
f"{file.name} is a valid {sbom.format.value} document.",
|
|
129
|
+
f" components {sbom.component_count}",
|
|
130
|
+
f" dependency edges {len(sbom.edges)}",
|
|
131
|
+
f" generated by {sbom.generated_by.value}",
|
|
132
|
+
]
|
|
133
|
+
if gaps:
|
|
134
|
+
lines.append("")
|
|
135
|
+
lines.append(f"Minimum-element gaps ({len(gaps)}):")
|
|
136
|
+
lines.extend(f" - {gap}" for gap in gaps)
|
|
137
|
+
else:
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append("No minimum-element gaps.")
|
|
140
|
+
emit("\n".join(lines) + "\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("lockfiles")
|
|
144
|
+
def lockfiles(
|
|
145
|
+
path: Annotated[
|
|
146
|
+
Path,
|
|
147
|
+
typer.Argument(exists=True, file_okay=False, help="Project directory to inspect."),
|
|
148
|
+
] = Path(),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""List the lockfiles found in a project, best first.
|
|
151
|
+
|
|
152
|
+
Useful when a repository holds several and you want to know which one a
|
|
153
|
+
scan would use, before running one.
|
|
154
|
+
"""
|
|
155
|
+
found = find_lockfiles(path)
|
|
156
|
+
if not found:
|
|
157
|
+
fail_config(
|
|
158
|
+
f"no supported lockfile in {path}. Generate one with 'uv lock', "
|
|
159
|
+
"'poetry lock' or 'npm install', or pass an SBOM with --sbom."
|
|
160
|
+
)
|
|
161
|
+
lines = [f"Lockfiles in {path}, in the order a scan would prefer them:"]
|
|
162
|
+
for index, candidate in enumerate(found, start=1):
|
|
163
|
+
marker = " (would be used)" if index == 1 else ""
|
|
164
|
+
lines.append(f" {index}. {candidate.name}{marker}")
|
|
165
|
+
if len(found) > 1:
|
|
166
|
+
warn(
|
|
167
|
+
"More than one lockfile is present. Pass --lockfile to choose "
|
|
168
|
+
"explicitly if the first is not the one that ships."
|
|
169
|
+
)
|
|
170
|
+
emit("\n".join(lines) + "\n")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["app"]
|
vexpath/commands/scan.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""The ``vexpath scan`` command: the default way the tool is used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from vexpath import credentials
|
|
11
|
+
from vexpath.commands import auth
|
|
12
|
+
from vexpath.exit_codes import ExitCode
|
|
13
|
+
from vexpath.output import OutputFormat, emit, emit_json, fail, fail_config, note
|
|
14
|
+
from vexpath.sources import SNAPSHOT_ENV, advisory_source, close_source
|
|
15
|
+
from vexpath_core import report
|
|
16
|
+
from vexpath_core.advisory.source import AdvisorySourceError
|
|
17
|
+
from vexpath_core.enums import ComponentScope
|
|
18
|
+
from vexpath_core.policy import PRESETS, PolicyError
|
|
19
|
+
from vexpath_core.sbom import LockfileError, UnknownSbomFormatError
|
|
20
|
+
from vexpath_core.sbom.cyclonedx import CycloneDxError
|
|
21
|
+
from vexpath_core.sbom.spdx import SpdxError
|
|
22
|
+
from vexpath_core.scan import ScanOptions, scanned_lockfile
|
|
23
|
+
from vexpath_core.scan import scan as run_scan
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def command(
|
|
27
|
+
path: Annotated[
|
|
28
|
+
Path,
|
|
29
|
+
typer.Argument(exists=True, file_okay=False, help="Project directory to scan."),
|
|
30
|
+
] = Path(),
|
|
31
|
+
sbom: Annotated[
|
|
32
|
+
Path | None,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--sbom",
|
|
35
|
+
exists=True,
|
|
36
|
+
dir_okay=False,
|
|
37
|
+
help="Analyse this SBOM instead of generating one from a lockfile.",
|
|
38
|
+
),
|
|
39
|
+
] = None,
|
|
40
|
+
lockfile: Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--lockfile",
|
|
44
|
+
exists=True,
|
|
45
|
+
dir_okay=False,
|
|
46
|
+
help="Use this lockfile instead of searching the directory.",
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
snapshot: Annotated[
|
|
50
|
+
Path | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--snapshot",
|
|
53
|
+
exists=True,
|
|
54
|
+
file_okay=False,
|
|
55
|
+
help=(
|
|
56
|
+
"Read advisories from this local snapshot, making the scan "
|
|
57
|
+
f"reproducible and offline. Defaults to ${SNAPSHOT_ENV}."
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
] = None,
|
|
61
|
+
output_format: Annotated[
|
|
62
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
63
|
+
] = OutputFormat.TABLE,
|
|
64
|
+
no_reach: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--no-reach",
|
|
68
|
+
help="Skip reachability analysis and report matching only.",
|
|
69
|
+
),
|
|
70
|
+
] = False,
|
|
71
|
+
ignore_dev: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--ignore-dev",
|
|
75
|
+
help="Exclude development dependencies from analysis.",
|
|
76
|
+
),
|
|
77
|
+
] = False,
|
|
78
|
+
show_cleared: Annotated[
|
|
79
|
+
bool,
|
|
80
|
+
typer.Option("--show-cleared", help="List findings that were cleared, too."),
|
|
81
|
+
] = False,
|
|
82
|
+
entry_point: Annotated[
|
|
83
|
+
str | None,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--entry-point",
|
|
86
|
+
"-e",
|
|
87
|
+
help=(
|
|
88
|
+
"Comma-separated entry points to start the reachability search "
|
|
89
|
+
"from, as module.path:function. Nothing beats being told how the "
|
|
90
|
+
"application starts."
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
] = None,
|
|
94
|
+
environment: Annotated[
|
|
95
|
+
Path | None,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--environment",
|
|
98
|
+
exists=True,
|
|
99
|
+
file_okay=False,
|
|
100
|
+
help=(
|
|
101
|
+
"Virtual environment holding the installed dependencies. "
|
|
102
|
+
"Defaults to the one this command is running in."
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
download_missing: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--download-missing",
|
|
110
|
+
help=(
|
|
111
|
+
"Fetch a dependency's source from the package index when it is "
|
|
112
|
+
"not installed. Reaches the network. Without it, a scan of a "
|
|
113
|
+
"lockfile alone can confirm findings but never clear them."
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
] = False,
|
|
117
|
+
no_cache: Annotated[
|
|
118
|
+
bool,
|
|
119
|
+
typer.Option(
|
|
120
|
+
"--no-cache", help="Rebuild dependency graphs instead of reusing cached ones."
|
|
121
|
+
),
|
|
122
|
+
] = False,
|
|
123
|
+
policy: Annotated[
|
|
124
|
+
str | None,
|
|
125
|
+
typer.Option(
|
|
126
|
+
"--policy",
|
|
127
|
+
help=(
|
|
128
|
+
"The gate: a preset name, a CEL expression, or a path to a file "
|
|
129
|
+
"holding one. Exits 1 when it fails. Run 'vexpath policy list' to "
|
|
130
|
+
"see the presets. Without this, nothing is gated and the scan "
|
|
131
|
+
"only reports."
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
] = None,
|
|
135
|
+
upload: Annotated[
|
|
136
|
+
bool,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--upload",
|
|
139
|
+
help=(
|
|
140
|
+
"Send the result to the platform after the scan. Needs a "
|
|
141
|
+
"credential from 'vexpath login' or "
|
|
142
|
+
f"${credentials.KEY_ENVIRONMENT_VARIABLE}, and --project."
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
] = False,
|
|
146
|
+
project: Annotated[
|
|
147
|
+
str | None,
|
|
148
|
+
typer.Option(
|
|
149
|
+
"--project",
|
|
150
|
+
help="Slug of the project to upload to. Required with --upload.",
|
|
151
|
+
),
|
|
152
|
+
] = None,
|
|
153
|
+
fail_on_findings: Annotated[
|
|
154
|
+
bool,
|
|
155
|
+
typer.Option(
|
|
156
|
+
"--fail-on-findings/--no-fail-on-findings",
|
|
157
|
+
help=(
|
|
158
|
+
"Exit with code 1 when anything needs attention at all. A blunt "
|
|
159
|
+
"alternative to --policy, kept because it needs no expression "
|
|
160
|
+
"written and sometimes that is the right trade."
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
] = False,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Scan a project for vulnerable dependencies.
|
|
166
|
+
|
|
167
|
+
Reachability analysis runs by default and is what makes the report short:
|
|
168
|
+
a finding is cleared only when the vulnerable code provably cannot run.
|
|
169
|
+
Pass ``--no-reach`` to report matching alone, in which case nothing is
|
|
170
|
+
cleared and the output says so.
|
|
171
|
+
"""
|
|
172
|
+
if no_reach:
|
|
173
|
+
note(
|
|
174
|
+
"Reachability analysis is off, so this reports matching only. No "
|
|
175
|
+
"finding will be cleared."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Resolved before any analysis runs. A missing credential or a forgotten
|
|
179
|
+
# --project discovered after a four-minute scan has thrown the scan away,
|
|
180
|
+
# and the person who sees that message is already in a hurry.
|
|
181
|
+
credential = _upload_credential(upload=upload, project=project)
|
|
182
|
+
|
|
183
|
+
source = advisory_source(snapshot, result_noun="scan")
|
|
184
|
+
scopes = (
|
|
185
|
+
frozenset({ComponentScope.RUNTIME, ComponentScope.OPTIONAL})
|
|
186
|
+
if ignore_dev
|
|
187
|
+
else frozenset(ComponentScope)
|
|
188
|
+
)
|
|
189
|
+
options = ScanOptions(
|
|
190
|
+
sbom_path=sbom,
|
|
191
|
+
lockfile=lockfile,
|
|
192
|
+
include_scopes=scopes,
|
|
193
|
+
policy=_resolve_policy(policy),
|
|
194
|
+
reachability=not no_reach,
|
|
195
|
+
entry_points=tuple(
|
|
196
|
+
entry.strip() for entry in (entry_point or "").split(",") if entry.strip()
|
|
197
|
+
),
|
|
198
|
+
environment=environment,
|
|
199
|
+
use_cache=not no_cache,
|
|
200
|
+
download_missing=download_missing,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
results = run_scan(path, source, options=options)
|
|
205
|
+
except PolicyError as exc:
|
|
206
|
+
# A gate that cannot be evaluated is a configuration problem, and must
|
|
207
|
+
# never be reported as a gate that passed.
|
|
208
|
+
fail_config(str(exc))
|
|
209
|
+
except (LockfileError, UnknownSbomFormatError, CycloneDxError, SpdxError) as exc:
|
|
210
|
+
fail(str(exc))
|
|
211
|
+
except AdvisorySourceError as exc:
|
|
212
|
+
fail(
|
|
213
|
+
f"{exc}. To scan without network access, download a snapshot with "
|
|
214
|
+
f"'vexpath advisory sync' and pass --snapshot."
|
|
215
|
+
)
|
|
216
|
+
finally:
|
|
217
|
+
close_source(source)
|
|
218
|
+
|
|
219
|
+
_render(results, output_format, path, options, show_cleared=show_cleared)
|
|
220
|
+
|
|
221
|
+
if credential is not None:
|
|
222
|
+
# Uploaded before the policy decides the exit code, deliberately: the
|
|
223
|
+
# scan the platform most needs is the one that just failed a gate, and
|
|
224
|
+
# exiting first would mean the record of the failure never arrives.
|
|
225
|
+
assert project is not None # guaranteed by _upload_credential
|
|
226
|
+
_upload(credential, project, results)
|
|
227
|
+
|
|
228
|
+
if results.policy is not None and not results.policy.passed:
|
|
229
|
+
raise typer.Exit(code=int(ExitCode.POLICY_FAILED))
|
|
230
|
+
|
|
231
|
+
needs_attention = len(results.findings_needing_attention())
|
|
232
|
+
if fail_on_findings and needs_attention:
|
|
233
|
+
raise typer.Exit(code=int(ExitCode.POLICY_FAILED))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _upload_credential(*, upload: bool, project: str | None) -> credentials.Credential | None:
|
|
237
|
+
"""The credential to upload with, or None when no upload was asked for.
|
|
238
|
+
|
|
239
|
+
Resolved up front so the two mistakes that waste a whole scan -- no
|
|
240
|
+
credential, and --upload without --project -- are reported before the
|
|
241
|
+
analysis starts rather than after it.
|
|
242
|
+
"""
|
|
243
|
+
if not upload:
|
|
244
|
+
if project is not None:
|
|
245
|
+
# Saying nothing would silently discard the flag, and the person
|
|
246
|
+
# would wait for a result that never appears on the platform.
|
|
247
|
+
fail_config("--project only means something with --upload.")
|
|
248
|
+
return None
|
|
249
|
+
if not project:
|
|
250
|
+
fail_config("--upload needs --project to say where the result goes.")
|
|
251
|
+
return auth.credential_or_exit()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _upload(credential: credentials.Credential, project: str, results: object) -> None:
|
|
255
|
+
from vexpath_core.models.results import ScanResults
|
|
256
|
+
|
|
257
|
+
assert isinstance(results, ScanResults)
|
|
258
|
+
stored = auth.upload_results(
|
|
259
|
+
credential, project, results.model_dump(mode="json", by_alias=True)
|
|
260
|
+
)
|
|
261
|
+
scan_id = stored.get("id", "unknown")
|
|
262
|
+
note(f"uploaded to {credential.api_url} as scan {scan_id}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _resolve_policy(source: str | None) -> str | None:
|
|
266
|
+
"""Turn the flag into something the core can compile.
|
|
267
|
+
|
|
268
|
+
A preset name wins over a file of the same name: the presets are a closed
|
|
269
|
+
vocabulary, and a file called ``balanced`` in a working directory must not
|
|
270
|
+
silently change what the default means.
|
|
271
|
+
"""
|
|
272
|
+
if source is None:
|
|
273
|
+
return None
|
|
274
|
+
if source in PRESETS:
|
|
275
|
+
return source
|
|
276
|
+
candidate = Path(source)
|
|
277
|
+
if candidate.is_file():
|
|
278
|
+
try:
|
|
279
|
+
return candidate.read_text(encoding="utf-8").strip()
|
|
280
|
+
except OSError as exc:
|
|
281
|
+
fail_config(f"{candidate} could not be read: {exc}")
|
|
282
|
+
return source
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _render(
|
|
286
|
+
results: object,
|
|
287
|
+
output_format: OutputFormat,
|
|
288
|
+
path: Path,
|
|
289
|
+
options: ScanOptions,
|
|
290
|
+
*,
|
|
291
|
+
show_cleared: bool,
|
|
292
|
+
) -> None:
|
|
293
|
+
from vexpath_core.models.results import ScanResults
|
|
294
|
+
|
|
295
|
+
assert isinstance(results, ScanResults) # narrowed for the type checker
|
|
296
|
+
|
|
297
|
+
if output_format is OutputFormat.JSON:
|
|
298
|
+
emit_json(results.model_dump(mode="json", by_alias=True))
|
|
299
|
+
return
|
|
300
|
+
if output_format is OutputFormat.SARIF:
|
|
301
|
+
emit(report.render_sarif_json(results, lockfile_path=scanned_lockfile(path, options)))
|
|
302
|
+
return
|
|
303
|
+
if output_format is OutputFormat.MARKDOWN:
|
|
304
|
+
emit(report.render_markdown(results, show_cleared=show_cleared))
|
|
305
|
+
return
|
|
306
|
+
emit(report.render_text(results, show_cleared=show_cleared))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
__all__ = ["SNAPSHOT_ENV", "command"]
|