bomdrift 0.9.9

SBOM diff with supply-chain risk signals (CVEs, typosquats, maintainer-age).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::Deserialize;

use crate::model::SbomFormat;
use crate::render::markdown;

#[derive(Parser, Debug)]
#[command(
    name = "bomdrift",
    version,
    about = "SBOM diff with supply-chain risk signals.",
    long_about = None,
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Diff two SBOMs and surface supply-chain risk signals on changed components.
    Diff(Box<DiffArgs>),
    /// Refresh the bundled typosquat top-package lists from upstream sources.
    ///
    /// Writes a fresh per-ecosystem list to the user's XDG cache directory
    /// (`<XDG_CACHE_HOME>/bomdrift/typosquat/<ecosystem>.txt` on Linux). The
    /// typosquat enricher will pick up the cache file in subsequent runs,
    /// overlaying the snapshot baked into the binary at compile time.
    RefreshTyposquat(RefreshArgs),
    /// Manage the suppression baseline file (v0.5+).
    ///
    /// The comment-suppress sub-action invokes `bomdrift baseline add <id>`
    /// when a reviewer comments `/bomdrift suppress <id>` on a PR; the
    /// subcommand is also useful from the command line for hand-curating a
    /// baseline without editing JSON directly.
    Baseline {
        #[command(subcommand)]
        action: BaselineAction,
    },
    /// Scaffold bomdrift config and GitHub Actions workflows in this repo.
    Init(InitArgs),
}

#[derive(Args, Debug)]
pub struct InitArgs {
    /// Overwrite existing generated files.
    #[arg(long)]
    pub force: bool,

    /// Only write `.bomdrift.toml`; skip GitHub workflow files.
    #[arg(long)]
    pub config_only: bool,
}

#[derive(Subcommand, Debug)]
pub enum BaselineAction {
    /// Append an advisory ID to the baseline's `suppressed_advisories` list.
    /// The file is created if it doesn't exist; existing fields are preserved.
    /// Idempotent — re-adding an existing ID is a no-op (exit 0).
    Add(BaselineAddArgs),
}

#[derive(Args, Debug)]
pub struct BaselineAddArgs {
    /// Advisory identifier to suppress (GHSA-..., CVE-..., MAL-...). Suppresses
    /// the advisory across ALL components in subsequent diffs — a wildcard
    /// match by ID. Use the diff-output baseline format (the JSON shape
    /// emitted by `bomdrift diff --output json`) for finer per-purl
    /// suppression instead.
    ///
    /// Optional when `--from-comment` is supplied — the directive in
    /// the comment body provides the ID instead.
    pub id: Option<String>,

    /// Path to the baseline file. Created if missing; parent directory is
    /// created if missing.
    #[arg(long, default_value = ".bomdrift/baseline.json")]
    pub path: PathBuf,

    /// Optional expiry date (YYYY-MM-DD). Once today is past this date,
    /// the entry stops suppressing and bomdrift prints a warning to
    /// stderr. Useful for time-boxed risk acceptance ("ignore until
    /// upstream ships a fix"). Strict format: zero-padded month/day.
    #[arg(long)]
    pub expires: Option<String>,

    /// Optional human-readable reason recorded alongside the entry.
    /// Surfaces in the v0.9 VEX export and in the warning printed when
    /// the entry expires. Free-form text.
    #[arg(long)]
    pub reason: Option<String>,

    /// Parse the body of a forge-issued PR/MR comment and extract the
    /// suppress directive. Accepts the raw note body as a single
    /// string. The directive grammar (matched case-sensitively at the
    /// start of any line, after optional leading whitespace):
    ///
    /// ```text
    /// /bomdrift suppress <ID>[ reason: <text>]
    /// ```
    ///
    /// `<ID>` must match `(?:GHSA|CVE|MAL|OSV)-[A-Z0-9-]+`. When no
    /// matching line is found, the command exits with a non-zero code
    /// and prints a clear stderr message — so a webhook bridge that
    /// invokes this flag doesn't silently no-op on a non-suppress
    /// comment. v0.9+.
    #[arg(long)]
    pub from_comment: Option<String>,
}

