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