Skip to main content

keyhog_verifier/
lib.rs

1//! Live credential verification: confirms whether detected secrets are actually
2//! active by making HTTP requests to the service's API endpoint as specified in
3//! each detector's `[detector.verify]` configuration.
4
5#![doc = include_str!("../README.md")]
6#![allow(clippy::too_many_arguments)]
7#![allow(clippy::type_complexity)]
8
9mod bogon;
10/// Shared in-memory verification cache.
11mod cache;
12mod domain_allowlist;
13mod interpolate;
14pub mod oob;
15pub mod rate_limit;
16pub mod sigv4;
17pub mod ssrf;
18mod verify;
19
20use std::collections::HashMap;
21use std::sync::atomic::AtomicUsize;
22use std::sync::Arc;
23use std::time::Duration;
24
25use dashmap::DashMap;
26use keyhog_core::{DedupedMatch, DetectorSpec, VerificationResult, VerifiedFinding};
27
28// Re-export dedup types from core so existing consumers (`use keyhog_verifier::DedupedMatch`)
29// continue to work without source changes.
30pub use keyhog_core::{dedup_matches, DedupScope};
31use reqwest::{Client, Error as ReqwestError};
32use thiserror::Error;
33use tokio::sync::{Notify, Semaphore};
34
35/// Errors returned while constructing or executing live verification.
36#[derive(Debug, Error)]
37pub enum VerifyError {
38    #[error(
39        "failed to send HTTP request: {0}. Fix: check network access, proxy settings, and the verification endpoint"
40    )]
41    Http(#[from] ReqwestError),
42    #[error(
43        "failed to build configured HTTP client: {0}. Fix: use a valid timeout and supported TLS/network configuration"
44    )]
45    ClientBuild(ReqwestError),
46    #[error(
47        "invalid verifier proxy configuration: {0}. Fix: use a valid http://, https://, or socks5:// URL, or set 'off' to disable proxying entirely"
48    )]
49    ProxyConfig(String),
50    #[error(
51        "failed to resolve verification field: {0}. Fix: use `match` or `companion.<name>` fields that exist in the detector spec"
52    )]
53    FieldResolution(String),
54    #[error(
55        "invalid detector verification response contract: {0}. Fix: correct the owning detector TOML before enabling live verification"
56    )]
57    DetectorConfig(String),
58}
59
60/// Live-verification engine with shared client, cache, and concurrency limits.
61pub struct VerificationEngine {
62    client: Client,
63    detectors: Arc<HashMap<Arc<str>, DetectorSpec>>,
64    /// Per-service concurrency limit to avoid hammering APIs.
65    service_semaphores: Arc<HashMap<Arc<str>, Arc<Semaphore>>>,
66    /// Configured per-service concurrency, reused as the fallback bound when a
67    /// group's service is absent from `service_semaphores`. Single owner for the
68    /// value (no second hardcoded default that could silently diverge).
69    pub(crate) max_concurrent_per_service: usize,
70    /// Global concurrency limit.
71    global_semaphore: Arc<Semaphore>,
72    timeout: Duration,
73    /// Response cache to avoid re-verifying the same credential.
74    cache: Arc<cache::VerificationCache>,
75    /// One in-flight request per complete hashed verification identity.
76    /// Companion values participate because detector TOML may interpolate them
77    /// into authentication, tenant, account, or endpoint fields.
78    pub(crate) inflight: Arc<DashMap<cache::VerificationIdentity, Arc<Notify>>>,
79    pub(crate) inflight_count: Arc<AtomicUsize>,
80    pub(crate) max_inflight_keys: usize,
81    pub(crate) danger_allow_private_ips: bool,
82    pub(crate) danger_allow_http: bool,
83    /// Mirrors `VerifyConfig.insecure_tls`. The base `client` is built
84    /// with `danger_accept_invalid_certs(insecure_tls)`, but the
85    /// per-request DNS-pinning rebuild path needs the bool itself so
86    /// it can match the base client's posture. See
87    /// `verify/request.rs::resolved_client_for_url`.
88    pub(crate) insecure_tls: bool,
89    /// Snapshot of "was the base client built with a proxy" - propagated
90    /// to per-request rebuild paths so they skip the rebuild (which would
91    /// strip the proxy). See `verify/request.rs::resolved_client_for_url`.
92    pub(crate) proxy_in_use: bool,
93    /// Script-auth policy bit captured from [`VerifyConfig`]. Defaults false;
94    /// only the visible CLI flag may turn it on.
95    pub(crate) allow_script_verify: bool,
96    /// Optional OOB session. When `Some`, detectors with `[detector.verify.oob]`
97    /// receive a per-finding callback URL and the engine waits for the
98    /// service to call back. When `None`, those detectors fail closed with a
99    /// verification error before any HTTP probe is sent. Set via
100    /// [`VerificationEngine::enable_oob`].
101    pub(crate) oob_session: Option<Arc<oob::OobSession>>,
102}
103
104/// Runtime configuration for live verification.
105///
106/// Config-surface boundary: `VerifyConfig` is an **orthogonal subsystem**
107/// config, NOT part of the detection/bench config surface. Only
108/// `ScanConfig` + `ScannerConfig` (+ nested `MultilineConfig`) influence
109/// detection accuracy and are exercised by the benchmark. `VerifyConfig`
110/// governs live HTTP verification (network I/O, concurrency, proxy, TLS)
111/// and is constructed only on the `--verify` path
112/// (`cli/src/orchestrator/postprocess.rs`); the bench runs with
113/// `--no-verification` and never touches it. The sibling orthogonal configs
114/// are `OobConfig` (verifier/src/oob/session.rs, `--verify-oob` only),
115/// `HttpClientConfig` (sources/src/http.rs, per-source network I/O),
116/// `MegakernelSessionConfig` (scanner GPU slot geometry), and
117/// `AwsSigV4Config` (S3 request signing). Do NOT fold any of these into the
118/// canonical scan config: they are legitimately separate axes.
119pub struct VerifyConfig {
120    /// End-to-end timeout for one verification attempt.
121    pub timeout: Duration,
122    /// Maximum concurrent requests allowed per service.
123    pub max_concurrent_per_service: usize,
124    /// Maximum concurrent verification tasks overall.
125    pub max_concurrent_global: usize,
126    /// Upper bound for distinct in-flight deduplication keys.
127    pub max_inflight_keys: usize,
128    /// Whether to skip SSRF protection for private IP addresses.
129    pub danger_allow_private_ips: bool,
130    /// Whether to allow plaintext HTTP verification URLs. Default `false`:
131    /// production paths must use HTTPS so credentials are never sent in the
132    /// clear. Test fixtures (mock HTTP servers, in-memory listeners) opt in.
133    pub danger_allow_http: bool,
134    /// Explicit upstream proxy URL applied to every verifier request and OOB
135    /// poll, set ONLY by `--proxy` / TOML. `None` means no proxy and also
136    /// neutralizes reqwest's ambient proxy-env detection; no environment
137    /// variable is consulted (config-policy mandate + security: an ambient proxy
138    /// must never silently reroute secret-bearing traffic). The literal
139    /// `"off"`/`"none"` sentinels disable proxying explicitly.
140    pub proxy: Option<String>,
141    /// Accept invalid / self-signed TLS certs for verifier + OOB traffic.
142    /// Off by default. Required when intercepting through a MITM proxy
143    /// (Burp, mitmproxy) that re-signs HTTPS with its own CA.
144    pub insecure_tls: bool,
145    /// Permit `AuthSpec::Script` verification. Off by default because detector
146    /// TOML can otherwise execute verifier-supplied code with credential
147    /// context. The CLI sets this only from the visible `--allow-script-verify`
148    /// flag and prints a warning when active; no environment variable can
149    /// weaken the policy.
150    pub allow_script_verify: bool,
151}
152
153impl Default for VerifyConfig {
154    fn default() -> Self {
155        Self {
156            timeout: Duration::from_secs(5),
157            max_concurrent_per_service: 5,
158            max_concurrent_global: 20,
159            max_inflight_keys: 10_000,
160            danger_allow_private_ips: false,
161            danger_allow_http: false,
162            proxy: None,
163            insecure_tls: false,
164            allow_script_verify: false,
165        }
166    }
167}
168
169/// Resolve a proxy spec into an applied `reqwest::ClientBuilder`. ONLY the
170/// explicit value (from `--proxy` / TOML) is honored, no environment variable
171/// is consulted, and when no proxy is configured the builder is given
172/// `.no_proxy()` so reqwest's ambient proxy-env detection cannot silently
173/// reroute secret-bearing verification + OOB traffic (config-policy mandate +
174/// security). The `"off"`/`"none"`/`""` sentinels also
175/// disable proxying. Shared by the verifier client and the OOB client so both
176/// carry the identical, env-free contract.
177pub(crate) fn apply_proxy_config(
178    builder: reqwest::ClientBuilder,
179    explicit: Option<&str>,
180) -> Result<reqwest::ClientBuilder, String> {
181    match resolve_proxy_mode(explicit) {
182        ProxyMode::Disabled => Ok(builder.no_proxy()),
183        ProxyMode::Explicit(url) => {
184            let parsed = url::Url::parse(&url).map_err(|_| invalid_proxy_url_diagnostic(None))?;
185            if !matches!(parsed.scheme(), "http" | "https" | "socks5")
186                || parsed.host_str().is_none()
187            {
188                return Err(invalid_proxy_url_diagnostic(Some(&parsed)));
189            }
190
191            // Deliberately discard reqwest's parser error and its source chain:
192            // either may repeat the original URL, including userinfo or query
193            // secrets. The diagnostic below contains only URL-parser-normalized
194            // scheme/host components.
195            let proxy = reqwest::Proxy::all(&url)
196                .map_err(|_| invalid_proxy_url_diagnostic(Some(&parsed)))?;
197            Ok(builder.proxy(proxy))
198        }
199    }
200}
201
202/// Build the only diagnostic emitted for a rejected verifier proxy URL.
203///
204/// A successful `url` parse makes its normalized scheme and host safe to name.
205/// Userinfo, path, query, and fragment are never copied. If parsing did not
206/// establish a host, echo none of the untrusted input.
207fn invalid_proxy_url_diagnostic(parsed: Option<&url::Url>) -> String {
208    if let Some((scheme, host)) =
209        parsed.and_then(|url| url.host_str().map(|host| (url.scheme(), host)))
210    {
211        format!("invalid verifier proxy URL (scheme `{scheme}`, host `{host}`)")
212    } else {
213        "invalid verifier proxy URL".to_owned()
214    }
215}
216
217enum ProxyMode {
218    Disabled,
219    Explicit(String),
220}
221
222/// Map an explicit proxy spec to a mode. No environment variable is read: an
223/// unset proxy (`None`) disables proxying entirely, which also neutralizes
224/// reqwest's ambient env-proxy detection via `.no_proxy()`.
225fn resolve_proxy_mode(explicit: Option<&str>) -> ProxyMode {
226    match explicit {
227        Some(raw) => proxy_mode_from_raw(raw),
228        None => ProxyMode::Disabled,
229    }
230}
231
232fn proxy_mode_from_raw(raw: &str) -> ProxyMode {
233    match raw {
234        "off" | "none" | "" => ProxyMode::Disabled,
235        url => ProxyMode::Explicit(url.to_string()),
236    }
237}
238
239/// Returns true iff an explicit proxy is configured (and not a disable
240/// sentinel). No environment variable is consulted, neither the old keyhog
241/// proxy env var nor reqwest's ambient proxy-env vars, because those are
242/// neutralized via `.no_proxy()` and can never route verifier traffic. This is
243/// the signal `resolved_client_for_url()` uses to decide whether to apply DNS
244/// pinning: with no proxy active it pins (SSRF / DNS-rebinding protection on the
245/// direct connection); with an explicit proxy the proxy resolves DNS, so pinning
246/// is skipped. Because an ambient proxy is now impossible, the old hazard of a
247/// pinned rebuild silently dropping an env-proxy (and connecting direct, past
248/// the operator's interception) cannot occur.
249pub fn proxy_is_active(explicit: Option<&str>) -> bool {
250    matches!(resolve_proxy_mode(explicit), ProxyMode::Explicit(_))
251}
252
253/// Scheme default reused wherever a verifier/OOB URL is resolved to a
254/// `host:port` for DNS screening. ONE owner for the `.unwrap_or(443)` that was
255/// pasted at every `port_or_known_default()` call site.
256pub(crate) const DEFAULT_HTTPS_PORT: u16 = 443;
257
258/// Apply the security-critical decompression + redirect posture EVERY verifier
259/// and OOB reqwest client must carry, from ONE definitional home. Decompression
260/// is disabled so the streaming body cap measures real wire bytes
261/// (decompression-bomb defense); redirects are refused so a public host cannot
262/// 302 to a private IP past the pre-connect SSRF screen. Shared by the base
263/// verifier client, the DNS-pinned per-request rebuild, and the OOB collector
264/// client so the posture can never diverge between them.
265pub(crate) fn harden_verifier_client_builder(
266    builder: reqwest::ClientBuilder,
267) -> reqwest::ClientBuilder {
268    builder
269        .no_gzip()
270        .no_brotli()
271        .no_zstd()
272        .no_deflate()
273        .redirect(reqwest::redirect::Policy::none())
274}
275
276/// Build a DNS-pinned reqwest client carrying the full verifier posture
277/// (hardened decompression/redirect + `no_proxy` + host→addr pin). ONE owner for
278/// the two byte-identical pinned rebuilds (per-request verify + OOB collector);
279/// each caller maps the reqwest build error into its own fail-closed refusal.
280pub(crate) fn build_pinned_verifier_client(
281    host: &str,
282    pinned_addrs: &[std::net::SocketAddr],
283    timeout: std::time::Duration,
284    insecure_tls: bool,
285) -> Result<reqwest::Client, reqwest::Error> {
286    harden_verifier_client_builder(
287        reqwest::Client::builder()
288            .timeout(timeout)
289            .danger_accept_invalid_certs(insecure_tls)
290            .no_proxy(),
291    )
292    .resolve_to_addrs(host, pinned_addrs)
293    .build()
294}
295
296/// Convert a [`DedupedMatch`] into a [`VerifiedFinding`] with the given verification result.
297pub(crate) fn into_finding(
298    group: DedupedMatch,
299    verification: VerificationResult,
300    mut metadata: HashMap<String, String>,
301) -> VerifiedFinding {
302    // Severity shift on verification (docs/src/verification.md "Severity shift"
303    // table; docs/src/first-scan.md "downgraded one"). A credential the provider
304    // rejects (`Dead`) or has explicitly revoked (`Revoked`) is still a leak, a
305    // developer typed it into a file once, but it is strictly less urgent than a
306    // credential an attacker can authenticate with right now. Drop exactly one
307    // severity tier (`critical → high`, `high → medium`, …) via the canonical
308    // `Severity::downgrade_one`; never collapse to a fixed level. `Live` keeps
309    // the detector's declared severity (it really is what it claims to be), and
310    // every non-conclusive result (`Error`/`RateLimited`/`Unverifiable`/
311    // `Skipped`) is treated as unverified and leaves severity unchanged.
312    let severity = match verification {
313        VerificationResult::Dead | VerificationResult::Revoked => group.severity.downgrade_one(),
314        _ => group.severity,
315    };
316    let credential = group.credential.as_ref();
317    // Backstop every live and cached path against a misclassified credential echo.
318    metadata.retain(|_, value| {
319        (credential.is_empty() || !value.contains(credential))
320            && group
321                .companions
322                .values()
323                .all(|secret| secret.is_empty() || !value.contains(secret))
324    });
325    VerifiedFinding::from_deduped(group, severity, verification, metadata)
326}
327
328/// Hidden hooks for integration tests. Not covered by semver.
329#[doc(hidden)]
330pub mod testing {
331    use std::collections::HashMap;
332    use std::sync::Arc;
333    use std::time::Duration;
334
335    pub use crate::cache::oldest_eviction_batch;
336    pub use crate::interpolate::{missing_companion_refs, MAX_TEMPLATE_TOKENS};
337    pub use crate::oob::redact_interactsh_error;
338    pub fn prewarm_oob_key_for_test() -> Result<(bool, Vec<u8>), crate::oob::InteractshError> {
339        crate::oob::prewarm_key_generation();
340        let pending = crate::oob::prewarmed_key_pending_for_test();
341        let modulus = crate::oob::consume_prewarmed_key_for_test()?;
342        Ok((pending, modulus))
343    }
344
345    /// Exercise the real `cache::evict_oldest_dashmap_entries` primitive (the
346    /// shared oldest-first bounded-cache eviction used by the DNS-resolution and
347    /// pinned-client caches) on an internally-built age-stamped map and return the
348    /// surviving keys, sorted ascending. Each key doubles as its age-in-seconds
349    /// (entry `k` is stamped `base + k`s, so key `0` is the oldest), making the
350    /// survivor set directly observable. Kept here, rather than in the
351    /// integration-test crate, so `dashmap` stays out of the test crate's
352    /// dependency set while the exact production eviction path is what runs.
353    pub fn evict_oldest_dashmap_survivors_for_test(ages_secs: &[u64], count: usize) -> Vec<u64> {
354        let base = std::time::Instant::now();
355        let cache: dashmap::DashMap<u64, (std::time::Instant, ())> = dashmap::DashMap::new();
356        for &s in ages_secs {
357            cache.insert(s, (base + std::time::Duration::from_secs(s), ()));
358        }
359        crate::cache::evict_oldest_dashmap_entries(&cache, count, |(t, _)| *t);
360        let mut survivors: Vec<u64> = cache.iter().map(|e| *e.key()).collect();
361        survivors.sort_unstable();
362        survivors
363    }
364    pub use crate::verify::aws::INVALID_AWS_REGION_ERROR;
365    pub use crate::verify::credential::MAX_RETRIES_ERROR;
366    pub use crate::verify::request::{
367        invalid_url_error, CONNECTION_FAILED_ERROR, DNS_NO_ADDRESSES_ERROR, HTTPS_ONLY_ERROR,
368        PRIVATE_URL_ERROR, REDIRECT_LIMIT_ERROR, REQUEST_FAILED_ERROR, TIMEOUT_ERROR,
369    };
370
371    // Pinned-client cache-key seams (request.rs `canonical_pinned_addrs` /
372    // `pinned_keys_equal_for_test` are `pub(crate)`, so they cannot be re-exported
373    // with `pub use`: wrap them in `pub fn`s here, like the other `_for_test`
374    // accessors, so `tests/unit/pinned_client_key.rs` reaches them without widening
375    // the crate's public API).
376    pub fn canonical_pinned_addrs(addrs: &[std::net::SocketAddr]) -> Vec<std::net::SocketAddr> {
377        crate::verify::request::canonical_pinned_addrs(addrs)
378    }
379    pub fn pinned_keys_equal_for_test(
380        host: &str,
381        addrs_a: &[std::net::SocketAddr],
382        addrs_b: &[std::net::SocketAddr],
383        timeout: std::time::Duration,
384        insecure_tls: bool,
385    ) -> bool {
386        crate::verify::request::pinned_keys_equal_for_test(
387            host,
388            addrs_a,
389            addrs_b,
390            timeout,
391            insecure_tls,
392        )
393    }
394
395    // OOB poller-degradation decision seams, surfaced for the re-homed
396    // `tests/unit/oob_poller_degradation.rs` (the `oob::session` no-inline-tests
397    // gate forbids testing the private `poller_is_degraded` / `elapsed_verdict` /
398    // threshold in place). `pub fn` wrappers, not `pub use`, because the helpers
399    // are `pub(crate)`.
400    pub fn oob_poller_is_degraded(consecutive_errors: u32) -> bool {
401        crate::oob::poller_is_degraded(consecutive_errors)
402    }
403    pub fn oob_elapsed_verdict(poller_degraded: bool) -> crate::oob::OobObservation {
404        crate::oob::elapsed_verdict(poller_degraded)
405    }
406    pub fn oob_degraded_error_threshold() -> u32 {
407        crate::oob::OOB_DEGRADED_ERROR_THRESHOLD
408    }
409
410    // SigV4 canonical-URI encoding seams (re-homed `sigv4::uri_encode_tests`).
411    pub fn aws_uri_encode(input: &str) -> String {
412        crate::sigv4::aws_uri_encode(input)
413    }
414    pub fn canonical_query_string(pairs: &[(String, String)]) -> String {
415        crate::sigv4::canonical_query_string(pairs)
416    }
417
418    // OOB combined-verdict policy-matrix seam (re-homed
419    // `verify::credential::oob_verdict_tests`).
420    pub fn oob_combined_verdict(
421        policy: keyhog_core::OobPolicy,
422        http_only_result: keyhog_core::VerificationResult,
423        http_live: bool,
424        observed: bool,
425    ) -> keyhog_core::VerificationResult {
426        crate::verify::credential::oob_combined_verdict(
427            policy,
428            http_only_result,
429            http_live,
430            observed,
431        )
432    }
433
434    // Response body-capacity DoS-guard seams (re-homed
435    // `verify::response::body_capacity_tests`).
436    pub fn body_capacity_hint(content_length: Option<u64>) -> usize {
437        crate::verify::response::body_capacity_hint(content_length)
438    }
439    pub const MAX_RESPONSE_BODY_BYTES: usize = crate::verify::response::MAX_RESPONSE_BODY_BYTES;
440    pub use crate::verify::response::{
441        BODY_NOT_UTF8_ERROR, BODY_READ_FAILED_ERROR, RESPONSE_TOO_LARGE_ERROR,
442    };
443    pub use crate::verify::tracked_join_error_preservation_for_test;
444
445    pub struct TestApi;
446
447    #[derive(Debug, Clone)]
448    pub struct TestMintedUrl {
449        pub unique_id: String,
450        pub host: String,
451        pub url: String,
452    }
453
454    fn test_minted_url(minted: crate::oob::MintedUrl) -> TestMintedUrl {
455        TestMintedUrl {
456            unique_id: minted.unique_id,
457            host: minted.host,
458            url: minted.url,
459        }
460    }
461
462    pub struct TestVerificationCache(crate::cache::VerificationCache);
463
464    pub trait VerifierTestCache {
465        fn new(ttl: Duration) -> Self;
466        fn with_max_entries(ttl: Duration, max_entries: usize) -> Self;
467        fn default_ttl() -> Self;
468        fn get(
469            &self,
470            credential: &str,
471            detector_id: &str,
472        ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)>;
473        fn get_with_companions(
474            &self,
475            credential: &str,
476            detector_id: &str,
477            companions: &HashMap<String, String>,
478        ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)>;
479        fn put(
480            &self,
481            credential: &str,
482            detector_id: &str,
483            result: keyhog_core::VerificationResult,
484            metadata: HashMap<String, String>,
485        );
486        fn put_with_companions(
487            &self,
488            credential: &str,
489            detector_id: &str,
490            companions: &HashMap<String, String>,
491            result: keyhog_core::VerificationResult,
492            metadata: HashMap<String, String>,
493        );
494        fn len(&self) -> usize;
495        fn queue_len(&self) -> usize;
496        fn is_empty(&self) -> bool;
497        fn evict_expired(&self);
498        fn enforce_max_entries_bound(&self);
499        fn clear_eviction_queue_for_test(&self);
500        fn insert_unqueued_for_test(
501            &self,
502            credential: &str,
503            detector_id: &str,
504            result: keyhog_core::VerificationResult,
505            metadata: HashMap<String, String>,
506        );
507    }
508
509    impl VerifierTestCache for TestVerificationCache {
510        fn new(ttl: Duration) -> Self {
511            Self(crate::cache::VerificationCache::new(ttl))
512        }
513
514        fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
515            Self(crate::cache::VerificationCache::with_max_entries(
516                ttl,
517                max_entries,
518            ))
519        }
520
521        fn default_ttl() -> Self {
522            Self(crate::cache::VerificationCache::default_ttl())
523        }
524
525        fn get(
526            &self,
527            credential: &str,
528            detector_id: &str,
529        ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)> {
530            self.0.get(credential, detector_id)
531        }
532
533        fn get_with_companions(
534            &self,
535            credential: &str,
536            detector_id: &str,
537            companions: &HashMap<String, String>,
538        ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)> {
539            self.0
540                .get_with_companions(credential, detector_id, companions)
541        }
542
543        fn put(
544            &self,
545            credential: &str,
546            detector_id: &str,
547            result: keyhog_core::VerificationResult,
548            metadata: HashMap<String, String>,
549        ) {
550            self.0.put(credential, detector_id, result, metadata);
551        }
552
553        fn put_with_companions(
554            &self,
555            credential: &str,
556            detector_id: &str,
557            companions: &HashMap<String, String>,
558            result: keyhog_core::VerificationResult,
559            metadata: HashMap<String, String>,
560        ) {
561            self.0
562                .put_with_companions(credential, detector_id, companions, result, metadata);
563        }
564
565        fn len(&self) -> usize {
566            self.0.len()
567        }
568
569        fn queue_len(&self) -> usize {
570            self.0.queue_len()
571        }
572
573        fn is_empty(&self) -> bool {
574            self.0.is_empty()
575        }
576
577        fn evict_expired(&self) {
578            self.0.evict_expired();
579        }
580
581        fn enforce_max_entries_bound(&self) {
582            self.0.enforce_max_entries_bound();
583        }
584
585        fn clear_eviction_queue_for_test(&self) {
586            self.0.clear_eviction_queue_for_test();
587        }
588
589        fn insert_unqueued_for_test(
590            &self,
591            credential: &str,
592            detector_id: &str,
593            result: keyhog_core::VerificationResult,
594            metadata: HashMap<String, String>,
595        ) {
596            self.0
597                .insert_unqueued_for_test(credential, detector_id, result, metadata);
598        }
599    }
600
601    pub trait VerifierTestApi {
602        const OOB_COMPANION_URL: &'static str;
603        const OOB_COMPANION_HOST: &'static str;
604        const OOB_COMPANION_ID: &'static str;
605
606        fn ip_addr_is_bogon(&self, ip: std::net::IpAddr) -> bool;
607        fn resolve_field(
608            &self,
609            field: &str,
610            credential: &str,
611            companions: &HashMap<String, String>,
612        ) -> String;
613        fn sanitize_oob_value(&self, s: &str) -> String;
614        fn sanitize_raw_value(&self, s: &str) -> String;
615        fn interpolate(
616            &self,
617            template: &str,
618            credential: &str,
619            companions: &HashMap<String, String>,
620        ) -> String;
621        fn interpolate_url(
622            &self,
623            template: &str,
624            credential: &str,
625            companions: &HashMap<String, String>,
626        ) -> String;
627        fn interpolate_http_value(
628            &self,
629            template: &str,
630            credential: &str,
631            companions: &HashMap<String, String>,
632        ) -> String;
633        fn companions_with_oob(
634            &self,
635            base: &HashMap<String, String>,
636            minted_host: &str,
637            minted_url: &str,
638            minted_id: &str,
639        ) -> HashMap<String, String>;
640        fn builtin_service_domains(
641            &self,
642        ) -> &'static HashMap<&'static str, &'static [&'static str]>;
643        fn effective_allowlist(&self, spec: &keyhog_core::VerifySpec) -> Option<Vec<String>>;
644        fn host_is_allowed(&self, host: &str, allowlist: &[String]) -> bool;
645        fn check_url_against_spec(
646            &self,
647            raw_url: &str,
648            spec: &keyhog_core::VerifySpec,
649        ) -> Result<(), String>;
650        fn engine_detector_verify_service(
651            &self,
652            engine: &crate::VerificationEngine,
653            detector_id: &str,
654        ) -> Option<String>;
655        fn engine_inflight_count(&self, engine: &crate::VerificationEngine) -> usize;
656        fn format_sigv4_timestamps(&self, unix_secs: u64) -> (String, String);
657        fn parse_aws_sts_success_metadata(
658            &self,
659            body: &str,
660        ) -> Result<HashMap<String, String>, String>;
661        fn classify_aws_sts_failure(
662            &self,
663            status: u16,
664            body: &str,
665        ) -> (keyhog_core::VerificationResult, bool);
666        fn classify_aws_sts_http_200(
667            &self,
668            body: &str,
669        ) -> (
670            keyhog_core::VerificationResult,
671            HashMap<String, String>,
672            bool,
673        );
674        fn script_auth_result_for_test(&self, output: &str) -> keyhog_core::VerificationResult;
675        fn valid_aws_format_for_test(&self, access_key: &str, secret_key: &str) -> bool;
676        fn validate_aws_region_for_test(
677            &self,
678            region: &str,
679        ) -> Result<(), keyhog_core::VerificationResult>;
680        fn build_aws_probe_final_for_test(
681            &self,
682            access_key: &str,
683            secret_key: &str,
684            region: &str,
685        ) -> impl std::future::Future<
686            Output = (
687                keyhog_core::VerificationResult,
688                HashMap<String, String>,
689                bool,
690            ),
691        > + Send;
692        fn rate_limit_feedback_sequence(&self) -> (usize, usize, usize, usize, usize);
693        fn retry_loop_records_rate_limit_feedback(
694            &self,
695        ) -> impl std::future::Future<Output = usize> + Send;
696        fn interactsh_client_for_test(
697            &self,
698            server: &str,
699        ) -> Result<crate::oob::InteractshClient, crate::oob::InteractshError>;
700        fn interactsh_client_correlation_id<'a>(
701            &self,
702            client: &'a crate::oob::InteractshClient,
703        ) -> &'a str;
704        fn interactsh_client_mint_url(
705            &self,
706            client: &crate::oob::InteractshClient,
707        ) -> TestMintedUrl;
708        fn oob_session_for_test(
709            &self,
710            client: Arc<crate::oob::InteractshClient>,
711            config: crate::oob::OobConfig,
712        ) -> Arc<crate::oob::OobSession>;
713        fn engine_set_oob_session_for_test(
714            &self,
715            engine: &mut crate::VerificationEngine,
716            session: Arc<crate::oob::OobSession>,
717        );
718        fn oob_session_mint(&self, session: &crate::oob::OobSession) -> TestMintedUrl;
719        fn oob_session_default_timeout(&self, session: &crate::oob::OobSession) -> Duration;
720        /// Force the degraded flag so the re-homed
721        /// `tests/unit/oob_poller_degradation.rs` can assert a `wait_for` timeout
722        /// on an unreachable collector fails closed (`Disabled`) instead of a
723        /// false `NotObserved`.
724        fn oob_session_set_degraded_for_test(
725            &self,
726            session: &crate::oob::OobSession,
727            degraded: bool,
728        );
729        fn oob_session_store_and_notify(
730            &self,
731            session: &crate::oob::OobSession,
732            interaction: crate::oob::Interaction,
733        );
734        fn oob_session_waiter_count(&self, session: &crate::oob::OobSession) -> usize;
735        fn oob_session_active_waiter_count(&self, session: &crate::oob::OobSession) -> usize;
736        fn oob_session_poll_generation(&self, session: &crate::oob::OobSession) -> u64;
737        fn oob_session_abort_poller_for_drop(&self, session: &crate::oob::OobSession);
738        fn decrypt_entry_for_test(
739            &self,
740            aes_key: &[u8],
741            b64: &str,
742        ) -> Result<Option<crate::oob::Interaction>, crate::oob::InteractshError>;
743        fn oob_collector_ssrf_check_dns_result(
744            &self,
745            server: &str,
746            resolved: std::io::Result<Vec<std::net::SocketAddr>>,
747        ) -> Result<(), crate::oob::InteractshError>;
748        /// `Ok(true)` = plan reuses the proxy client, `Ok(false)` = pins a
749        /// direct client, `Err` = the resolved-IP screen rejected the host.
750        /// Proves the proxied path screens resolved IPs (proxy-SSRF fix).
751        fn oob_collector_reuses_proxy_client(
752            &self,
753            server: &str,
754            proxy_in_use: bool,
755            resolved: std::io::Result<Vec<std::net::SocketAddr>>,
756        ) -> Result<bool, crate::oob::InteractshError>;
757        /// The freshly-created rate-limit slot's initial `last_request`,
758        /// clamped with `checked_sub` so a low-uptime host cannot panic.
759        fn rate_limiter_initial_last_request(
760            &self,
761            now: std::time::Instant,
762            interval: Duration,
763        ) -> std::time::Instant;
764        fn rate_limiter_burst_waits(
765            &self,
766            interval: Duration,
767            burst: usize,
768            reservations: usize,
769        ) -> Vec<Option<Duration>>;
770        fn retry_loop_preserves_metadata_on_exhaustion(
771            &self,
772        ) -> impl std::future::Future<
773            Output = (keyhog_core::VerificationResult, HashMap<String, String>),
774        > + Send;
775        fn retry_delay_bounds_for_attempt(&self, attempt: usize, base_delay_ms: u64) -> (u64, u64);
776        fn multi_step_rate_limit_service_name<'a>(
777            &self,
778            spec: &'a keyhog_core::VerifySpec,
779            auth: &'a keyhog_core::AuthSpec,
780        ) -> &'a str;
781        fn evaluate_success_for_test(
782            &self,
783            spec: &keyhog_core::SuccessSpec,
784            status: u16,
785            body: &str,
786        ) -> bool;
787        fn evaluate_success_result_for_test(
788            &self,
789            spec: &keyhog_core::SuccessSpec,
790            status: u16,
791            body: &str,
792        ) -> Result<bool, String>;
793        fn body_indicates_error_for_test(&self, body: &str) -> bool;
794        fn extract_metadata_for_test(
795            &self,
796            specs: &[keyhog_core::MetadataSpec],
797            body: &str,
798        ) -> Result<HashMap<String, String>, String>;
799        fn retryable_http_status_for_test(&self, status: u16) -> bool;
800        fn success_spec_is_explicit_for_test(&self, spec: &keyhog_core::SuccessSpec) -> bool;
801        fn resolve_live_verdict_for_test(
802            &self,
803            is_live: bool,
804            success_is_explicit: bool,
805            body: &str,
806        ) -> bool;
807        fn record_inflight_cap_bypass_for_test(&self, max_inflight_keys: usize) -> usize;
808        fn verification_result_is_cacheable_for_test(
809            &self,
810            result: &keyhog_core::VerificationResult,
811        ) -> bool;
812        fn ssrf_check_url_with_resolved_addrs_for_test(
813            &self,
814            raw_url: &str,
815            addrs: &[std::net::SocketAddr],
816            allow_private_ips: bool,
817        ) -> Result<(), keyhog_core::VerificationResult>;
818        fn proxied_request_target_for_test(
819            &self,
820            raw_url: &str,
821            allow_private_ips: bool,
822            allow_http: bool,
823        ) -> impl std::future::Future<Output = Result<(), keyhog_core::VerificationResult>> + Send;
824        fn clear_pinned_request_client_cache(&self);
825        fn pinned_request_client_cache_len(&self) -> usize;
826        fn pinned_request_client_cache_len_for_host(&self, host: &str) -> usize;
827        fn pinned_request_client_for_test(
828            &self,
829            host: &str,
830            addrs: &[std::net::SocketAddr],
831            timeout: Duration,
832            insecure_tls: bool,
833        ) -> Result<(), keyhog_core::VerificationResult>;
834        fn build_finding(
835            &self,
836            group: keyhog_core::DedupedMatch,
837            verification: keyhog_core::VerificationResult,
838            metadata: HashMap<String, String>,
839        ) -> keyhog_core::VerifiedFinding;
840        /// Drive the REAL outbound header/body interpolation boundary
841        /// (`verify::request::apply_header_body_templates`) end to end and return
842        /// the *built* `reqwest::Request`'s final header set (as `(name, value)`
843        /// UTF-8-lossy pairs) and body. Unlike `interpolate_http_value`, which
844        /// tests the sanitizer in isolation, this proves the sanitizer is
845        /// actually WIRED into the request builder: a regression that attached a
846        /// raw `header.value` (bypassing interpolation) would surface here, not in
847        /// a helper-only test. `header_templates` are `(name, value-template)`
848        /// pairs; the credential is interpolated into each value template.
849        fn built_request_header_body_for_test(
850            &self,
851            header_templates: &[(&str, &str)],
852            body_template: Option<&str>,
853            credential: &str,
854            companions: &HashMap<String, String>,
855        ) -> (Vec<(String, String)>, Option<String>);
856    }
857
858    impl VerifierTestApi for TestApi {
859        const OOB_COMPANION_URL: &'static str = crate::interpolate::OOB_COMPANION_URL;
860        const OOB_COMPANION_HOST: &'static str = crate::interpolate::OOB_COMPANION_HOST;
861        const OOB_COMPANION_ID: &'static str = crate::interpolate::OOB_COMPANION_ID;
862
863        fn ip_addr_is_bogon(&self, ip: std::net::IpAddr) -> bool {
864            crate::bogon::ip_addr_is_bogon(ip)
865        }
866
867        fn resolve_field(
868            &self,
869            field: &str,
870            credential: &str,
871            companions: &HashMap<String, String>,
872        ) -> String {
873            crate::interpolate::resolve_field(field, credential, companions)
874        }
875
876        fn sanitize_oob_value(&self, s: &str) -> String {
877            crate::interpolate::sanitize_oob_value(s)
878        }
879
880        fn sanitize_raw_value(&self, s: &str) -> String {
881            crate::interpolate::sanitize_raw_value(s)
882        }
883
884        fn interpolate(
885            &self,
886            template: &str,
887            credential: &str,
888            companions: &HashMap<String, String>,
889        ) -> String {
890            crate::interpolate::interpolate(template, credential, companions)
891        }
892
893        fn interpolate_url(
894            &self,
895            template: &str,
896            credential: &str,
897            companions: &HashMap<String, String>,
898        ) -> String {
899            crate::interpolate::interpolate_url(template, credential, companions)
900        }
901
902        fn interpolate_http_value(
903            &self,
904            template: &str,
905            credential: &str,
906            companions: &HashMap<String, String>,
907        ) -> String {
908            crate::interpolate::interpolate_http_value(template, credential, companions)
909        }
910
911        fn companions_with_oob(
912            &self,
913            base: &HashMap<String, String>,
914            minted_host: &str,
915            minted_url: &str,
916            minted_id: &str,
917        ) -> HashMap<String, String> {
918            crate::interpolate::companions_with_oob(base, minted_host, minted_url, minted_id)
919                .into_iter()
920                .map(|(name, value)| (name.to_string(), value))
921                .collect()
922        }
923
924        fn built_request_header_body_for_test(
925            &self,
926            header_templates: &[(&str, &str)],
927            body_template: Option<&str>,
928            credential: &str,
929            companions: &HashMap<String, String>,
930        ) -> (Vec<(String, String)>, Option<String>) {
931            let client = reqwest::Client::new();
932            // A fixed, non-routable target: the request is BUILT, never sent, so
933            // no traffic leaves the test, only the assembled header/body bytes
934            // are inspected.
935            let builder = client.post("https://verify.example.invalid/probe");
936            let specs: Vec<keyhog_core::HeaderSpec> = header_templates
937                .iter()
938                .map(|(name, value)| keyhog_core::HeaderSpec {
939                    name: (*name).to_string(),
940                    value: (*value).to_string(),
941                })
942                .collect();
943            let builder = crate::verify::request::apply_header_body_templates(
944                builder,
945                &specs,
946                body_template,
947                credential,
948                companions,
949            );
950            let request = builder
951                .build()
952                .expect("a sanitized verification request must always build");
953            let headers = request
954                .headers()
955                .iter()
956                .map(|(name, value)| {
957                    (
958                        name.as_str().to_string(),
959                        String::from_utf8_lossy(value.as_bytes()).into_owned(),
960                    )
961                })
962                .collect();
963            let body = request
964                .body()
965                .and_then(reqwest::Body::as_bytes)
966                .map(|bytes| String::from_utf8_lossy(bytes).into_owned());
967            (headers, body)
968        }
969
970        fn builtin_service_domains(
971            &self,
972        ) -> &'static HashMap<&'static str, &'static [&'static str]> {
973            crate::domain_allowlist::builtin_service_domains()
974        }
975
976        fn effective_allowlist(&self, spec: &keyhog_core::VerifySpec) -> Option<Vec<String>> {
977            crate::domain_allowlist::effective_allowlist(spec)
978        }
979
980        fn host_is_allowed(&self, host: &str, allowlist: &[String]) -> bool {
981            crate::domain_allowlist::host_is_allowed(host, allowlist)
982        }
983
984        fn check_url_against_spec(
985            &self,
986            raw_url: &str,
987            spec: &keyhog_core::VerifySpec,
988        ) -> Result<(), String> {
989            crate::domain_allowlist::check_url_against_spec(raw_url, spec)
990        }
991
992        fn engine_detector_verify_service(
993            &self,
994            engine: &crate::VerificationEngine,
995            detector_id: &str,
996        ) -> Option<String> {
997            engine
998                .detectors
999                .get(detector_id)
1000                .and_then(|detector| detector.verify.as_ref())
1001                .map(|verify| verify.service.clone())
1002        }
1003
1004        fn engine_inflight_count(&self, engine: &crate::VerificationEngine) -> usize {
1005            engine
1006                .inflight_count
1007                .load(std::sync::atomic::Ordering::Acquire)
1008        }
1009
1010        fn format_sigv4_timestamps(&self, unix_secs: u64) -> (String, String) {
1011            crate::sigv4::format_sigv4_timestamps(unix_secs)
1012        }
1013
1014        fn parse_aws_sts_success_metadata(
1015            &self,
1016            body: &str,
1017        ) -> Result<HashMap<String, String>, String> {
1018            crate::verify::parse_aws_sts_success_metadata(body)
1019        }
1020
1021        fn classify_aws_sts_failure(
1022            &self,
1023            status: u16,
1024            body: &str,
1025        ) -> (keyhog_core::VerificationResult, bool) {
1026            crate::verify::classify_aws_sts_failure(status, body)
1027        }
1028
1029        fn classify_aws_sts_http_200(
1030            &self,
1031            body: &str,
1032        ) -> (
1033            keyhog_core::VerificationResult,
1034            HashMap<String, String>,
1035            bool,
1036        ) {
1037            crate::verify::classify_aws_sts_http_200(body)
1038        }
1039
1040        fn script_auth_result_for_test(&self, output: &str) -> keyhog_core::VerificationResult {
1041            crate::verify::script_auth_result(output)
1042        }
1043
1044        fn valid_aws_format_for_test(&self, access_key: &str, secret_key: &str) -> bool {
1045            crate::verify::valid_aws_format(access_key, secret_key)
1046        }
1047
1048        fn validate_aws_region_for_test(
1049            &self,
1050            region: &str,
1051        ) -> Result<(), keyhog_core::VerificationResult> {
1052            crate::verify::validate_aws_region(region)
1053        }
1054
1055        async fn build_aws_probe_final_for_test(
1056            &self,
1057            access_key: &str,
1058            secret_key: &str,
1059            region: &str,
1060        ) -> (
1061            keyhog_core::VerificationResult,
1062            HashMap<String, String>,
1063            bool,
1064        ) {
1065            let client = reqwest::Client::builder()
1066                .no_proxy()
1067                .build()
1068                .expect("test verifier client builds");
1069            let companions = HashMap::<String, String>::new();
1070            match crate::verify::build_aws_probe(
1071                access_key,
1072                secret_key,
1073                &None,
1074                region,
1075                access_key,
1076                &companions,
1077                Duration::from_millis(10),
1078                &client,
1079                false,
1080                false,
1081                false,
1082                false,
1083            )
1084            .await
1085            {
1086                crate::verify::RequestBuildResult::Final {
1087                    result,
1088                    metadata,
1089                    transient,
1090                } => (result, metadata, transient),
1091                crate::verify::RequestBuildResult::Ready(_) => {
1092                    panic!("AWS probe preflight unexpectedly reached network-ready request")
1093                }
1094            }
1095        }
1096
1097        fn rate_limit_feedback_sequence(&self) -> (usize, usize, usize, usize, usize) {
1098            crate::verify::rate_limit_feedback_sequence_for_test()
1099        }
1100
1101        async fn retry_loop_records_rate_limit_feedback(&self) -> usize {
1102            crate::verify::retry_loop_records_rate_limit_feedback_for_test().await
1103        }
1104
1105        fn interactsh_client_for_test(
1106            &self,
1107            server: &str,
1108        ) -> Result<crate::oob::InteractshClient, crate::oob::InteractshError> {
1109            crate::oob::InteractshClient::for_test(server)
1110        }
1111
1112        fn interactsh_client_correlation_id<'a>(
1113            &self,
1114            client: &'a crate::oob::InteractshClient,
1115        ) -> &'a str {
1116            client.correlation_id()
1117        }
1118
1119        fn interactsh_client_mint_url(
1120            &self,
1121            client: &crate::oob::InteractshClient,
1122        ) -> TestMintedUrl {
1123            test_minted_url(client.mint_url())
1124        }
1125
1126        fn oob_session_for_test(
1127            &self,
1128            client: Arc<crate::oob::InteractshClient>,
1129            config: crate::oob::OobConfig,
1130        ) -> Arc<crate::oob::OobSession> {
1131            crate::oob::OobSession::for_test(client, config)
1132        }
1133
1134        fn engine_set_oob_session_for_test(
1135            &self,
1136            engine: &mut crate::VerificationEngine,
1137            session: Arc<crate::oob::OobSession>,
1138        ) {
1139            engine.oob_session = Some(session);
1140        }
1141
1142        fn oob_session_mint(&self, session: &crate::oob::OobSession) -> TestMintedUrl {
1143            test_minted_url(session.mint())
1144        }
1145
1146        fn oob_session_default_timeout(&self, session: &crate::oob::OobSession) -> Duration {
1147            session.config_default_timeout()
1148        }
1149
1150        fn oob_session_set_degraded_for_test(
1151            &self,
1152            session: &crate::oob::OobSession,
1153            degraded: bool,
1154        ) {
1155            session.set_degraded_for_test(degraded);
1156        }
1157
1158        fn oob_session_store_and_notify(
1159            &self,
1160            session: &crate::oob::OobSession,
1161            interaction: crate::oob::Interaction,
1162        ) {
1163            session.store_and_notify_for_test(interaction);
1164        }
1165
1166        fn oob_session_waiter_count(&self, session: &crate::oob::OobSession) -> usize {
1167            session.waiter_count_for_test()
1168        }
1169
1170        fn oob_session_active_waiter_count(&self, session: &crate::oob::OobSession) -> usize {
1171            session.active_waiter_count_for_test()
1172        }
1173
1174        fn oob_session_poll_generation(&self, session: &crate::oob::OobSession) -> u64 {
1175            session.poll_generation_for_test()
1176        }
1177
1178        fn oob_session_abort_poller_for_drop(&self, session: &crate::oob::OobSession) {
1179            session.abort_poller_for_drop();
1180        }
1181
1182        fn decrypt_entry_for_test(
1183            &self,
1184            aes_key: &[u8],
1185            b64: &str,
1186        ) -> Result<Option<crate::oob::Interaction>, crate::oob::InteractshError> {
1187            crate::oob::decrypt_entry_for_test(aes_key, b64)
1188        }
1189
1190        fn oob_collector_ssrf_check_dns_result(
1191            &self,
1192            server: &str,
1193            resolved: std::io::Result<Vec<std::net::SocketAddr>>,
1194        ) -> Result<(), crate::oob::InteractshError> {
1195            crate::oob::ssrf_check_collector_dns_result_for_test(server, resolved)
1196        }
1197
1198        fn oob_collector_reuses_proxy_client(
1199            &self,
1200            server: &str,
1201            proxy_in_use: bool,
1202            resolved: std::io::Result<Vec<std::net::SocketAddr>>,
1203        ) -> Result<bool, crate::oob::InteractshError> {
1204            crate::oob::collector_reuses_proxy_client_for_test(server, proxy_in_use, resolved)
1205        }
1206
1207        fn rate_limiter_initial_last_request(
1208            &self,
1209            now: std::time::Instant,
1210            interval: Duration,
1211        ) -> std::time::Instant {
1212            crate::rate_limit::initial_last_request(now, interval)
1213        }
1214
1215        fn rate_limiter_burst_waits(
1216            &self,
1217            interval: Duration,
1218            burst: usize,
1219            reservations: usize,
1220        ) -> Vec<Option<Duration>> {
1221            crate::rate_limit::burst_reservation_waits_for_test(interval, burst, reservations)
1222        }
1223
1224        async fn retry_loop_preserves_metadata_on_exhaustion(
1225            &self,
1226        ) -> (keyhog_core::VerificationResult, HashMap<String, String>) {
1227            crate::verify::retry_loop_preserves_metadata_on_exhaustion_for_test().await
1228        }
1229
1230        fn retry_delay_bounds_for_attempt(&self, attempt: usize, base_delay_ms: u64) -> (u64, u64) {
1231            crate::verify::retry_delay_bounds_for_attempt(attempt, base_delay_ms)
1232        }
1233
1234        fn multi_step_rate_limit_service_name<'a>(
1235            &self,
1236            spec: &'a keyhog_core::VerifySpec,
1237            auth: &'a keyhog_core::AuthSpec,
1238        ) -> &'a str {
1239            crate::verify::multi_step_rate_limit_service_name(spec, auth)
1240        }
1241
1242        fn evaluate_success_for_test(
1243            &self,
1244            spec: &keyhog_core::SuccessSpec,
1245            status: u16,
1246            body: &str,
1247        ) -> bool {
1248            crate::verify::evaluate_success(spec, status, body)
1249                .expect("success contract should evaluate cleanly")
1250        }
1251
1252        fn evaluate_success_result_for_test(
1253            &self,
1254            spec: &keyhog_core::SuccessSpec,
1255            status: u16,
1256            body: &str,
1257        ) -> Result<bool, String> {
1258            crate::verify::evaluate_success(spec, status, body).map_err(|error| error.to_string())
1259        }
1260
1261        fn body_indicates_error_for_test(&self, body: &str) -> bool {
1262            crate::verify::body_indicates_error(body)
1263        }
1264
1265        fn extract_metadata_for_test(
1266            &self,
1267            specs: &[keyhog_core::MetadataSpec],
1268            body: &str,
1269        ) -> Result<HashMap<String, String>, String> {
1270            crate::verify::extract_provider_evidence(specs, body).map_err(|error| error.to_string())
1271        }
1272
1273        fn retryable_http_status_for_test(&self, status: u16) -> bool {
1274            crate::verify::retryable_http_status(status)
1275        }
1276
1277        fn success_spec_is_explicit_for_test(&self, spec: &keyhog_core::SuccessSpec) -> bool {
1278            crate::verify::success_spec_is_explicit(spec)
1279        }
1280
1281        fn resolve_live_verdict_for_test(
1282            &self,
1283            is_live: bool,
1284            success_is_explicit: bool,
1285            body: &str,
1286        ) -> bool {
1287            crate::verify::resolve_live_verdict(is_live, success_is_explicit, body)
1288        }
1289
1290        fn record_inflight_cap_bypass_for_test(&self, max_inflight_keys: usize) -> usize {
1291            crate::verify::note_inflight_cap_bypass(max_inflight_keys)
1292        }
1293
1294        fn verification_result_is_cacheable_for_test(
1295            &self,
1296            result: &keyhog_core::VerificationResult,
1297        ) -> bool {
1298            crate::verify::verification_result_is_cacheable(result)
1299        }
1300
1301        fn ssrf_check_url_with_resolved_addrs_for_test(
1302            &self,
1303            raw_url: &str,
1304            addrs: &[std::net::SocketAddr],
1305            allow_private_ips: bool,
1306        ) -> Result<(), keyhog_core::VerificationResult> {
1307            crate::verify::ssrf_check_url_with_resolved_addrs_for_test(
1308                raw_url,
1309                addrs,
1310                allow_private_ips,
1311            )
1312        }
1313
1314        async fn proxied_request_target_for_test(
1315            &self,
1316            raw_url: &str,
1317            allow_private_ips: bool,
1318            allow_http: bool,
1319        ) -> Result<(), keyhog_core::VerificationResult> {
1320            let client = reqwest::Client::builder()
1321                .no_proxy()
1322                .build()
1323                .expect("test verifier client builds");
1324            crate::verify::resolved_client_for_url(
1325                &client,
1326                raw_url,
1327                Duration::from_millis(10),
1328                allow_private_ips,
1329                allow_http,
1330                true,
1331                false,
1332            )
1333            .await
1334            .map(|_| ())
1335        }
1336
1337        fn clear_pinned_request_client_cache(&self) {
1338            crate::verify::clear_pinned_client_cache_for_test();
1339        }
1340
1341        fn pinned_request_client_cache_len(&self) -> usize {
1342            crate::verify::pinned_client_cache_len_for_test()
1343        }
1344
1345        fn pinned_request_client_cache_len_for_host(&self, host: &str) -> usize {
1346            crate::verify::pinned_client_cache_len_for_host_for_test(host)
1347        }
1348
1349        fn pinned_request_client_for_test(
1350            &self,
1351            host: &str,
1352            addrs: &[std::net::SocketAddr],
1353            timeout: Duration,
1354            insecure_tls: bool,
1355        ) -> Result<(), keyhog_core::VerificationResult> {
1356            crate::verify::pinned_client_for_test(host, addrs, timeout, insecure_tls)
1357        }
1358
1359        fn build_finding(
1360            &self,
1361            group: keyhog_core::DedupedMatch,
1362            verification: keyhog_core::VerificationResult,
1363            metadata: HashMap<String, String>,
1364        ) -> keyhog_core::VerifiedFinding {
1365            crate::into_finding(group, verification, metadata)
1366        }
1367    }
1368}