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