keyhog 0.5.73

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
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
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
//! `keyhog scan` CLI arguments.
//!
//! Split out of `args.rs` because the scan subcommand has the largest flag
//! surface and needs its own validation boundary.

use clap::{parser::ValueSource, Parser, ValueEnum};
use keyhog_core::DedupScope;
use std::path::PathBuf;

use super::SourceLimitArgs;

fn fmt_value_enum<T: ValueEnum>(
    value: &T,
    formatter: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
    match value.to_possible_value() {
        Some(possible) => formatter.write_str(possible.get_name()),
        None => Err(std::fmt::Error),
    }
}

#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SeverityFilter {
    Info,
    ClientSafe,
    Low,
    Medium,
    High,
    Critical,
}

impl SeverityFilter {
    pub fn to_severity(&self) -> keyhog_core::Severity {
        match self {
            Self::Info => keyhog_core::Severity::Info,
            Self::ClientSafe => keyhog_core::Severity::ClientSafe,
            Self::Low => keyhog_core::Severity::Low,
            Self::Medium => keyhog_core::Severity::Medium,
            Self::High => keyhog_core::Severity::High,
            Self::Critical => keyhog_core::Severity::Critical,
        }
    }
}

impl std::fmt::Display for SeverityFilter {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        fmt_value_enum(self, formatter)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
    Text,
    Json,
    #[value(alias = "json_envelope")]
    JsonEnvelope,
    Jsonl,
    #[value(alias = "jsonl_envelope")]
    JsonlEnvelope,
    Sarif,
    Csv,
    #[value(alias = "github_annotations")]
    GithubAnnotations,
    #[value(alias = "gitlab_sast")]
    GitlabSast,
    Html,
    Junit,
}

impl std::fmt::Display for OutputFormat {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        fmt_value_enum(self, formatter)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum CliDedupScope {
    Credential,
    File,
    None,
}

impl std::fmt::Display for CliDedupScope {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        fmt_value_enum(self, formatter)
    }
}

impl CliDedupScope {
    pub fn to_core(&self) -> DedupScope {
        match self {
            Self::Credential => DedupScope::Credential,
            Self::File => DedupScope::File,
            Self::None => DedupScope::None,
        }
    }
}

/// Explicit policy for a custom `--detectors` directory.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum DetectorMode {
    /// Use only the custom detector directory.
    Replace,
    /// Add the custom directory to the embedded corpus, rejecting ID collisions.
    Overlay,
}

impl From<DetectorMode> for keyhog_core::DetectorCorpusMode {
    fn from(mode: DetectorMode) -> Self {
        match mode {
            DetectorMode::Replace => Self::Replace,
            DetectorMode::Overlay => Self::Overlay,
        }
    }
}

/// Daemon routing policy for `scan --daemon[=auto|on|mass|off]`.
///
/// One flag owns the complete daemon policy:
///   * `--daemon` (bare)  → [`Self::On`]   (force the warm daemon route)
///   * `--daemon=auto`    → [`Self::Auto`] (the default when absent)
///   * `--daemon=mass`    → [`Self::Mass`] (force bounded source batches)
///   * `--daemon=off`     → [`Self::Off`]  (force in-process execution)
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum, Debug)]
pub enum DaemonMode {
    /// Use a compatible daemon when its socket is reachable; otherwise scan in
    /// process. A failure after selecting the daemon is reported before the
    /// in-process retry. This is the behavior when the flag is absent. The
    /// explicit `--daemon=auto` spelling requires Unix; an absent flag remains
    /// portable and runs in process where no daemon transport ships.
    Auto,
    /// Force the scan through a running `keyhog daemon`; fail if none is up.
    On,
    /// Force a bounded source transaction through a daemon started with
    /// `keyhog daemon start --mass`. Filesystem roots are acquired by the
    /// daemon; credential-bearing remote sources use protected client batches.
    Mass,
    /// Force in-process scanning even when a daemon is running.
    Off,
}

impl DaemonMode {
    /// Whether this policy may open the Unix daemon transport. An explicit
    /// `Auto` or `On` therefore requires Unix; `Off` is portable.
    pub const fn may_use_daemon_transport(self) -> bool {
        !matches!(self, Self::Off)
    }
}

#[derive(Parser, Clone)]
pub struct ScanArgs {
    /// Detector TOML directory
    #[arg(short, long, default_value = "detectors")]
    pub detectors: PathBuf,
    #[arg(skip)]
    pub(crate) detectors_cli_explicit: bool,
    /// How an explicitly selected custom corpus participates in the embedded
    /// corpus. Omitted preserves the established replace behavior.
    #[arg(long, value_name = "MODE")]
    pub detectors_mode: Option<DetectorMode>,

    /// Path(s) to scan. Pass several to scan multiple roots in one run
    /// (`keyhog scan a/ b/ c/`); nested or duplicate roots fold into their
    /// covering parent. Positional shorthand for `--path` (single root only).
    #[arg(value_name = "PATH", conflicts_with = "path")]
    pub input: Vec<PathBuf>,

    /// Scan a directory or file
    #[arg(short, long)]
    pub path: Option<PathBuf>,

    /// Scan binary files for hardcoded strings
    #[cfg(feature = "binary")]
    #[arg(long)]
    pub binary: bool,

    /// Scan stdin
    #[arg(long)]
    pub stdin: bool,

    /// Shared stdin bytes retained while an automatic daemon request is in
    /// flight. An in-process retry scans bounded overlapping windows from this
    /// same allocation instead of copying the payload or rereading the pipe.
    #[arg(skip)]
    pub(crate) buffered_stdin: Option<std::sync::Arc<[u8]>>,

    /// Scan repository blobs from refs, reflogs, stashes, and unreachable objects. Commit blobs are collected by parent-tree diff (added, changed, and deleted sides); every ref tip under refs/ plus HEAD, root commits, and unreadable parents fall back to a full tree walk
    #[cfg(feature = "git")]
    #[arg(long)]
    pub git_blobs: Option<PathBuf>,

