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