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 ADDED
@@ -0,0 +1,12 @@
1
+ """The ``vexpath`` command-line tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try: # pragma: no cover - depends on install mode
8
+ __version__ = version("vexpath")
9
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ """Command implementations, one module per command group."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,305 @@
1
+ """The ``vexpath advisory`` command group: sync a corpus and inspect records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
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, note
12
+ from vexpath.sources import SNAPSHOT_ENV
13
+ from vexpath_core.advisory.coverage import FixReferenceTier, measure
14
+ from vexpath_core.advisory.sync import DEFAULT_ECOSYSTEMS, AdvisorySyncError
15
+ from vexpath_core.advisory.sync import sync as run_sync
16
+ from vexpath_core.enums import canonical_ecosystem
17
+ from vexpath_core.snapshot import SnapshotIntegrityError, read_snapshot
18
+
19
+ app = typer.Typer(help="Download and inspect the advisory corpus.", no_args_is_help=True)
20
+
21
+
22
+ @app.command("sync")
23
+ def sync(
24
+ directory: Annotated[
25
+ Path,
26
+ typer.Argument(help="Where to write the snapshot."),
27
+ ] = Path("advisories"),
28
+ ecosystems: Annotated[
29
+ str,
30
+ typer.Option(
31
+ "--ecosystem",
32
+ "-e",
33
+ help="Comma-separated OSV ecosystem names to download.",
34
+ ),
35
+ ] = ",".join(DEFAULT_ECOSYSTEMS),
36
+ ) -> None:
37
+ """Download advisories and write a local snapshot.
38
+
39
+ A snapshot makes scans reproducible and lets them run with no network
40
+ access at all, which is what an air-gapped environment needs and what makes
41
+ a verdict auditable months later.
42
+ """
43
+ names = [canonical_ecosystem(name) for name in ecosystems.split(",") if name.strip()]
44
+ if not names:
45
+ fail("no ecosystems given; pass --ecosystem PyPI for example")
46
+
47
+ note(f"downloading {', '.join(names)} from OSV; this takes a few minutes")
48
+ try:
49
+ report = run_sync(directory, ecosystems=names)
50
+ except AdvisorySyncError as exc:
51
+ fail(str(exc))
52
+
53
+ lines = [
54
+ f"Wrote snapshot {report.snapshot.id} to {directory}",
55
+ f" advisories {report.advisories}",
56
+ ]
57
+ for ecosystem, share in sorted(report.symbol_coverage.items()):
58
+ counts = report.snapshot.counts.get(ecosystem, {})
59
+ lines.append(
60
+ f" {ecosystem:<10} {counts.get('advisories', 0):>6} advisories, "
61
+ f"{counts.get('with_symbols', 0):>6} with vulnerable symbols ({share:.1%})"
62
+ )
63
+ if report.skipped:
64
+ lines.append(f" skipped {len(report.skipped)} malformed record(s)")
65
+ lines.append("")
66
+ lines.append(f"Set {SNAPSHOT_ENV}={directory} so scans use it by default.")
67
+ emit("\n".join(lines) + "\n")
68
+
69
+
70
+ @app.command("show")
71
+ def show(
72
+ advisory_id: Annotated[
73
+ str, typer.Argument(help="Advisory identifier, such as CVE-2024-35195.")
74
+ ],
75
+ snapshot: Annotated[
76
+ Path | None,
77
+ typer.Option(
78
+ "--snapshot",
79
+ exists=True,
80
+ file_okay=False,
81
+ help=f"Snapshot directory to read. Defaults to ${SNAPSHOT_ENV}.",
82
+ ),
83
+ ] = None,
84
+ output_format: Annotated[
85
+ OutputFormat, typer.Option("--format", "-f", help="Output format.")
86
+ ] = OutputFormat.TABLE,
87
+ ) -> None:
88
+ """Show one advisory: affected ranges, severity and vulnerable symbols.
89
+
90
+ The vulnerable symbols are what reachability analysis searches for, so this
91
+ is the command for understanding why a finding was or was not reachable.
92
+ """
93
+ directory = snapshot or _snapshot_from_environment()
94
+ if directory is None:
95
+ fail(
96
+ f"no snapshot given. Pass --snapshot, or set ${SNAPSHOT_ENV}, "
97
+ "or download one with 'vexpath advisory sync'."
98
+ )
99
+
100
+ try:
101
+ _, advisories = read_snapshot(directory)
102
+ except FileNotFoundError as exc:
103
+ fail(str(exc))
104
+ except SnapshotIntegrityError as exc:
105
+ fail(f"advisory snapshot is not trustworthy: {exc}")
106
+
107
+ wanted = advisory_id.strip().upper()
108
+ found = next(
109
+ (
110
+ advisory
111
+ for advisory in advisories
112
+ if any(identifier.upper() == wanted for identifier in advisory.all_ids)
113
+ ),
114
+ None,
115
+ )
116
+ if found is None:
117
+ fail(f"{advisory_id} is not in the snapshot at {directory}")
118
+
119
+ if output_format is OutputFormat.JSON:
120
+ emit_json(found.model_dump(mode="json"))
121
+ return
122
+
123
+ lines = [f"{found.id} {found.severity.value}"]
124
+ if found.aliases:
125
+ lines.append(f" also known as {', '.join(found.aliases)}")
126
+ if found.summary:
127
+ lines.append(f" summary {found.summary}")
128
+ if found.cvss_v3:
129
+ lines.append(f" CVSS v3 {found.cvss_v3.base_score} {found.cvss_v3.vector}")
130
+ if found.cvss_v4:
131
+ lines.append(f" CVSS v4 {found.cvss_v4.vector}")
132
+ if found.epss_score is not None:
133
+ lines.append(f" EPSS {found.epss_score:.5f}")
134
+ if found.kev:
135
+ lines.append(" CISA KEV yes, known to be exploited")
136
+ if found.cwe_ids:
137
+ lines.append(f" weaknesses {', '.join(found.cwe_ids)}")
138
+ if found.repo_url:
139
+ lines.append(f" repository {found.repo_url}")
140
+ if found.is_withdrawn:
141
+ lines.append(" withdrawn yes, this advisory produces no findings")
142
+
143
+ lines.append("")
144
+ lines.append("Affected packages")
145
+ for package in found.affected:
146
+ lines.append(f" {package.ecosystem} {package.name}")
147
+ for affected_range in package.ranges:
148
+ events = " ".join(
149
+ f"{field}={value}"
150
+ for event in affected_range.events
151
+ for field, value in event.model_dump(exclude_none=True).items()
152
+ )
153
+ lines.append(f" {affected_range.type.value}: {events}")
154
+ if package.versions:
155
+ shown = ", ".join(package.versions[:8])
156
+ more = "" if len(package.versions) <= 8 else f" and {len(package.versions) - 8} more"
157
+ lines.append(f" explicit versions: {shown}{more}")
158
+ if package.fixed_versions:
159
+ lines.append(f" fixed in: {', '.join(package.fixed_versions)}")
160
+
161
+ lines.append("")
162
+ if found.symbols:
163
+ lines.append(f"Vulnerable symbols ({found.symbols_status.value})")
164
+ for symbol in found.symbols:
165
+ lines.append(
166
+ f" {symbol.symbol} [{symbol.kind.value}, {symbol.source.value}, "
167
+ f"{symbol.confidence.value} confidence]"
168
+ )
169
+ else:
170
+ lines.append(
171
+ f"No vulnerable symbols ({found.symbols_status.value}), so reachability "
172
+ "cannot be determined for this advisory and findings stay under "
173
+ "investigation."
174
+ )
175
+
176
+ emit("\n".join(lines) + "\n")
177
+
178
+
179
+ @app.command("coverage")
180
+ def coverage(
181
+ snapshot: Annotated[
182
+ Path | None,
183
+ typer.Option(
184
+ "--snapshot",
185
+ exists=True,
186
+ file_okay=False,
187
+ help=f"Snapshot directory to read. Defaults to ${SNAPSHOT_ENV}.",
188
+ ),
189
+ ] = None,
190
+ output_format: Annotated[
191
+ OutputFormat, typer.Option("--format", "-f", help="Output format.")
192
+ ] = OutputFormat.TABLE,
193
+ ) -> None:
194
+ """Report what share of advisories reachability analysis could ever decide.
195
+
196
+ Two different numbers, and conflating them would mislead:
197
+
198
+ *Symbols* is the share of advisories that already name their vulnerable
199
+ functions. Only Go advisories do. For PyPI this is zero and will stay zero,
200
+ because OSV carries no such field for it.
201
+
202
+ *Fix* is the share whose fix commit can be recovered, which is what symbol
203
+ extraction needs. This is the ceiling on what reachability can ever speak
204
+ to, and the number that decides whether the call-graph engine is worth
205
+ building.
206
+ """
207
+ directory = snapshot or _snapshot_from_environment()
208
+ if directory is None:
209
+ fail(f"no snapshot given. Pass --snapshot or set ${SNAPSHOT_ENV}.")
210
+
211
+ try:
212
+ metadata, advisories = read_snapshot(directory)
213
+ except FileNotFoundError as exc:
214
+ fail(str(exc))
215
+ except SnapshotIntegrityError as exc:
216
+ fail(f"advisory snapshot is not trustworthy: {exc}")
217
+
218
+ report = measure(advisories, snapshot_id=metadata.id)
219
+
220
+ if output_format is OutputFormat.JSON:
221
+ emit_json(
222
+ {
223
+ "snapshot": report.snapshot_id,
224
+ "advisories": report.advisories,
225
+ "withdrawn": report.withdrawn,
226
+ "malicious": report.malicious,
227
+ "ecosystems": [
228
+ {
229
+ "ecosystem": entry.ecosystem,
230
+ "advisories": entry.advisories,
231
+ "withdrawn": entry.withdrawn,
232
+ "malicious": entry.malicious,
233
+ "total_records": entry.total_records,
234
+ "with_symbols": entry.with_symbols,
235
+ "symbol_coverage": round(entry.symbol_coverage, 4),
236
+ "fix_reference_coverage": round(entry.fix_reference_coverage, 4),
237
+ "high_confidence_coverage": round(entry.high_confidence_coverage, 4),
238
+ "tiers": {
239
+ tier.value: entry.tiers.get(tier, 0) for tier in FixReferenceTier
240
+ },
241
+ }
242
+ for entry in report.ecosystems
243
+ ],
244
+ }
245
+ )
246
+ return
247
+
248
+ lines = [
249
+ f"Snapshot {report.snapshot_id}",
250
+ "",
251
+ f"{report.advisories} advisories that reachability analysis could apply to.",
252
+ f"Excluded: {report.malicious} malicious-package records, {report.withdrawn} withdrawn.",
253
+ "",
254
+ f"{'ECOSYSTEM':<12} {'ANALYSABLE':>11} {'MALICIOUS':>10} {'SYMBOLS':>9} "
255
+ f"{'FIX':>7} {'EXACT':>7}",
256
+ "-" * 62,
257
+ ]
258
+ for entry in report.ecosystems:
259
+ lines.append(
260
+ f"{entry.ecosystem:<12} {entry.advisories:>11} {entry.malicious:>10} "
261
+ f"{entry.symbol_coverage:>8.1%} "
262
+ f"{entry.fix_reference_coverage:>6.1%} "
263
+ f"{entry.high_confidence_coverage:>6.1%}"
264
+ )
265
+
266
+ lines.append("")
267
+ lines.append("How a fix commit would be recovered")
268
+ lines.append("")
269
+ header = f"{'ECOSYSTEM':<12}" + "".join(f"{tier.value:>15}" for tier in FixReferenceTier)
270
+ lines.append(header)
271
+ lines.append("-" * len(header))
272
+ for entry in report.ecosystems:
273
+ row = f"{entry.ecosystem:<12}"
274
+ for tier in FixReferenceTier:
275
+ count = entry.tiers.get(tier, 0)
276
+ share = count / entry.advisories if entry.advisories else 0.0
277
+ row += f"{count:>8} {share:>5.0%} "
278
+ lines.append(row.rstrip())
279
+
280
+ lines.append("")
281
+ lines.append(
282
+ "ANALYSABLE excludes MALICIOUS records: malware has no fix to diff, and "
283
+ "installing it runs it, so its presence alone is the finding."
284
+ )
285
+ lines.append(
286
+ "SYMBOLS: advisories that already name their vulnerable functions. Only Go "
287
+ "publishes these, and only in its own export."
288
+ )
289
+ lines.append(
290
+ "FIX: advisories whose fix commit can be recovered, which is the ceiling "
291
+ "on what reachability can decide."
292
+ )
293
+ lines.append("EXACT: of those, the ones resolving to a precise commit rather than a tag diff.")
294
+ emit("\n".join(lines) + "\n")
295
+
296
+
297
+ def _snapshot_from_environment() -> Path | None:
298
+ raw = os.environ.get(SNAPSHOT_ENV, "").strip()
299
+ if not raw:
300
+ return None
301
+ candidate = Path(raw)
302
+ return candidate if candidate.is_dir() else None
303
+
304
+
305
+ __all__ = ["app"]
@@ -0,0 +1,348 @@
1
+ """The ``vexpath attest`` command group: sign a document, and check a signature.
2
+
3
+ A VEX document that says "this does not apply to us" is worth something only
4
+ if a consumer can establish who said it. These commands are the two halves of
5
+ that: producing a signature somebody else can check, and checking one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+
17
+ from vexpath.output import OutputFormat, emit, emit_json, fail, fail_config, note, warn
18
+ from vexpath_core import attest
19
+ from vexpath_core.vex import OPENVEX_PREDICATE_TYPE
20
+
21
+ app = typer.Typer(
22
+ help="Sign documents so their conclusions can be attributed, and verify signatures.",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+ #: What each document type's predicate is called. A verifier matches on this,
27
+ #: so guessing wrongly produces an attestation nothing will accept.
28
+ PREDICATE_TYPES = {
29
+ "vex": OPENVEX_PREDICATE_TYPE,
30
+ "results": attest.RESULTS_PREDICATE_TYPE,
31
+ }
32
+
33
+
34
+ @app.command("keygen")
35
+ def keygen(
36
+ private_out: Annotated[
37
+ Path,
38
+ typer.Option("--private", dir_okay=False, help="Where to write the private key."),
39
+ ] = Path("vexpath-signing.key"),
40
+ public_out: Annotated[
41
+ Path,
42
+ typer.Option("--public", dir_okay=False, help="Where to write the public key."),
43
+ ] = Path("vexpath-signing.pub"),
44
+ force: Annotated[
45
+ bool,
46
+ typer.Option("--force", help="Overwrite an existing key."),
47
+ ] = False,
48
+ ) -> None:
49
+ """Generate an ed25519 keypair for static signing.
50
+
51
+ Prefer keyless signing in CI: it binds the signature to the workflow that
52
+ produced it, and there is no long-lived secret to leak. A static key is
53
+ the right tool for an air-gapped pipeline, and for verifying an
54
+ attestation with nothing but this tool.
55
+ """
56
+ for path in (private_out, public_out):
57
+ if path.exists() and not force:
58
+ fail_config(
59
+ f"{path} already exists. Overwriting a signing key invalidates every "
60
+ f"signature made with it, so pass --force if that is what you want."
61
+ )
62
+
63
+ private_pem, public_pem = attest.generate_key()
64
+ private_out.parent.mkdir(parents=True, exist_ok=True)
65
+ private_out.write_bytes(private_pem)
66
+ public_out.write_bytes(public_pem)
67
+ # Best effort: on Windows this is a no-op, and the warning below covers it.
68
+ with contextlib.suppress(OSError):
69
+ private_out.chmod(0o600)
70
+
71
+ note(f"private key {private_out}")
72
+ note(f"public key {public_out}")
73
+ note(f"key id {attest.key_id(public_pem)}")
74
+ warn(
75
+ "The private key is unencrypted. Keep it out of version control and out "
76
+ "of anything that logs file contents."
77
+ )
78
+
79
+
80
+ @app.command("sign")
81
+ def sign(
82
+ document: Annotated[
83
+ Path,
84
+ typer.Argument(exists=True, dir_okay=False, help="The document to sign."),
85
+ ],
86
+ document_type: Annotated[
87
+ str,
88
+ typer.Option(
89
+ "--type",
90
+ help="What kind of document this is: 'vex' or 'results'.",
91
+ ),
92
+ ] = "vex",
93
+ key: Annotated[
94
+ Path | None,
95
+ typer.Option(
96
+ "--key",
97
+ exists=True,
98
+ dir_okay=False,
99
+ help=(
100
+ "Sign with this ed25519 private key. Without it, signing is "
101
+ "keyless through Sigstore and needs an OIDC credential, which "
102
+ "CI has and a laptop usually does not."
103
+ ),
104
+ ),
105
+ ] = None,
106
+ subject: Annotated[
107
+ list[str] | None,
108
+ typer.Option(
109
+ "--subject",
110
+ help=(
111
+ "What the attestation is about, as name=sha256. Repeatable. "
112
+ "Defaults to the document itself, which is the honest subject "
113
+ "when nothing else is named."
114
+ ),
115
+ ),
116
+ ] = None,
117
+ output: Annotated[
118
+ Path | None,
119
+ typer.Option("--output", "-o", dir_okay=False, help="Write here instead of stdout."),
120
+ ] = None,
121
+ ) -> None:
122
+ """Wrap a document in a signed in-toto attestation.
123
+
124
+ The result is a DSSE envelope holding an in-toto Statement v1, which is
125
+ what `cosign verify-attestation` and the in-toto tooling read.
126
+
127
+ A failure to sign is always reported and never produces an unsigned
128
+ envelope. A pipeline that believes it is signing and is not is worse off
129
+ than one that knows it is not.
130
+ """
131
+ predicate_type = PREDICATE_TYPES.get(document_type)
132
+ if predicate_type is None:
133
+ fail_config(f"--type {document_type!r} is not one of: {', '.join(sorted(PREDICATE_TYPES))}")
134
+
135
+ raw = document.read_bytes()
136
+ try:
137
+ predicate = json.loads(raw)
138
+ except json.JSONDecodeError as exc:
139
+ fail(f"{document} is not readable JSON, so it cannot be attested: {exc}")
140
+
141
+ try:
142
+ subjects = _subjects(subject, document, raw)
143
+ statement = attest.build_statement(predicate, predicate_type, subjects)
144
+ envelope = (
145
+ attest.sign_with_key(statement, key.read_bytes())
146
+ if key is not None
147
+ else attest.sign_keyless(statement)
148
+ )
149
+ except attest.AttestationError as exc:
150
+ fail(str(exc))
151
+
152
+ note(f"predicate {predicate_type}")
153
+ for entry in subjects:
154
+ note(f"subject {entry.name} sha256:{entry.sha256[:16]}...")
155
+ note("signing " + ("static key" if key is not None else "keyless (Sigstore)"))
156
+ if key is not None:
157
+ warn(
158
+ "Signed with a static key, so a consumer learns only that whoever "
159
+ "holds that key made the claim. Keyless signing in CI names the "
160
+ "workflow instead."
161
+ )
162
+
163
+ rendered = envelope.to_json()
164
+ if output is None:
165
+ emit(rendered)
166
+ return
167
+ output.parent.mkdir(parents=True, exist_ok=True)
168
+ output.write_text(rendered, encoding="utf-8", newline="\n")
169
+ note(f"written to {output}")
170
+
171
+
172
+ def _subjects(
173
+ raw_subjects: list[str] | None, document: Path, payload: bytes
174
+ ) -> list[attest.Subject]:
175
+ """Work out what the attestation is about.
176
+
177
+ Defaults to the document itself. That is the honest subject when nothing
178
+ else is named: it says "this exact document is what I signed", which is
179
+ weaker than naming a built artefact but is true, and inventing a subject
180
+ would not be.
181
+ """
182
+ if not raw_subjects:
183
+ return [attest.subject_for_bytes(payload, document.name)]
184
+
185
+ subjects: list[attest.Subject] = []
186
+ for entry in raw_subjects:
187
+ name, separator, digest = entry.partition("=")
188
+ if not separator:
189
+ fail_config(
190
+ f"--subject {entry!r} is not name=sha256. A subject needs a digest "
191
+ f"a verifier can match against the artefact in front of them."
192
+ )
193
+ try:
194
+ subjects.append(attest.Subject(name=name.strip(), sha256=digest.strip()))
195
+ except attest.AttestationError as exc:
196
+ fail_config(str(exc))
197
+ return subjects
198
+
199
+
200
+ @app.command("verify")
201
+ def verify(
202
+ envelope_path: Annotated[
203
+ Path,
204
+ typer.Argument(exists=True, dir_okay=False, help="The attestation to check."),
205
+ ],
206
+ key: Annotated[
207
+ Path | None,
208
+ typer.Option(
209
+ "--key",
210
+ exists=True,
211
+ dir_okay=False,
212
+ help="Public key to verify a static-key signature against.",
213
+ ),
214
+ ] = None,
215
+ expect_type: Annotated[
216
+ str | None,
217
+ typer.Option(
218
+ "--expect-type",
219
+ help=(
220
+ "Require this predicate type: 'vex', 'results', or a URI. An "
221
+ "attestation about something else is not an answer to what you "
222
+ "asked."
223
+ ),
224
+ ),
225
+ ] = None,
226
+ expect_subject: Annotated[
227
+ str | None,
228
+ typer.Option(
229
+ "--expect-subject",
230
+ help=(
231
+ "Require that the attestation covers this sha256 digest. Without "
232
+ "it, a valid signature over someone else's artefact passes."
233
+ ),
234
+ ),
235
+ ] = None,
236
+ expect_identity: Annotated[
237
+ str | None,
238
+ typer.Option(
239
+ "--expect-identity",
240
+ help="Require this signer identity, such as a workflow URL.",
241
+ ),
242
+ ] = None,
243
+ expect_issuer: Annotated[
244
+ str | None,
245
+ typer.Option("--expect-issuer", help="Require this OIDC issuer."),
246
+ ] = None,
247
+ output: Annotated[
248
+ OutputFormat, typer.Option("--format", "-f", help="Output format.")
249
+ ] = OutputFormat.TABLE,
250
+ ) -> None:
251
+ """Check an attestation, and that it says what you asked about.
252
+
253
+ Exits 1 when verification fails. A signature that merely verifies is a
254
+ weaker fact than it sounds: it may cover a different artefact, a different
255
+ kind of claim, or come from a different signer. The ``--expect`` options
256
+ are how that gap is closed, and the output says which of them were applied.
257
+ """
258
+ try:
259
+ envelope = attest.parse_envelope(envelope_path.read_text(encoding="utf-8"))
260
+ except attest.AttestationError as exc:
261
+ fail(str(exc))
262
+
263
+ if key is None:
264
+ fail_config(
265
+ "--key is required. Verifying a keyless signature means checking a "
266
+ "certificate and a transparency-log entry against an identity, which "
267
+ "is what 'cosign verify-attestation' does; this command verifies "
268
+ "static-key signatures."
269
+ )
270
+
271
+ try:
272
+ result = attest.verify_with_key(envelope, key.read_bytes())
273
+ except attest.AttestationError as exc:
274
+ fail(str(exc))
275
+
276
+ wanted_type = PREDICATE_TYPES.get(expect_type or "", expect_type)
277
+ result = result.requires(
278
+ predicate_type=wanted_type,
279
+ subject_sha256=expect_subject,
280
+ identity=expect_identity,
281
+ issuer=expect_issuer,
282
+ )
283
+
284
+ applied = [
285
+ name
286
+ for name, value in (
287
+ ("predicate type", wanted_type),
288
+ ("subject digest", expect_subject),
289
+ ("identity", expect_identity),
290
+ ("issuer", expect_issuer),
291
+ )
292
+ if value
293
+ ]
294
+
295
+ if output is OutputFormat.JSON:
296
+ emit_json(
297
+ {
298
+ "verified": result.verified,
299
+ "reason": result.reason,
300
+ "method": result.method,
301
+ "key_id": result.key_id,
302
+ "identity": result.identity,
303
+ "issuer": result.issuer,
304
+ "predicate_type": result.predicate_type,
305
+ "subjects": [entry.as_json() for entry in result.subjects],
306
+ "constraints_applied": applied,
307
+ }
308
+ )
309
+ else:
310
+ lines = [result.summary_line()]
311
+ lines.append("constraints " + (", ".join(applied) if applied else "none applied"))
312
+ if not applied and result.verified:
313
+ lines.append("")
314
+ lines.append(
315
+ "A signature with no constraints applied says only that someone "
316
+ "holding that key signed something. Pass --expect-type and "
317
+ "--expect-subject to make it say more."
318
+ )
319
+ emit("\n".join(lines) + "\n")
320
+
321
+ if not result.verified:
322
+ raise typer.Exit(code=1)
323
+
324
+
325
+ @app.command("inspect")
326
+ def inspect(
327
+ envelope_path: Annotated[
328
+ Path,
329
+ typer.Argument(exists=True, dir_okay=False, help="The attestation to describe."),
330
+ ],
331
+ ) -> None:
332
+ """Describe an attestation without verifying it.
333
+
334
+ Deliberately a separate command. Describing an envelope and believing it
335
+ are different operations, and a tool that blurs them teaches people that a
336
+ signature's presence is the same as its validity.
337
+ """
338
+ try:
339
+ envelope = attest.parse_envelope(envelope_path.read_text(encoding="utf-8"))
340
+ metadata = attest.attestation_metadata(envelope)
341
+ except attest.AttestationError as exc:
342
+ fail(str(exc))
343
+
344
+ emit_json(metadata)
345
+ warn("Nothing here was verified. Use 'vexpath attest verify' for that.")
346
+
347
+
348
+ __all__ = ["PREDICATE_TYPES", "app"]