    /// Scan only changed lines between two git refs (e.g., --git-diff main)
    #[cfg(feature = "git")]
    #[arg(long, value_name = "BASE_REF")]
    pub git_diff: Option<String>,

    /// Scan reachable commits using added lines from each commit patch
    #[cfg(feature = "git")]
    #[arg(long, value_name = "PATH")]
    pub git_history: Option<PathBuf>,

    /// Scan exact staged index blobs, never substituted working-tree bytes
    #[cfg(feature = "git")]
    #[arg(long)]
    pub git_staged: bool,

    /// Path to git repository for --git-diff (defaults to current directory)
    #[cfg(feature = "git")]
    #[arg(long, requires = "git_diff")]
    pub git_diff_path: Option<PathBuf>,

    /// Scan all repositories in a GitHub organization
    #[cfg(feature = "github")]
    #[arg(long, value_name = "ORG")]
    pub github_org: Option<String>,

    /// GitHub repository whose explicitly selected collaboration surfaces are scanned
    #[cfg(feature = "github")]
    #[arg(long, value_name = "OWNER/REPO")]
    pub github_collaboration: Option<String>,
    /// Include every supported collaboration surface for --github-collaboration.
    /// This is the concise equivalent of passing all six --github-* surface flags.
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_all: bool,

    /// Include issue text and comments from --github-collaboration
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_issues: bool,

    /// Include pull request text, issue comments, and review comments
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_pull_requests: bool,

    /// Include discussion text and comments from --github-collaboration
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_discussions: bool,

    /// Include every readable wiki revision from --github-collaboration
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_wiki: bool,

    /// Include public gist revisions and comments for the repository owner
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_gists: bool,

    /// Include release notes, including drafts and prereleases, plus every
    /// release asset name and label, from --github-collaboration
    #[cfg(feature = "github")]
    #[arg(long, requires = "github_collaboration")]
    pub github_releases: bool,

    /// GitHub personal access token for --github-org or --github-collaboration. Prefer
    /// KEYHOG_GITHUB_TOKEN so the token is not exposed in the process list.
    #[cfg(feature = "github")]
    #[arg(long, value_name = "PAT")]
    pub github_token: Option<String>,

    /// GitHub-compatible API endpoint for --github-collaboration
    #[cfg(feature = "github")]
    #[arg(long, value_name = "URL")]
    pub github_api_endpoint: Option<String>,

    /// Explicit clone URL for the wiki selected by --github-wiki
    #[cfg(feature = "github")]
    #[arg(long, value_name = "URL", requires = "github_wiki")]
    pub github_wiki_url: Option<String>,

    /// Scan all projects in a GitLab group, including subgroups
    #[cfg(feature = "gitlab")]
    #[arg(long, value_name = "GROUP")]
    pub gitlab_group: Option<String>,

    /// GitLab personal access token for --gitlab-group. Prefer
    /// KEYHOG_GITLAB_TOKEN so the token is not exposed in the process list.
    #[cfg(feature = "gitlab")]
    #[arg(long, requires = "gitlab_group", value_name = "PAT")]
    pub gitlab_token: Option<String>,

    /// GitLab API endpoint root, for example https://gitlab.example.com
    #[cfg(feature = "gitlab")]
    #[arg(long, requires = "gitlab_group", default_value = "https://gitlab.com")]
    pub gitlab_endpoint: String,

    /// Scan all repositories in a Bitbucket Cloud workspace
    #[cfg(feature = "bitbucket")]
    #[arg(long, value_name = "WORKSPACE")]
    pub bitbucket_workspace: Option<String>,

    /// Bitbucket username for --bitbucket-workspace. May be supplied through
    /// KEYHOG_BITBUCKET_USERNAME.
    #[cfg(feature = "bitbucket")]
    #[arg(long, requires = "bitbucket_workspace", value_name = "USERNAME")]
    pub bitbucket_username: Option<String>,

    /// Bitbucket app password for --bitbucket-workspace. Prefer
    /// KEYHOG_BITBUCKET_TOKEN so the token is not exposed in the process list.
    #[cfg(feature = "bitbucket")]
    #[arg(long, requires = "bitbucket_workspace", value_name = "APP_PASSWORD")]
    pub bitbucket_token: Option<String>,