#[derive(Args, Debug)]
pub struct RefreshArgs {
    /// Which ecosystem's list to refresh. Defaults to `all`.
    ///
    /// `npm`, `pypi`, and `cargo` fetch fresh top-package lists from their
    /// canonical upstream sources (anvaka gist, hugovk JSON, crates.io API).
    /// `maven` is hand-curated — there is no canonical "top N" feed for
    /// Maven Central, so the embedded list is the source of truth and
    /// `--ecosystem maven` emits a notice rather than fetching anything.
    #[arg(long, value_enum, default_value_t = RefreshEcosystem::All)]
    pub ecosystem: RefreshEcosystem,
}

#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum RefreshEcosystem {
    /// Refresh every ecosystem with a wired-up fetcher (npm, PyPI, Cargo, NuGet).
    All,
    /// Refresh just the npm top-1000 list from the anvaka most-depended-upon gist.
    Npm,
    /// Refresh the PyPI top-200 list from hugovk/top-pypi-packages.
    #[value(name = "pypi")]
    PyPI,
    /// Refresh the Cargo (crates.io) top-200 list from the crates.io API.
    Cargo,
    /// Maven has no canonical upstream feed; the list is curated and shipped
    /// embedded. This variant is accepted so `--ecosystem all` stays
    /// stable, and emits an informational notice.
    Maven,
    /// Go has no canonical upstream popularity feed; the list is curated
    /// from pkg.go.dev and well-known imports. Variant is accepted so
    /// `--ecosystem all` stays stable, and emits an informational notice.
    Go,
    /// RubyGems' public most-downloaded API has gone through several
    /// breaking changes; the v0.4 list is curated. Variant is accepted
    /// so `--ecosystem all` stays stable, and emits an informational
    /// notice.
    Gem,
    /// Refresh the NuGet top-200 list from the nuget.org v3 search API.
    #[value(name = "nuget")]
    NuGet,
    /// Packagist's public statistics API has gone through several
    /// breaking changes; the v0.4 Composer list is curated. Variant is
    /// accepted so `--ecosystem all` stays stable, and emits an
    /// informational notice.
    Composer,
}

/// Forge the rendered markdown is destined for. Drives the action-affordance
/// footer in `render::markdown` and CI-side defaults (e.g. detection of
/// `GITLAB_CI` / `CI_PROJECT_URL`).
///
/// Variants intentionally cover only forges with a wired-up footer
/// implementation. New forges (Bitbucket, Gitea, ...) are an additive change.
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Platform {
    /// GitHub.com or GitHub Enterprise. Default — preserves the v0.5
    /// footer shape for existing consumers.
    #[default]
    #[value(name = "github")]
    GitHub,
    /// GitLab.com or Self-Managed GitLab. The MR-note footer omits the
    /// `/bomdrift suppress` hint and points at `bomdrift baseline add`
    /// instead, because GitLab in-comment suppression is deferred to
    /// v0.8 (note webhooks have a different model than GitHub PR
    /// comments).
    #[value(name = "gitlab")]
    GitLab,
    /// Bitbucket Cloud or Bitbucket Data Center. Footer points
    /// reviewers at the `/issues/new` form and uses `bomdrift baseline
    /// add <ID>` for suppression — Bitbucket has no in-comment
    /// suppression flow in v0.9.
    #[value(name = "bitbucket")]
    Bitbucket,
    /// Azure DevOps Repos (Azure Pipelines). Footer points reviewers at
    /// the work-item create form and uses `bomdrift baseline add <ID>`
    /// for suppression.
    #[value(name = "azure-devops")]
    AzureDevOps,
}

impl From<Platform> for markdown::Platform {
    /// User-facing CLI / config enum maps 1:1 to the renderer's enum. The
    /// two are kept separate so the renderer doesn't take a clap+serde
    /// dependency, but the variants must stay in lockstep — the match
    /// here is exhaustive on purpose: a new variant added to one side
    /// fails the build on the other until both are updated.
    fn from(value: Platform) -> Self {
        match value {
            Platform::GitHub => markdown::Platform::GitHub,
            Platform::GitLab => markdown::Platform::GitLab,
            Platform::Bitbucket => markdown::Platform::Bitbucket,
            Platform::AzureDevOps => markdown::Platform::AzureDevOps,
        }
    }
}

