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