    /// Bitbucket Cloud API endpoint root
    #[cfg(feature = "bitbucket")]
    #[arg(
        long,
        requires = "bitbucket_workspace",
        default_value = "https://api.bitbucket.org/2.0"
    )]
    pub bitbucket_endpoint: String,

    /// Scan a public or path-style S3 bucket via ListObjectsV2
    #[cfg(feature = "s3")]
    #[arg(long, value_name = "BUCKET")]
    pub s3_bucket: Option<String>,

    /// Optional S3 object prefix to limit the scan
    #[cfg(feature = "s3")]
    #[arg(long, requires = "s3_bucket", value_name = "PREFIX")]
    pub s3_prefix: Option<String>,

    /// Optional S3 endpoint for S3-compatible APIs
    #[cfg(feature = "s3")]
    #[arg(long, requires = "s3_bucket", value_name = "URL")]
    pub s3_endpoint: Option<String>,

    /// Forward ambient AWS credentials to a custom S3 endpoint you trust.
    /// Off by default; AWS-owned endpoints do not need this. This flag is
    /// intentionally explicit because it can send AWS identity material to a
    /// third-party host.
    #[cfg(feature = "s3")]
    #[arg(long, requires = "s3_endpoint")]
    pub allow_s3_credential_forward: bool,

    /// Scan a Google Cloud Storage bucket via the JSON API
    #[cfg(feature = "gcs")]
    #[arg(long, value_name = "BUCKET")]
    pub gcs_bucket: Option<String>,

    /// Optional GCS object prefix to limit the scan
    #[cfg(feature = "gcs")]
    #[arg(long, requires = "gcs_bucket", value_name = "PREFIX")]
    pub gcs_prefix: Option<String>,

    /// Optional GCS endpoint override for compatible APIs or tests
    #[cfg(feature = "gcs")]
    #[arg(long, requires = "gcs_bucket", value_name = "URL")]
    pub gcs_endpoint: Option<String>,

    /// Forward the ambient GCS bearer token to a custom GCS endpoint you trust.
    /// Off by default; googleapis.com endpoints do not need this. This flag is
    /// intentionally explicit because it can send a bearer token to a
    /// third-party host.
    #[cfg(feature = "gcs")]
    #[arg(long, requires = "gcs_endpoint")]
    pub allow_gcs_token_forward: bool,

    /// Scan an Azure Blob Storage container URL. Include a SAS query string for private containers.
    #[cfg(feature = "azure")]
    #[arg(long, value_name = "URL")]
    pub azure_container_url: Option<String>,

    /// Optional Azure Blob prefix to limit the scan
    #[cfg(feature = "azure")]
    #[arg(long, requires = "azure_container_url", value_name = "PREFIX")]
    pub azure_prefix: Option<String>,

    /// Scan a Docker image by unpacking `docker image save`
    #[cfg(feature = "docker")]
    #[arg(long, value_name = "IMAGE")]
    pub docker_image: Option<String>,

    /// Scan JavaScript, source maps, or WASM binaries at URLs for secrets
    #[cfg(feature = "web")]
    #[arg(long, value_name = "URL", num_args = 1..)]
    pub url: Option<Vec<String>>,

    /// Route outbound HTTP through a proxy (`http://burp:8080`,
    /// `socks5://127.0.0.1:9050`, etc.). This flag (or its TOML
    /// equivalent) is the ONLY way to set a proxy: no environment
    /// variable is consulted, and ambient `HTTPS_PROXY` / `HTTP_PROXY`
    /// / `ALL_PROXY` is ignored, so a stray env proxy can never silently
    /// reroute secret-bearing traffic. When unset, no proxy is used.
    /// Pass `off` to make that explicit for air-gapped scans.
    #[cfg(any(
        feature = "web",
        feature = "slack",
        feature = "github",
        feature = "gitlab",
        feature = "bitbucket",
        feature = "s3",
        feature = "gcs",
        feature = "azure",
        feature = "verify"
    ))]
    #[arg(long, value_name = "URL")]
    pub proxy: Option<String>,

    /// Skip TLS certificate verification for every outbound HTTP
    /// request. Needed when scanning through Burp / mitmproxy /
    /// corporate-MITM CAs that present self-signed certificates.
    /// Off by default. This flag (or its TOML equivalent) is the ONLY
    /// way to disable verification: no environment variable can turn it
    /// off, so an ambient toggle can't silently expose secrets to a MITM.
    #[cfg(any(
        feature = "web",
        feature = "slack",
        feature = "github",
        feature = "gitlab",
        feature = "bitbucket",
        feature = "s3",
        feature = "gcs",
        feature = "azure",
        feature = "verify"
    ))]
    #[arg(long)]
    pub insecure: bool,

    /// Allow web, hosted-git, and cloud sources to reach an endpoint whose host,
    /// literal or DNS-resolved, is private, loopback, link-local, or
    /// cloud-metadata. OFF by default: the shared SSRF screen refuses every such
    /// endpoint. Enable ONLY for a trusted private-network deployment, such as an
    /// on-premises web application or self-hosted object store. This flag (or its
    /// `[http].allow_private_endpoint` TOML equivalent) is the ONLY way to relax
    /// the screen. No environment variable can silently turn KeyHog into an SSRF
    /// proxy for internal services.
    #[cfg(any(
        feature = "web",
        feature = "slack",
        feature = "github",
        feature = "gitlab",
        feature = "bitbucket",
        feature = "s3",
        feature = "gcs",
        feature = "azure",
        feature = "verify"
    ))]
    #[arg(long)]
    pub allow_private_cloud_endpoint: bool,

    /// Max git commits to traverse
    #[cfg(feature = "git")]
    #[arg(long)]
    pub max_commits: Option<usize>,

    /// Verify discovered credentials via API calls
    #[cfg(feature = "verify")]
    #[arg(long, conflicts_with = "no_verify")]
    pub verify: bool,

    /// Disable credential verification, overriding `verify = true` in `.keyhog.toml`
    #[arg(long)]
    pub no_verify: bool,

    /// Enable out-of-band callback verification via an embedded interactsh
    /// client. For webhook- and callback-shaped credentials, OOB verification
    /// proves the credential is exfil-capable: we mint a per-finding
    /// subdomain on the configured collector, embed it in the verification
    /// probe, and confirm the service actually called back. Off by default.
    /// See docs/src/reference/oob-verification.md for the threat model and
    /// self-hosting guidance.
    #[cfg(feature = "verify")]
    #[arg(long, requires = "verify")]
    pub verify_oob: bool,

    /// Interactsh server for OOB verification. Defaults to projectdiscovery's
    /// public collector at `oast.fun`. Use a self-hosted server for sensitive
    /// scans; the collector sees correlation IDs and the IPs of services
    /// that call back, never the credential itself. Only meaningful with
    /// `--verify-oob`; clap rejects the flag without it instead of silently
    /// ignoring it (the prior behavior gave false confidence that an
    /// override had been applied).
    #[cfg(feature = "verify")]
    #[arg(
        long,
        default_value = "oast.fun",
        value_name = "HOST",
        requires = "verify_oob"
    )]
    pub oob_server: String,

    /// Per-finding OOB wait timeout in seconds. Detector specs may set their
    /// own `timeout_secs`; this value is the global default. The upper bound
    /// is max(this value, 120s), so a detector can always wait at least 120s
    /// for a delayed webhook even when this default is lower. Lower = faster
    /// scans, higher = catches services with delayed webhooks (e.g., queued
    /// mail delivery). Requires `--verify-oob`.
    #[cfg(feature = "verify")]
    #[arg(
        long,
        default_value = "30",
        value_name = "SECS",
        requires = "verify_oob"
    )]
    pub oob_timeout: u64,

    /// Show full credentials (default: redacted)
    #[arg(long)]
    pub show_secrets: bool,

    /// Incremental scan: skip files whose metadata and content match the
    /// spec-bound Merkle index. The index is updated after successful scanning.
    /// This works in process and with `--daemon=mass` for daemon-local
    /// filesystem roots. If acquisition yields only unchanged files, backend
    /// routing and scanner dispatch do not start. Pass
    /// `--incremental-cache <path>` to override the default location.
    #[arg(long)]
    pub incremental: bool,

    /// Override the merkle-index cache file location.
    #[arg(long, value_name = "PATH", requires = "incremental")]
    pub incremental_cache: Option<PathBuf>,

    /// Override the Hyperscan compiled-database cache directory.
    ///
    /// This is explicit CLI/TOML configuration, not an environment variable:
    /// pass an absolute path under your home directory or the per-user keyhog
    /// temp cache root. Config: `[system].cache_dir` in `.keyhog.toml`; this
    /// flag overrides it.
    #[arg(long, value_name = "DIR")]
    pub cache_dir: Option<PathBuf>,

    /// Override the persistent autoroute calibration cache file.
    ///
    /// Use an absolute path, or `off` to disable persistence. Config:
    /// `[system].autoroute_cache` in `.keyhog.toml`; this flag overrides it.
    #[arg(long, value_name = "PATH|off")]
    pub autoroute_cache: Option<String>,

    /// Override the MatcherArtifact cache directory.
    ///
    /// Persists the eager compiled matcher graph across process invocations.
    /// This is distinct from `--cache-dir`, which only stores Hyperscan `.db`
    /// shards. Use an absolute directory, or `off` to disable. Config:
    /// `[system].matcher_cache` in `.keyhog.toml`; this flag overrides it.
    #[arg(long, value_name = "DIR|off")]
    pub matcher_cache: Option<String>,

    /// Explicit per-detector Bayesian calibration cache for confidence scoring.
    ///
    /// Normal scans are hermetic and ignore any default `keyhog calibrate`
    /// cache unless this flag or `[system].calibration_cache` supplies a path.
    /// The file must already exist and parse cleanly; damaged or missing
    /// explicit caches fail before scanning so score changes are reproducible.
    #[arg(long, value_name = "PATH")]
    pub calibration_cache: Option<PathBuf>,

    /// Run this scan as an explicit autoroute calibration probe: benchmark
    /// parity-checked backend candidates and persist the fastest-correct
    /// decision for each workload bucket. Normal scans never benchmark on cache
    /// miss; they use persisted evidence or fail closed without scanning. An
    /// explicit `--backend` is diagnostic only.
    #[arg(long)]
    pub autoroute_calibrate: bool,

    /// Output format. `json` is a bare findings array for pipelines; prefer
    /// `json-envelope` for scan status, coverage gaps, and backend recoveries
    /// in one document (KH-1435 / KH-1474).
    #[arg(long, default_value = "text", value_enum)]
    pub format: OutputFormat,
    #[arg(skip)]
    pub(crate) format_cli_explicit: bool,

    /// Show progress bar
    #[arg(long)]
    pub progress: bool,

    /// Suppress the interactive stderr chrome (banner, live progress ticker,
    /// and the "Scan complete" summary). Coverage FAIL/WARN lines and fatal
    /// errors are still printed so a quiet scan can never read as clean when it
    /// was not. Findings still go to stdout / `--output`. Mutually exclusive
    /// with `--progress`.
    #[arg(long, conflicts_with = "progress")]
    pub quiet: bool,

    /// Disable ANSI color in the report and the stderr summary, regardless of
    /// whether the output is a TTY (the `NO_COLOR` convention is also honored).
    #[arg(long)]
    pub no_color: bool,

    /// Emit a redacted `[stream]` preview line on stderr for every REPORTED
    /// finding (`SEVERITY  SERVICE/DETECTOR  PATH:LINE  redacted`), so a quick
    /// human- or CI-scrapeable summary lands on stderr while the full formatted
    /// report (text/json/sarif/jsonl) goes to stdout or `--output`. The preview
    /// stream is consistent with that report and the exit code: every streamed
    /// line corresponds to a finding that survived suppression, the confidence
    /// floor / `--min-confidence`, and baseline filtering, it never previews a
    /// match the report drops.
    #[arg(long)]
    pub stream: bool,

    /// Emit low-overhead stage, resource, build, policy, source, and measured workload identity evidence to stderr at scan end.
    #[arg(long)]
    pub profile: bool,

    /// Write the complete causal scan profile as JSON to `PATH` at scan end.
    /// Implies `--profile`; the artifact is written atomically.
    #[arg(long, value_name = "PATH")]
    pub profile_out: Option<std::path::PathBuf>,

    /// Raise `--profile` to its diagnostic level: add higher-overhead per-pattern, per-decoder, and backend timing traces on stderr.
    #[arg(long)]
    pub perf_trace: bool,

    /// Select persisted autoroute or explicitly force one diagnostic backend.
    /// Accepted values are listed below.
    #[arg(
        long,
        value_name = "BACKEND",
        value_parser = clap::builder::PossibleValuesParser::new(
            keyhog_scanner::hw_probe::BACKEND_OVERRIDE_VALUES
        )
    )]
    pub backend: Option<String>,

    /// Disable GPU probing and GPU backend acquisition for this scan.
    #[arg(long, conflicts_with = "require_gpu")]
    pub no_gpu: bool,

    /// Require a usable GPU stack before scanning and keep GPU execution as a
    /// hard contract; unavailable initialization or runtime dispatch exits 12.
    #[arg(long, conflicts_with = "no_gpu")]
    pub require_gpu: bool,

    /// Allow autoroute calibration to include GPU candidates for eligible
    /// workload buckets. Normal scans still use persisted calibration only.
    #[arg(long, conflicts_with = "no_autoroute_gpu")]
    pub autoroute_gpu: bool,

    /// Keep GPU candidates out of autoroute calibration even when TOML enables
    /// them.
    #[arg(long, conflicts_with = "autoroute_gpu")]
    pub no_autoroute_gpu: bool,

    /// Force the coalesced batch scan pipeline instead of the fused filesystem
    /// pipeline. This is an explicit calibration/diagnostic control, not an
    /// ambient environment switch. Config: `[system].batch_pipeline`; this flag
    /// overrides it.
    #[arg(long, conflicts_with = "no_batch_pipeline")]
    pub batch_pipeline: bool,

    /// Keep the fused filesystem pipeline even when `[system].batch_pipeline`
    /// is true.
    #[arg(long, conflicts_with = "batch_pipeline")]
    pub no_batch_pipeline: bool,

    /// Daemon routing: `auto` (default, use a live daemon for eligible warm
    /// requests), `on` (require the warm stdin/single-file route), `mass`
    /// (stream bounded directory, Git, archive, binary, remote, or cloud source
    /// batches to a daemon started with `daemon start --mass`), or `off`
    /// (force in-process). Bare `--daemon` means `on`. Startup and request
    /// latency depend on the corpus, backend, cache state, host, and input.
    /// See `keyhog daemon start --help`.
    ///
    /// Socket: the daemon route connects to the shared default resolution
    /// (`$XDG_RUNTIME_DIR`, then the OS cache directory, then the OS temporary
    /// directory) unless `--daemon-socket <path>` points it at a daemon bound
    /// elsewhere (`daemon start --socket <path>`).
    /// Unix only: Windows rejects explicit `auto` and `on`; explicit `off` is
    /// accepted as a portable declaration of in-process execution.
    ///
    #[arg(
        long,
        value_enum,
        num_args = 0..=1,
        require_equals = true,
        default_missing_value = "on",
        value_name = "auto|on|mass|off"
    )]
    pub daemon: Option<DaemonMode>,

    /// Connect the daemon route to a daemon bound on a non-default socket.
    ///
    /// By default `scan --daemon` uses `$XDG_RUNTIME_DIR/keyhog.sock`, then the
    /// OS user-cache directory, then the OS temporary directory. Pass the same
    /// path a daemon was started on
    /// (`keyhog daemon start --socket <path>`) to reach a fixed-location daemon
    /// (e.g. a shared/system or systemd-managed instance). Combining it with
    /// `--daemon=off` is rejected as contradictory.
    #[arg(long, value_name = "PATH")]
    pub daemon_socket: Option<PathBuf>,

    /// Write findings to file
    #[arg(short, long)]
    pub output: Option<PathBuf>,

    /// Write an internal composite-Action receipt bound to the completed report
    #[arg(long, value_name = "PATH", hide = true, requires = "output")]
    pub action_receipt: Option<PathBuf>,

    /// Per-request HTTP verification timeout in seconds (default: 5). This does
    /// not impose a deadline on scanning; use `--per-chunk-timeout-ms` for the
    /// scanner's optional chunk deadline.
    #[cfg(feature = "verify")]
    #[arg(long, requires = "verify")]
    pub timeout: Option<u64>,

    /// Maximum in-flight verification requests per service (default: 5).
    #[cfg(feature = "verify")]
    #[arg(
        long,
        requires = "verify",
        value_name = "N",
        value_parser = crate::value_parsers::parse_positive_usize
    )]
    pub verify_concurrency: Option<usize>,

    /// Steady-state cap for verification calls *per service*, in
    /// requests-per-second. Default 5.0. Drop this to be polite to
    /// upstream APIs when scanning a tree with hundreds of legitimate
    /// findings (test fixtures, examples); every finding produces a
    /// live verify call and most public APIs throttle aggressively.
    /// The limiter applies even with `--verify-batch` (which adds
    /// per-service serialisation on top).
    #[cfg(feature = "verify")]
    #[arg(
        long,
        requires = "verify",
        value_name = "RPS",
        default_value = "5.0",
        value_parser = crate::value_parsers::parse_verify_rate
    )]
    pub verify_rate: f64,

    /// Conservative verify mode: serialises live verifications per
    /// service (max-concurrent-per-service = 1) on top of the
    /// `--verify-rate` cap. Use for repos with lots of legitimate
    /// findings (test fixtures, vendored examples) where bursting a
    /// provider's auth endpoint would get the scan IP rate-limited
    /// or blocked. Implies `--verify`.
    #[cfg(feature = "verify")]
    #[arg(long, requires = "verify")]
    pub verify_batch: bool,

    /// Permit detector `script:` verification for trusted detector corpora.
    /// Off by default because scripts execute verifier-supplied code with
    /// credential-adjacent context. Prints an explicit warning when active.
    #[cfg(feature = "verify")]
    #[arg(long, requires = "verify")]
    pub allow_script_verify: bool,

    /// Min severity to report: info, client-safe, low, medium, high, critical
    #[arg(short, long, value_enum)]
    pub severity: Option<SeverityFilter>,

    /// Maximum file size to scan. Files larger than this are listed in
    /// the end-of-scan "files skipped: exceeded --max-file-size"
    /// summary. Default is 100 MiB, the `FilesystemSource` ceiling. Files
    /// above the 1 MiB window size are read in overlapping ~1 MiB windows
    /// (so memory stays bounded regardless of file size), up to this cap.
    #[arg(long, value_name = "SIZE", value_parser = crate::value_parsers::parse_byte_size)]
    pub max_file_size: Option<usize>,

    /// Per-regex lazy-DFA cache CEILING, e.g. "256KB" or "1MB" (default 1 MiB).
    /// Bounds the worst-case per-thread DFA cache for pathological/state-heavy
    /// patterns; typical detectors stay well under it, so lowering this does
    /// NOT meaningfully cut peak memory (it's a safety ceiling, not a general
    /// memory lever). Lowering can force complex regexes to slower NFA
    /// simulation; raise it only for unusually large patterns. Config:
    /// `regex_dfa_limit` in `.keyhog.toml`; this flag overrides it.
    #[arg(long, value_name = "SIZE", value_parser = crate::value_parsers::parse_byte_size)]
    pub regex_dfa_limit: Option<usize>,

    /// GPU batch-input buffer byte budget, e.g. "256MB" or "1GB". Overrides
    /// the VRAM-adaptive default (128 MiB–1 GiB by detected VRAM); the value is
    /// clamped into that range. Larger buffers scan more bytes per GPU dispatch
    /// on big inputs at higher VRAM cost. Config: `gpu_batch_input_limit` in
    /// `.keyhog.toml`; this flag overrides it.
    #[arg(long, value_name = "SIZE", value_parser = crate::value_parsers::parse_byte_size)]
    pub gpu_batch_input_limit: Option<usize>,

    #[command(flatten)]
    pub limits: SourceLimitArgs,

    /// Construct a compiled-in source by canonical name.
    #[arg(long, value_name = "NAME[:PARAMS]")]
    pub source: Option<Vec<String>>,

    /// Fast mode: pattern matching only. No decode, no entropy, no ML scoring.
    /// Maximum speed. A preset is a BASE: it seeds defaults, then compatible
    /// explicit knobs override it (e.g. `--fast --decode-depth 2` re-enables
    /// shallow decode on top of the fast base). Entropy-only knobs conflict
    /// because fast mode disables entropy, so accepting them would create a
    /// no-op flag.
    #[arg(
        long,
        conflicts_with_all = [
            "deep",
            "precision",
            "no_decode",
            "no_entropy",
            "no_entropy_ml_scoring",
            "no_keyword_low_entropy",
            "entropy_threshold",
            "entropy_source_files",
            "min_secret_len"
        ]
    )]
    pub fast: bool,

    /// Deep recovery mode: scans entropy candidates in source files, removes
    /// comment confidence penalties, keeps heuristic evidence alongside ML for
    /// entropy candidates, sets decode depth 10, and admits one 1 MiB chunk into
    /// decode-through. Compatible explicit knobs override this BASE.
    #[arg(long, conflicts_with_all = ["fast", "precision", "no_decode", "no_entropy"])]
    pub deep: bool,

    /// High-precision mode for mass scanning: minimise false positives at the
    /// cost of some recall. Disables entropy discovery and the relaxed keyword
    /// bridge, retains ML scoring for remaining candidates, raises the minimum
    /// confidence floor to 0.85, and uses decode depth 1. Explicit confidence
    /// flags may tighten but cannot lower that floor. Entropy-only knobs conflict
    /// because precision mode disables entropy.
    #[arg(
        long,
        conflicts_with_all = [
            "fast",
            "deep",
            "no_decode",
            "no_entropy",
            "no_entropy_ml_scoring",
            "no_keyword_low_entropy",
            "entropy_threshold",
            "entropy_source_files",
            "min_secret_len"
        ]
    )]
    pub precision: bool,

    /// Lockdown mode: maximum security at the cost of throughput. Enables
    /// every protection in `keyhog_core::apply_protections(true)`
    /// (mlock, refuse-on-coredump-leak, refuse-on-disk-cache), forces
    /// HTTPS-only verifier, refuses to write any cache to disk, and
    /// hard-aborts if any protection fails to take. Use this when keyhog
    /// is running inside EnvSeal or otherwise in a security-critical
    /// embedding.
    #[arg(long)]
    pub lockdown: bool,

    /// Skip decoding base64/hex encoded content
    #[arg(long)]
    pub no_decode: bool,

    /// Disable entropy-based detection
    #[arg(long)]
    pub no_entropy: bool,

    /// Score entropy-discovery candidates with the bare entropy heuristic instead
    /// of routing them through the MoE (the model is authoritative by default).
    /// The default ML path is a recall-safe precision win on the
    /// detector-owned model mode; this opt-out selects bare entropy-only
    /// scoring. It does not change detector policy and has no effect when
    /// `--no-entropy` or `--no-ml` is set.
    #[arg(long)]
    pub no_entropy_ml_scoring: bool,

    /// Disable the lower-floor `generic-keyword-secret` bridge for anchored
    /// values (`PASSWORD=`, `*_PASS=`, `secret:`, `api_key=` ...). Anchored
    /// candidates must then satisfy the stricter `generic-secret` policy. No
    /// effect unless the generic keyword bridge would otherwise fire.
    #[arg(long)]
    pub no_keyword_low_entropy: bool,

    /// Raise the global confidence floor (0.0 to 1.0). Takes effect as
    /// `max(min_confidence, ml_threshold)`, so it tightens but never loosens
    /// the floor set by `--min-confidence`. Despite the name, this raises the
    /// floor for ALL findings, not only ML-scored ones, and still applies when
    /// `--no-ml` disables ML scoring. A detector's explicit `min_confidence`
    /// in its TOML remains that detector's effective floor. Absence leaves the
    /// canonical floor untouched.
    #[arg(
        long,
        value_name = "THRESHOLD",
        value_parser = crate::value_parsers::parse_ml_threshold
    )]
    pub ml_threshold: Option<f64>,

    /// Minimum confidence score (0.0 - 1.0) to report findings (default: 0.40).
    #[arg(long, value_name = "FLOAT", value_parser = crate::value_parsers::parse_min_confidence)]
    pub min_confidence: Option<f64>,

    /// Number of parallel scanning threads (default: number of CPU cores)
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_positive_thread_count)]
    pub threads: Option<usize>,

    /// Dedicated filesystem reader threads. Default is one direct reader.
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_positive_usize)]
    pub reader_threads: Option<usize>,

    /// Fused filesystem pipeline chunk batch size.
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_positive_usize)]
    pub fused_batch: Option<usize>,

    /// Fused filesystem pipeline channel depth.
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_positive_usize)]
    pub fused_depth: Option<usize>,

    /// Hard deadline per chunk scan in milliseconds. Default unset = no
    /// operator deadline; decode still has its internal bomb guard.
    #[arg(long, value_name = "MS", value_parser = crate::value_parsers::parse_positive_millis)]
    pub per_chunk_timeout_ms: Option<u64>,

    /// Deduplication scope for findings.
    #[arg(long, default_value_t = CliDedupScope::Credential, value_enum)]
    pub dedup: CliDedupScope,
    #[arg(skip)]
    pub(crate) dedup_cli_explicit: bool,

    /// Load configuration from a specific file path.
    #[arg(long, value_name = "PATH")]
    pub config: Option<PathBuf>,

    /// Ignore any ambient `.keyhog.toml`: skip the walk-up discovery from the
    /// scan root and reject an explicit `--config`. The scan then runs on the
    /// compiled-in shipped defaults (the Tier-A `SHIPPED_*` floors/disables)
    /// and nothing else. This is the hermetic, reproducible config used by CI
    /// gates and the benchmark harness, so the measured behavior is the shipped
    /// default BY DESIGN and cannot silently drift when a stray `.keyhog.toml`
    /// appears on an ancestor path; the hermetic-config tests pin that contract.
    #[arg(long, conflicts_with = "config")]
    pub no_config: bool,

    /// Suppress findings that match an existing baseline file
    #[arg(long, value_name = "PATH", conflicts_with_all = ["create_baseline", "update_baseline"])]
    pub baseline: Option<PathBuf>,

    /// Create a new baseline file from current findings and exit
    #[arg(long, value_name = "PATH", conflicts_with_all = ["baseline", "update_baseline"])]
    pub create_baseline: Option<PathBuf>,

    /// Update an existing baseline file with new findings
    #[arg(long, value_name = "PATH", conflicts_with_all = ["baseline", "create_baseline"])]
    pub update_baseline: Option<PathBuf>,

    /// Maximum depth for recursive decoding (1-10, default: 10).
    #[arg(long, value_name = "DEPTH", value_parser = crate::value_parsers::parse_decode_depth)]
    pub decode_depth: Option<usize>,

    /// Maximum prepared chunk size admitted to decode-through (default: 512KB).
    #[arg(long, value_name = "SIZE", value_parser = crate::value_parsers::parse_byte_size)]
    pub decode_size_limit: Option<usize>,

    /// Enable entropy scanning in source code files.
    #[arg(long)]
    pub entropy_source_files: bool,

    /// Disable every default exclusion for this scan.
    ///
    /// Two separate defaults are turned off. The walker stops skipping lock
    /// files, minified and bundled assets, build outputs, and vendored trees,
    /// so their bytes are read. The scanner also stops dropping findings whose
    /// path is a minified or vendored bundle (`.min.js`, `.bundle.js`,
    /// `.min.css`, `node_modules/`, `site-packages/`, `wp-includes/`, and
    /// similar), so a credential a build pipeline inlined into `app.min.js` is
    /// reported instead of silently discarded.
    ///
    /// Expect more noise: random byte sequences in third-party bundles do
    /// collide with credential shapes. Without this flag, findings dropped by
    /// the second rule are counted and reported as a coverage gap, so you can
    /// see how many there were before deciding to rerun.
    #[arg(long)]
    pub no_default_excludes: bool,

    /// Explicit paths or glob patterns to exclude from scanning.
    #[arg(long, value_name = "PATH", num_args = 1..)]
    pub exclude_paths: Option<Vec<String>>,

    /// Entropy threshold in bits per byte (default: 4.5).
    #[arg(
        long,
        value_name = "BITS",
        allow_hyphen_values = true,
        value_parser = crate::value_parsers::parse_entropy_threshold
    )]
    pub entropy_threshold: Option<f64>,

    /// BPE "rare-not-random" suppression bound in bytes-per-token (default: 2.2).
    /// A surviving entropy/generic candidate whose cl100k_base bytes-per-token is
    /// above this is treated as word-like (dotted API paths, prose) and dropped.
    /// Lower = more aggressive suppression (higher precision, lower recall);
    /// a large value effectively disables the gate.
    #[arg(
        long,
        value_name = "RATIO",
        allow_hyphen_values = true,
        value_parser = crate::value_parsers::parse_entropy_bpe_max_bytes_per_token
    )]
    pub entropy_bpe_max_bytes_per_token: Option<f64>,

    /// Minimum credential length for entropy-discovery candidates (default: 16).
    /// Named detectors keep their own shape-specific length gates.
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_min_secret_len)]
    pub min_secret_len: Option<usize>,

    /// Disable Unicode normalization (not recommended).
    #[arg(long)]
    pub no_unicode_norm: bool,

    /// Disable ML-based confidence scoring.
    #[arg(long)]
    pub no_ml: bool,

    /// Opt out of the bundled test-fixture suppression list. By default
    /// keyhog suppresses well-known public demo credentials (Stripe's
    /// docs example `sk_live_4eC39...`, GitHub's docs example
    /// `ghp_aBcD...`, the keyhog test fixtures, etc.) so the report
    /// stays focused on real leaks rather than tutorial copies. Pass
    /// this flag when you intentionally want those surfaced. Useful
    /// for differential benchmarking against gitleaks / trufflehog
    /// (which do NOT suppress these), or for auditing the suppression
    /// list itself.
    #[arg(long)]
    pub no_suppress_test_fixtures: bool,

    /// Report cross-file credential correlations alongside the findings.
    ///
    /// Joins one credential value seen at several file paths, across the
    /// detector boundary that per-detector dedup never crosses, and provider
    /// credentials whose halves are separate detectors split across files of
    /// one directory (an AWS access key in `main.tf`, its secret in `.env`).
    /// Which providers have halves is Tier-B data, not a hardcoded list, and
    /// an ambiguous directory reports nothing rather than a guess.
    ///
    /// Additive only: `--format json-envelope` gains a `correlations` array
    /// and `--format text` a summary block. Findings and every other format
    /// are unchanged, so a default scan is byte-identical without this flag.
    #[arg(long)]
    pub correlate: bool,

    /// Report the resource each credential opens (its "door").
    ///
    /// A finding says where a credential is. It does not say which database,
    /// bucket, tenant, or account that credential reaches, which is the first
    /// thing a responder needs in order to rank it. The address almost always
    /// sits next to the credential (in the same connection string, the same
    /// `.env`, the same variable block) and no detector can see it: a companion
    /// regex is bounded to a few lines and is written to capture the other half
    /// of the CREDENTIAL, not the resource.
    ///
    /// This pass runs after the scan, over the findings the report is about to
    /// publish, and attaches typed targets: `account`, `tenant`, `endpoint`,
    /// `database`, `resource`. Which providers are understood is Tier-B data
    /// (`crates/core/data/access-targets.toml`), not a hardcoded list.
    ///
    /// Redaction-safe by construction. Connection-string rules skip userinfo
    /// with a non-capturing group, any candidate whose digest matches a
    /// credential in the same report is dropped, and evidence carries only the
    /// rule id, line, column, span length, and line distance. No document text
    /// is ever emitted.
    ///
    /// Bounded: file context is indexed at most once per file, over at most
    /// 1 MiB of it, under a 256 MiB whole-pass ceiling. Findings the pass could
    /// not inspect (git history, container layers, stdin, unreadable paths) are
    /// reported as coverage gaps, so an empty target list never reads as "this
    /// credential opens nothing".
    ///
    /// Purely additive: findings are never added, dropped, reordered, or
    /// edited. `--format json-envelope` gains an `access_targets` object; every
    /// other format is untouched. Default off, so a report produced without
    /// this flag is byte-identical.
    #[arg(long)]
    pub access_targets: bool,

    /// Run the built-in backend benchmark corpus and exit.
    ///
    /// This measures backend throughput over KeyHog's own corpus; it never scans
    /// an operator-supplied target and never writes a report. Passing a scan
    /// target (`PATH`, `--path`, `--stdin`) or a report destination
    /// (`--output`) alongside it used to exit 0 having silently ignored both, so
    /// an operator could read "benchmark winner: ..." as a completed scan of
    /// their tree. Those combinations now fail closed with the conflict named.
    #[arg(
        long,
        conflicts_with_all = ["input", "path", "stdin", "output"]
    )]
    pub benchmark: bool,

    /// Emit a structured `--dogfood` JSON trace to stderr after the
    /// scan: every credential that was matched but suppressed, with the
    /// reason, both example/test/placeholder markers
    /// (`kind: example_suppressed`) AND shape/heuristic gates such as
    /// UUID-v4, bare-hex digest, base64 blob, dashed serial, or repetitive
    /// run (`kind: shape_suppressed`, `reason` names the gate), plus bounded
    /// static-recovery expressions rejected as malformed
    /// (`kind: static_recovery_rejected`). Detail events are bounded; exact
    /// aggregate rejection counts and `detail_events_dropped` remain visible
    /// after the bound is reached. Credentials are redacted (prefix and suffix
    /// shown, middle elided), and recovery rejections contain no source bytes.
    /// Useful when keyhog reports
    /// zero findings and you want to know whether a match was made and
    /// silenced, recovery rejected an expression, or the candidate never
    /// reached the engine.
    #[arg(long)]
    pub dogfood: bool,

    /// Override every detector's ML scoring weight for diagnostics/benchmarks.
    #[arg(long, value_name = "WEIGHT", value_parser = crate::value_parsers::parse_ml_weight)]
    pub ml_weight: Option<f64>,

    /// Drop every `client-safe` finding before reporting. Use this
    /// for bug-bounty / exfiltration-impact workflows where keys that
    /// are public by design (Sentry DSN, Stripe `pk_*`, Firebase web,
    /// Mapbox `pk.`, PostHog project, Google Maps browser, Mixpanel
    /// project, Algolia search, Datadog browser RUM) are noise: the
    /// vendor *expects* them to ship in client bundles and no
    /// attacker gains server-side access from finding one.
    ///
    /// Default off: client-safe findings still appear in scan output
    /// at the `CLIENT-SAFE` tier (below `LOW`) so a misconfigured
    /// "publishable" key wired into a server-only detector still
    /// surfaces. `--hide-client-safe` is the explicit opt-in to
    /// silence them.
    #[arg(long)]
    pub hide_client_safe: bool,

    /// Treat credentials inside source-code comments (// … / # … /
    /// /* … */ / <!-- … -->) as first-class findings instead of
    /// applying the default comment-context confidence penalty.
    ///
    /// By default keyhog downgrades the confidence of credentials it
    /// sees inside a comment because the most common case is an
    /// engineer pasting an EXAMPLE token into a doc comment. The
    /// drawback is that genuine secrets pasted into a TODO ("rotate
    /// this key, Bob") or a debug-trace comment never surface.
    /// Pass `--scan-comments` for repos where comments are part of
    /// the threat surface: shared snippets directories, leak
    /// post-mortems, training corpora, and CTF-style audits.
    #[arg(long)]
    pub scan_comments: bool,

    /// Known secret prefixes (internal use for config merge)
    #[arg(skip)]
    pub known_prefixes: Vec<String>,
    /// Secret keywords (internal use for config merge)
    #[arg(skip)]
    pub secret_keywords: Vec<String>,
    /// Test keywords (internal use for config merge)
    #[arg(skip)]
    pub test_keywords: Vec<String>,
    /// Placeholder keywords (internal use for config merge)
    #[arg(skip)]
    pub placeholder_keywords: Vec<String>,
}