#[derive(Args, Debug)]
pub struct DiffArgs {
    /// Path to the "before" SBOM (CycloneDX, SPDX, or Syft JSON).
    /// Optional when `--before-attestation` is used to fetch the SBOM
    /// from an OCI registry instead.
    #[arg(required_unless_present = "before_attestation")]
    pub before: Option<PathBuf>,
    /// Path to the "after" SBOM (CycloneDX, SPDX, or Syft JSON).
    /// Optional when `--after-attestation` is used.
    #[arg(required_unless_present = "after_attestation")]
    pub after: Option<PathBuf>,
    /// Path to a repo policy config file. When omitted, `.bomdrift.toml` is
    /// loaded if it exists in the current working directory.
    #[arg(long)]
    pub config: Option<PathBuf>,
    /// Output format (default: terminal, unless `.bomdrift.toml` sets one).
    #[arg(long, value_enum)]
    pub output: Option<OutputFormat>,
    /// Force input format detection (default: auto, unless `.bomdrift.toml`
    /// sets one).
    #[arg(long, value_enum)]
    pub format: Option<InputFormat>,
    /// Skip OSV.dev CVE enrichment (offline mode, faster, deterministic).
    #[arg(long)]
    pub no_osv: bool,
    /// Skip the on-disk OSV severity cache (`<XDG_CACHE_HOME>/bomdrift/osv/`).
    /// Useful for reproducibility audits and the rare case where a stale
    /// cached severity (within the 24-hour TTL) is actively misleading. Has
    /// no effect when `--no-osv` is set.
    #[arg(long)]
    pub no_osv_cache: bool,
    /// Path to a baseline JSON file (output of a previous `bomdrift diff
    /// --output json` run). Findings present in the baseline are suppressed
    /// from this run's output; only what *changed* surfaces. Lets a team
    /// adopt bomdrift on a project with pre-existing findings without
    /// drowning the first PR comment.
    #[arg(long)]
    pub baseline: Option<PathBuf>,
    /// Skip the maintainer-age enricher (no GitHub API calls). Use for offline
    /// runs and tests; required when `GITHUB_TOKEN` is unset and the unauth
    /// rate limit (60/hr) is too low for the diff being analyzed.
    #[arg(long)]
    pub no_maintainer_age: bool,
    /// Exit with code 2 when findings of the configured severity or higher
    /// surface (default: none, unless `.bomdrift.toml` sets one).
    #[arg(long, value_enum)]
    pub fail_on: Option<FailOn>,
    /// Emit only the summary table (counts per change/finding category) and
    /// a footer pointing at the full output, omitting every per-category
    /// section. The PR-comment-friendly form for diffs that would otherwise
    /// blow past GitHub's 65,536-character comment-body cap.
    ///
    /// Markdown-only: terminal / JSON / SARIF outputs ignore the flag (the
    /// goal is comment-size compression, not data loss).
    #[arg(long)]
    pub summary_only: bool,
    /// Markdown-only. Omit raw Added / Removed / Version changed detail
    /// sections, leaving the summary table plus risk-bearing sections. Useful
    /// for PR comments where reviewers only want actionable findings.
    #[arg(long)]
    pub findings_only: bool,
    /// Keep `Ecosystem::Other("file")` pseudo-components emitted by Syft's
    /// directory cataloger. Off by default — the cataloger emits each
    /// YAML / lockfile / source file in the scanned directory as a synthetic
    /// component whose path differs between the PR-head and base-ref
    /// checkouts, producing phantom Added/Removed pairs that drown real
    /// package changes. Enable for debugging or auditing the raw cataloger
    /// output.
    #[arg(long)]
    pub include_file_components: bool,
    /// Repository URL (e.g. `https://github.com/owner/repo`) used to
    /// render the markdown comment's action-affordance footer — the
    /// "Report this finding" link target and the suppress-comment hint.
    /// When unset, falls back to the `BOMDRIFT_REPO_URL` env var; when
    /// neither is set, the footer is omitted so forks and standalone CLI
    /// use don't render dead links to bomdrift's own issue tracker.
    #[arg(long)]
    pub repo_url: Option<String>,
    /// Forge the rendered markdown is destined for. Controls the action-
    /// affordance footer shape (GitHub uses the `/bomdrift suppress`
    /// comment-driven flow; GitLab points reviewers at the manual
    /// `bomdrift baseline add` CLI flow). When omitted, auto-detects from
    /// CI environment variables (`GITLAB_CI=true` → GitLab; default
    /// otherwise is GitHub).
    #[arg(long, value_enum)]
    pub platform: Option<Platform>,
    /// Exit 2 when more than this many components are added in one diff.
    #[arg(long)]
    pub max_added: Option<usize>,
    /// Exit 2 when more than this many components are removed in one diff.
    #[arg(long)]
    pub max_removed: Option<usize>,
    /// Exit 2 when more than this many components change version in one diff.
    #[arg(long)]
    pub max_version_changed: Option<usize>,
    /// Print one CSV-friendly stderr line per finding showing the score
    /// and the threshold that gated it. Off by default. Used to gather
    /// real-world calibration data — `SIMILARITY_THRESHOLD` for
    /// typosquats, `YOUNG_MAINTAINER_DAYS` for maintainer-age — without
    /// shipping telemetry. The output is opt-in and the user owns the
    /// resulting CSV; pipe to a file with `2>calibration.csv`.
    ///
    /// Format: `kind|key|score|threshold` per line. `kind` is one of
    /// `typosquat`, `maintainer-age`, `version-jump`, `cve`. `score` is
    /// the underlying similarity / age / jump-size / CVSS value;
    /// `threshold` is the constant the finding was compared against.
    /// Skip the EPSS enricher (FIRST.org) entirely. Useful for offline /
    /// air-gapped CI where outbound HTTP is blocked, or when EPSS data is
    /// not part of the team's risk model. Disables both the network call
    /// and the disk cache lookup.
    #[arg(long)]
    pub no_epss: bool,
    /// Skip the CISA KEV enricher entirely.
    #[arg(long)]
    pub no_kev: bool,
    /// Trip exit-2 when any advisory's EPSS score is >= this threshold
    /// (0.0 - 1.0). Recommended starting point: 0.5 (top decile of
    /// actively-exploited CVEs). Implicit `--fail-on cve` semantics —
    /// only advisories surface this; non-CVE findings are unaffected.
    #[arg(long)]
    pub fail_on_epss: Option<f32>,
    /// Comma-separated SPDX license identifiers (or `*`-suffix globs)
    /// permitted by policy. May be repeated. CLI flag takes precedence
    /// over `[license] allow` in `.bomdrift.toml` (override, not merge).
    #[arg(long, value_delimiter = ',')]
    pub allow_licenses: Vec<String>,
    /// Comma-separated SPDX license identifiers (or `*`-suffix globs)
    /// forbidden by policy. May be repeated. Deny wins when a license
    /// matches both allow and deny.
    #[arg(long, value_delimiter = ',')]
    pub deny_licenses: Vec<String>,
    /// When set, compound SPDX expressions like `(MIT OR GPL-3.0)` are
    /// permitted (the v0.9 SPDX evaluator will replace this with proper
    /// expression evaluation). Off by default — fail-closed.
    #[arg(long)]
    pub allow_ambiguous_licenses: bool,
    /// Comma-separated SPDX exception identifiers (e.g.
    /// `LLVM-exception`, `Classpath-exception-2.0`) permitted as the
    /// right-hand side of a `WITH` clause. Repeatable. When set,
    /// `Apache-2.0 WITH <other>` violates policy even if `Apache-2.0`
    /// is on the base allow list. v0.9.5+.
    #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)]
    pub allow_exception: Vec<String>,
    /// Comma-separated SPDX exception identifiers forbidden as the
    /// right-hand side of a `WITH` clause. Repeatable. v0.9.5+.
    #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)]
    pub deny_exception: Vec<String>,
    /// Path(s) to VEX (Vulnerability Exploitability eXchange) files
    /// to consume. Repeatable. Each file is auto-detected as either
    /// OpenVEX 0.2.0 or CycloneDX VEX 1.6. Statements with status
    /// `not_affected` / `fixed` suppress matching findings; statements
    /// with `under_investigation` annotate without suppressing;
    /// statements with `affected` annotate as a no-op badge. See
    /// <https://metbcy.github.io/bomdrift/vex.html> for the
    /// finding-id matching rules including the synthetic-id convention
    /// for non-CVE findings.
    #[arg(long, action = clap::ArgAction::Append)]
    pub vex: Vec<PathBuf>,
    /// Emit a single OpenVEX 0.2.0 doc covering every finding in the
    /// post-baseline diff. Baseline-suppressed entries inherit their
    /// `vex_status` from the baseline entry (defaulting to
    /// `under_investigation` to avoid publishing false `not_affected`
    /// claims); un-suppressed findings emit as `affected`. v0.9+.
    #[arg(long)]
    pub emit_vex: Option<PathBuf>,
    /// Skip registry-metadata enrichers (npm/PyPI/crates.io) entirely.
    /// Use for offline runs or when you don't want bomdrift to fan out
    /// HTTP requests to package registries.
    #[arg(long)]
    pub no_registry: bool,
    /// Recently-published threshold in days. Components published
    /// within this window trip a `RecentlyPublished` finding. Default
    /// 14 days; set to 0 to disable the kind without disabling the
    /// other registry checks.
    #[arg(long)]
    pub recently_published_days: Option<i64>,
    /// VEX `author` for `--emit-vex`. Falls back to repo_url, then
    /// to `"bomdrift"`. v0.9+.
    #[arg(long)]
    pub vex_author: Option<String>,
    /// Default OpenVEX `justification` written into emitted statements
    /// when the source baseline entry doesn't supply one. Defaults to
    /// `"vulnerable_code_not_in_execute_path"` — the safe fallback per
    /// the OpenVEX spec.
    #[arg(long)]
    pub vex_default_justification: Option<String>,
    /// Override the typosquat similarity threshold (default 0.92).
    /// Range 0.0 - 1.0 inclusive. Lower values surface more findings
    /// (and more false positives); higher values cut down to only
    /// near-perfect matches. v0.9.6+.
    #[arg(long, value_parser = parse_similarity_threshold)]
    pub typosquat_similarity_threshold: Option<f64>,
    /// Override the maintainer-age young-maintainer-days threshold
    /// (default 90 days). Components whose top contributor's first
    /// commit is younger than this trip a `YoungMaintainer` finding.
    /// Must be >= 1. v0.9.6+.
    #[arg(long, value_parser = clap::value_parser!(i64).range(1..))]
    pub young_maintainer_days: Option<i64>,
    /// Override the on-disk cache TTL in hours (default 24). Applies
    /// uniformly to OSV / EPSS / KEV / Registry caches. Must be >= 1.
    /// v0.9.6+.
    #[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
    pub cache_ttl_hours: Option<u64>,
    /// Override the version-jump minimum major-delta threshold (default
    /// 2). A delta of 1 flags every cross-major upgrade; higher values
    /// only flag larger jumps. Must be >= 1. v0.9.7+.
    #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
    pub multi_major_delta: Option<u32>,
    /// Fetch the "before" SBOM as a cosign-verified attestation
    /// attached to an OCI artifact instead of reading a local file.
    /// Mutually exclusive with the positional `before` argument.
    /// Requires `--cosign-identity` and `--cosign-issuer`. v0.9.6+.
    #[arg(long, conflicts_with = "before")]
    pub before_attestation: Option<String>,
    /// Fetch the "after" SBOM as a cosign-verified attestation
    /// attached to an OCI artifact. v0.9.6+.
    #[arg(long, conflicts_with = "after")]
    pub after_attestation: Option<String>,
    /// Regex passed to `cosign verify-attestation
    /// --certificate-identity-regexp`. Required when either
    /// `--before-attestation` or `--after-attestation` is set.
    /// Example: `https://github.com/owner/.+`. v0.9.6+.
    #[arg(long)]
    pub cosign_identity: Option<String>,
    /// URL passed to `cosign verify-attestation
    /// --certificate-oidc-issuer`. Required alongside
    /// `--cosign-identity`. Example:
    /// `https://token.actions.githubusercontent.com`. v0.9.6+.
    #[arg(long)]
    pub cosign_issuer: Option<String>,
    /// Refuse to fall back to local-file SBOMs: both sides MUST come
    /// from a verified OCI attestation. Implies that
    /// `--before-attestation` and `--after-attestation` are both set.
    /// v0.9.6+.
    #[arg(long)]
    pub require_attestation: bool,
    /// Path to a plugin manifest TOML. Repeatable. Each plugin is an
    /// external executable invoked once per added / version-changed
    /// component with JSON over stdin/stdout. Plugin failures (timeout,
    /// non-zero exit, malformed JSON) drop their findings without
    /// failing the diff. v0.9.6+.
    #[arg(long, action = clap::ArgAction::Append)]
    pub plugin: Vec<PathBuf>,
    #[arg(long)]
    pub debug_calibration: bool,
    /// Format for `--debug-calibration` rows. `pipe` (default, back-compat
    /// with v0.7) emits `kind|key|score|threshold` per line; `jsonl` emits
    /// one JSON object per line for downstream tooling that doesn't want
    /// to maintain a custom CSV-ish parser.
    #[arg(long, value_enum, default_value_t = DebugFormat::Pipe)]
    pub debug_calibration_format: DebugFormat,
    /// Write the chosen `--output` format to this path instead of stdout.
    /// Useful for SARIF (`--output sarif --output-file bomdrift.sarif`)
    /// where YAML quoting `>` redirection is fragile in CI templates.
    #[arg(long)]
    pub output_file: Option<PathBuf>,
}