impl ScanArgs {
    pub(crate) fn mark_cli_value_sources(&mut self, matches: &clap::ArgMatches) {
        self.detectors_cli_explicit =
            matches.value_source("detectors") == Some(ValueSource::CommandLine);
        self.format_cli_explicit = matches.value_source("format") == Some(ValueSource::CommandLine);
        self.dedup_cli_explicit = matches.value_source("dedup") == Some(ValueSource::CommandLine);
    }
}

impl ScanArgs {
    /// Resolve the effective daemon routing policy (CLI-02).
    #[must_use]
    pub fn daemon_mode(&self) -> DaemonMode {
        self.daemon.unwrap_or(DaemonMode::Auto) // LAW10: absent config => documented default; Tier-A knob, recall-irrelevant
    }

    /// The ordered set of filesystem roots this invocation scans, the single
    /// source of truth for "which paths" across source construction, daemon
    /// routing, and the scan-target header.
    ///
    /// Positional roots live in one vector, so Clap's generated usage, parsing,
    /// daemon eligibility, and source construction share the same model. The
    /// positional vector wins over the orchestrator's internal first-root copy
    /// into `path`; Clap guarantees a user cannot combine positional roots with
    /// the explicit single-root `--path` flag.
    #[must_use]
    pub fn scan_roots(&self) -> Vec<PathBuf> {
        if !self.input.is_empty() {
            return self.input.clone();
        }
        self.path.clone().into_iter().collect()
    }
}