/// Wire format for `--debug-calibration` output. Pipe-delimited keeps v0.7
/// callers working unchanged; JSONL is the recommended shape for new tooling
/// because adding a new finding kind doesn't fork the parser.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DebugFormat {
    #[default]
    Pipe,
    Jsonl,
}

/// Threshold for `--fail-on` exit-code-2 behavior.
///
/// Variants are intentionally ordered loosest-to-strictest in their
/// declaration order, but the comparison logic in [`crate::tripped`] is
/// per-variant rather than ordinal — adding a new variant later is safe.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FailOn {
    /// Never trip. Default. The diff is informational-only.
    None,
    /// Trip when at least one CVE / advisory finding is present in
    /// `enrichment.vulns`.
    Cve,
    /// Trip only when an advisory at severity HIGH or above is present
    /// (per OSV's `database_specific.severity` GHSA label, fetched via
    /// `/v1/vulns/{id}`). Advisories with no resolvable severity surface
    /// in the diff but do NOT trip this threshold.
    CriticalCve,
    /// Trip when at least one typosquat finding is present.
    Typosquat,
    /// Trip when at least one same-version license change is present.
    LicenseChange,
    /// Trip when any advisory's CISA KEV flag is set (i.e. listed in the
    /// Known Exploited Vulnerabilities catalog). KEV is a high-signal
    /// "actively exploited in the wild" claim — narrower than `cve` but
    /// less rigid than `critical-cve` (KEV entries can be Medium-severity).
    Kev,
    /// Trip on a license-policy violation (Phase D, v0.8+).
    LicenseViolation,
    /// Trip when a registry-metadata enricher (npm/PyPI/crates.io) flags
    /// any added component as published within the
    /// recently-published threshold (default 14 days). v0.9+.
    RecentlyPublished,
    /// Trip when a registry-metadata enricher flags any component as
    /// deprecated or yanked upstream. v0.9+.
    Deprecated,
    /// Trip on ANY finding (CVE, typosquat, version-jump, young-maintainer)
    /// OR any license-changed-without-version-bump pair (the suspicious case).
    Any,
}

#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
    Terminal,
    Markdown,
    Json,
    Sarif,
}

#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InputFormat {
    Auto,
    Cdx,
    Spdx,
    Syft,
}

impl InputFormat {
    /// Convert the user-facing `--format` flag to the internal model enum used
    /// by the parser layer. `Auto` returns `None`, signalling auto-detection;
    /// every other variant maps 1:1 to a forced-parse hint.
    pub fn to_sbom_format(self) -> Option<SbomFormat> {
        match self {
            InputFormat::Auto => None,
            InputFormat::Cdx => Some(SbomFormat::CycloneDx),
            InputFormat::Spdx => Some(SbomFormat::Spdx),
            InputFormat::Syft => Some(SbomFormat::Syft),
        }
    }
}

/// Clap value parser for `--typosquat-similarity-threshold`. Rejects
/// values outside the inclusive 0.0..=1.0 range with a clear message
/// (clap's built-in numeric range parser doesn't support `f64`).
fn parse_similarity_threshold(s: &str) -> Result<f64, String> {
    let v: f64 = s
        .parse()
        .map_err(|_| format!("expected a float in 0.0..=1.0, got {s:?}"))?;
    if !v.is_finite() || !(0.0..=1.0).contains(&v) {
        return Err(format!("expected a float in 0.0..=1.0, got {v}"));
    }
    Ok(v)
}