1#![doc = include_str!("../README.md")]
6#![allow(clippy::too_many_arguments)]
7#![allow(clippy::type_complexity)]
8
9mod bogon;
10mod cache;
12mod domain_allowlist;
13mod interpolate;
14pub mod oob;
15pub mod rate_limit;
16pub mod sigv4;
17pub mod ssrf;
18mod verify;
19
20use std::collections::HashMap;
21use std::sync::atomic::AtomicUsize;
22use std::sync::Arc;
23use std::time::Duration;
24
25use dashmap::DashMap;
26use keyhog_core::{DedupedMatch, DetectorSpec, VerificationResult, VerifiedFinding};
27
28pub use keyhog_core::{dedup_matches, DedupScope};
31use reqwest::{Client, Error as ReqwestError};
32use thiserror::Error;
33use tokio::sync::{Notify, Semaphore};
34
35#[derive(Debug, Error)]
37pub enum VerifyError {
38 #[error(
39 "failed to send HTTP request: {0}. Fix: check network access, proxy settings, and the verification endpoint"
40 )]
41 Http(#[from] ReqwestError),
42 #[error(
43 "failed to build configured HTTP client: {0}. Fix: use a valid timeout and supported TLS/network configuration"
44 )]
45 ClientBuild(ReqwestError),
46 #[error(
47 "invalid verifier proxy configuration: {0}. Fix: use a valid http://, https://, or socks5:// URL, or set 'off' to disable proxying entirely"
48 )]
49 ProxyConfig(String),
50 #[error(
51 "failed to resolve verification field: {0}. Fix: use `match` or `companion.<name>` fields that exist in the detector spec"
52 )]
53 FieldResolution(String),
54 #[error(
55 "invalid detector verification response contract: {0}. Fix: correct the owning detector TOML before enabling live verification"
56 )]
57 DetectorConfig(String),
58}
59
60pub struct VerificationEngine {
62 client: Client,
63 detectors: Arc<HashMap<Arc<str>, DetectorSpec>>,
64 service_semaphores: Arc<HashMap<Arc<str>, Arc<Semaphore>>>,
66 pub(crate) max_concurrent_per_service: usize,
70 global_semaphore: Arc<Semaphore>,
72 timeout: Duration,
73 cache: Arc<cache::VerificationCache>,
75 pub(crate) inflight: Arc<DashMap<cache::VerificationIdentity, Arc<Notify>>>,
79 pub(crate) inflight_count: Arc<AtomicUsize>,
80 pub(crate) max_inflight_keys: usize,
81 pub(crate) danger_allow_private_ips: bool,
82 pub(crate) danger_allow_http: bool,
83 pub(crate) insecure_tls: bool,
89 pub(crate) proxy_in_use: bool,
93 pub(crate) allow_script_verify: bool,
96 pub(crate) oob_session: Option<Arc<oob::OobSession>>,
102}
103
104pub struct VerifyConfig {
120 pub timeout: Duration,
122 pub max_concurrent_per_service: usize,
124 pub max_concurrent_global: usize,
126 pub max_inflight_keys: usize,
128 pub danger_allow_private_ips: bool,
130 pub danger_allow_http: bool,
134 pub proxy: Option<String>,
141 pub insecure_tls: bool,
145 pub allow_script_verify: bool,
151}
152
153impl Default for VerifyConfig {
154 fn default() -> Self {
155 Self {
156 timeout: Duration::from_secs(5),
157 max_concurrent_per_service: 5,
158 max_concurrent_global: 20,
159 max_inflight_keys: 10_000,
160 danger_allow_private_ips: false,
161 danger_allow_http: false,
162 proxy: None,
163 insecure_tls: false,
164 allow_script_verify: false,
165 }
166 }
167}
168
169pub(crate) fn apply_proxy_config(
178 builder: reqwest::ClientBuilder,
179 explicit: Option<&str>,
180) -> Result<reqwest::ClientBuilder, String> {
181 match resolve_proxy_mode(explicit) {
182 ProxyMode::Disabled => Ok(builder.no_proxy()),
183 ProxyMode::Explicit(url) => {
184 let parsed = url::Url::parse(&url).map_err(|_| invalid_proxy_url_diagnostic(None))?;
185 if !matches!(parsed.scheme(), "http" | "https" | "socks5")
186 || parsed.host_str().is_none()
187 {
188 return Err(invalid_proxy_url_diagnostic(Some(&parsed)));
189 }
190
191 let proxy = reqwest::Proxy::all(&url)
196 .map_err(|_| invalid_proxy_url_diagnostic(Some(&parsed)))?;
197 Ok(builder.proxy(proxy))
198 }
199 }
200}
201
202fn invalid_proxy_url_diagnostic(parsed: Option<&url::Url>) -> String {
208 if let Some((scheme, host)) =
209 parsed.and_then(|url| url.host_str().map(|host| (url.scheme(), host)))
210 {
211 format!("invalid verifier proxy URL (scheme `{scheme}`, host `{host}`)")
212 } else {
213 "invalid verifier proxy URL".to_owned()
214 }
215}
216
217enum ProxyMode {
218 Disabled,
219 Explicit(String),
220}
221
222fn resolve_proxy_mode(explicit: Option<&str>) -> ProxyMode {
226 match explicit {
227 Some(raw) => proxy_mode_from_raw(raw),
228 None => ProxyMode::Disabled,
229 }
230}
231
232fn proxy_mode_from_raw(raw: &str) -> ProxyMode {
233 match raw {
234 "off" | "none" | "" => ProxyMode::Disabled,
235 url => ProxyMode::Explicit(url.to_string()),
236 }
237}
238
239pub fn proxy_is_active(explicit: Option<&str>) -> bool {
250 matches!(resolve_proxy_mode(explicit), ProxyMode::Explicit(_))
251}
252
253pub(crate) const DEFAULT_HTTPS_PORT: u16 = 443;
257
258pub(crate) fn harden_verifier_client_builder(
266 builder: reqwest::ClientBuilder,
267) -> reqwest::ClientBuilder {
268 builder
269 .no_gzip()
270 .no_brotli()
271 .no_zstd()
272 .no_deflate()
273 .redirect(reqwest::redirect::Policy::none())
274}
275
276pub(crate) fn build_pinned_verifier_client(
281 host: &str,
282 pinned_addrs: &[std::net::SocketAddr],
283 timeout: std::time::Duration,
284 insecure_tls: bool,
285) -> Result<reqwest::Client, reqwest::Error> {
286 harden_verifier_client_builder(
287 reqwest::Client::builder()
288 .timeout(timeout)
289 .danger_accept_invalid_certs(insecure_tls)
290 .no_proxy(),
291 )
292 .resolve_to_addrs(host, pinned_addrs)
293 .build()
294}
295
296pub(crate) fn into_finding(
298 group: DedupedMatch,
299 verification: VerificationResult,
300 mut metadata: HashMap<String, String>,
301) -> VerifiedFinding {
302 let severity = match verification {
313 VerificationResult::Dead | VerificationResult::Revoked => group.severity.downgrade_one(),
314 _ => group.severity,
315 };
316 let credential = group.credential.as_ref();
317 metadata.retain(|_, value| {
319 (credential.is_empty() || !value.contains(credential))
320 && group
321 .companions
322 .values()
323 .all(|secret| secret.is_empty() || !value.contains(secret))
324 });
325 VerifiedFinding::from_deduped(group, severity, verification, metadata)
326}
327
328#[doc(hidden)]
330pub mod testing {
331 use std::collections::HashMap;
332 use std::sync::Arc;
333 use std::time::Duration;
334
335 pub use crate::cache::oldest_eviction_batch;
336 pub use crate::interpolate::{missing_companion_refs, MAX_TEMPLATE_TOKENS};
337 pub use crate::oob::redact_interactsh_error;
338 pub fn prewarm_oob_key_for_test() -> Result<(bool, Vec<u8>), crate::oob::InteractshError> {
339 crate::oob::prewarm_key_generation();
340 let pending = crate::oob::prewarmed_key_pending_for_test();
341 let modulus = crate::oob::consume_prewarmed_key_for_test()?;
342 Ok((pending, modulus))
343 }
344
345 pub fn evict_oldest_dashmap_survivors_for_test(ages_secs: &[u64], count: usize) -> Vec<u64> {
354 let base = std::time::Instant::now();
355 let cache: dashmap::DashMap<u64, (std::time::Instant, ())> = dashmap::DashMap::new();
356 for &s in ages_secs {
357 cache.insert(s, (base + std::time::Duration::from_secs(s), ()));
358 }
359 crate::cache::evict_oldest_dashmap_entries(&cache, count, |(t, _)| *t);
360 let mut survivors: Vec<u64> = cache.iter().map(|e| *e.key()).collect();
361 survivors.sort_unstable();
362 survivors
363 }
364 pub use crate::verify::aws::INVALID_AWS_REGION_ERROR;
365 pub use crate::verify::credential::MAX_RETRIES_ERROR;
366 pub use crate::verify::request::{
367 invalid_url_error, CONNECTION_FAILED_ERROR, DNS_NO_ADDRESSES_ERROR, HTTPS_ONLY_ERROR,
368 PRIVATE_URL_ERROR, REDIRECT_LIMIT_ERROR, REQUEST_FAILED_ERROR, TIMEOUT_ERROR,
369 };
370
371 pub fn canonical_pinned_addrs(addrs: &[std::net::SocketAddr]) -> Vec<std::net::SocketAddr> {
377 crate::verify::request::canonical_pinned_addrs(addrs)
378 }
379 pub fn pinned_keys_equal_for_test(
380 host: &str,
381 addrs_a: &[std::net::SocketAddr],
382 addrs_b: &[std::net::SocketAddr],
383 timeout: std::time::Duration,
384 insecure_tls: bool,
385 ) -> bool {
386 crate::verify::request::pinned_keys_equal_for_test(
387 host,
388 addrs_a,
389 addrs_b,
390 timeout,
391 insecure_tls,
392 )
393 }
394
395 pub fn oob_poller_is_degraded(consecutive_errors: u32) -> bool {
401 crate::oob::poller_is_degraded(consecutive_errors)
402 }
403 pub fn oob_elapsed_verdict(poller_degraded: bool) -> crate::oob::OobObservation {
404 crate::oob::elapsed_verdict(poller_degraded)
405 }
406 pub fn oob_degraded_error_threshold() -> u32 {
407 crate::oob::OOB_DEGRADED_ERROR_THRESHOLD
408 }
409
410 pub fn aws_uri_encode(input: &str) -> String {
412 crate::sigv4::aws_uri_encode(input)
413 }
414 pub fn canonical_query_string(pairs: &[(String, String)]) -> String {
415 crate::sigv4::canonical_query_string(pairs)
416 }
417
418 pub fn oob_combined_verdict(
421 policy: keyhog_core::OobPolicy,
422 http_only_result: keyhog_core::VerificationResult,
423 http_live: bool,
424 observed: bool,
425 ) -> keyhog_core::VerificationResult {
426 crate::verify::credential::oob_combined_verdict(
427 policy,
428 http_only_result,
429 http_live,
430 observed,
431 )
432 }
433
434 pub fn body_capacity_hint(content_length: Option<u64>) -> usize {
437 crate::verify::response::body_capacity_hint(content_length)
438 }
439 pub const MAX_RESPONSE_BODY_BYTES: usize = crate::verify::response::MAX_RESPONSE_BODY_BYTES;
440 pub use crate::verify::response::{
441 BODY_NOT_UTF8_ERROR, BODY_READ_FAILED_ERROR, RESPONSE_TOO_LARGE_ERROR,
442 };
443 pub use crate::verify::tracked_join_error_preservation_for_test;
444
445 pub struct TestApi;
446
447 #[derive(Debug, Clone)]
448 pub struct TestMintedUrl {
449 pub unique_id: String,
450 pub host: String,
451 pub url: String,
452 }
453
454 fn test_minted_url(minted: crate::oob::MintedUrl) -> TestMintedUrl {
455 TestMintedUrl {
456 unique_id: minted.unique_id,
457 host: minted.host,
458 url: minted.url,
459 }
460 }
461
462 pub struct TestVerificationCache(crate::cache::VerificationCache);
463
464 pub trait VerifierTestCache {
465 fn new(ttl: Duration) -> Self;
466 fn with_max_entries(ttl: Duration, max_entries: usize) -> Self;
467 fn default_ttl() -> Self;
468 fn get(
469 &self,
470 credential: &str,
471 detector_id: &str,
472 ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)>;
473 fn get_with_companions(
474 &self,
475 credential: &str,
476 detector_id: &str,
477 companions: &HashMap<String, String>,
478 ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)>;
479 fn put(
480 &self,
481 credential: &str,
482 detector_id: &str,
483 result: keyhog_core::VerificationResult,
484 metadata: HashMap<String, String>,
485 );
486 fn put_with_companions(
487 &self,
488 credential: &str,
489 detector_id: &str,
490 companions: &HashMap<String, String>,
491 result: keyhog_core::VerificationResult,
492 metadata: HashMap<String, String>,
493 );
494 fn len(&self) -> usize;
495 fn queue_len(&self) -> usize;
496 fn is_empty(&self) -> bool;
497 fn evict_expired(&self);
498 fn enforce_max_entries_bound(&self);
499 fn clear_eviction_queue_for_test(&self);
500 fn insert_unqueued_for_test(
501 &self,
502 credential: &str,
503 detector_id: &str,
504 result: keyhog_core::VerificationResult,
505 metadata: HashMap<String, String>,
506 );
507 }
508
509 impl VerifierTestCache for TestVerificationCache {
510 fn new(ttl: Duration) -> Self {
511 Self(crate::cache::VerificationCache::new(ttl))
512 }
513
514 fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
515 Self(crate::cache::VerificationCache::with_max_entries(
516 ttl,
517 max_entries,
518 ))
519 }
520
521 fn default_ttl() -> Self {
522 Self(crate::cache::VerificationCache::default_ttl())
523 }
524
525 fn get(
526 &self,
527 credential: &str,
528 detector_id: &str,
529 ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)> {
530 self.0.get(credential, detector_id)
531 }
532
533 fn get_with_companions(
534 &self,
535 credential: &str,
536 detector_id: &str,
537 companions: &HashMap<String, String>,
538 ) -> Option<(keyhog_core::VerificationResult, HashMap<String, String>)> {
539 self.0
540 .get_with_companions(credential, detector_id, companions)
541 }
542
543 fn put(
544 &self,
545 credential: &str,
546 detector_id: &str,
547 result: keyhog_core::VerificationResult,
548 metadata: HashMap<String, String>,
549 ) {
550 self.0.put(credential, detector_id, result, metadata);
551 }
552
553 fn put_with_companions(
554 &self,
555 credential: &str,
556 detector_id: &str,
557 companions: &HashMap<String, String>,
558 result: keyhog_core::VerificationResult,
559 metadata: HashMap<String, String>,
560 ) {
561 self.0
562 .put_with_companions(credential, detector_id, companions, result, metadata);
563 }
564
565 fn len(&self) -> usize {
566 self.0.len()
567 }
568
569 fn queue_len(&self) -> usize {
570 self.0.queue_len()
571 }
572
573 fn is_empty(&self) -> bool {
574 self.0.is_empty()
575 }
576
577 fn evict_expired(&self) {
578 self.0.evict_expired();
579 }
580
581 fn enforce_max_entries_bound(&self) {
582 self.0.enforce_max_entries_bound();
583 }
584
585 fn clear_eviction_queue_for_test(&self) {
586 self.0.clear_eviction_queue_for_test();
587 }
588
589 fn insert_unqueued_for_test(
590 &self,
591 credential: &str,
592 detector_id: &str,
593 result: keyhog_core::VerificationResult,
594 metadata: HashMap<String, String>,
595 ) {
596 self.0
597 .insert_unqueued_for_test(credential, detector_id, result, metadata);
598 }
599 }
600
601 pub trait VerifierTestApi {
602 const OOB_COMPANION_URL: &'static str;
603 const OOB_COMPANION_HOST: &'static str;
604 const OOB_COMPANION_ID: &'static str;
605
606 fn ip_addr_is_bogon(&self, ip: std::net::IpAddr) -> bool;
607 fn resolve_field(
608 &self,
609 field: &str,
610 credential: &str,
611 companions: &HashMap<String, String>,
612 ) -> String;
613 fn sanitize_oob_value(&self, s: &str) -> String;
614 fn sanitize_raw_value(&self, s: &str) -> String;
615 fn interpolate(
616 &self,
617 template: &str,
618 credential: &str,
619 companions: &HashMap<String, String>,
620 ) -> String;
621 fn interpolate_url(
622 &self,
623 template: &str,
624 credential: &str,
625 companions: &HashMap<String, String>,
626 ) -> String;
627 fn interpolate_http_value(
628 &self,
629 template: &str,
630 credential: &str,
631 companions: &HashMap<String, String>,
632 ) -> String;
633 fn companions_with_oob(
634 &self,
635 base: &HashMap<String, String>,
636 minted_host: &str,
637 minted_url: &str,
638 minted_id: &str,
639 ) -> HashMap<String, String>;
640 fn builtin_service_domains(
641 &self,
642 ) -> &'static HashMap<&'static str, &'static [&'static str]>;
643 fn effective_allowlist(&self, spec: &keyhog_core::VerifySpec) -> Option<Vec<String>>;
644 fn host_is_allowed(&self, host: &str, allowlist: &[String]) -> bool;
645 fn check_url_against_spec(
646 &self,
647 raw_url: &str,
648 spec: &keyhog_core::VerifySpec,
649 ) -> Result<(), String>;
650 fn engine_detector_verify_service(
651 &self,
652 engine: &crate::VerificationEngine,
653 detector_id: &str,
654 ) -> Option<String>;
655 fn engine_inflight_count(&self, engine: &crate::VerificationEngine) -> usize;
656 fn format_sigv4_timestamps(&self, unix_secs: u64) -> (String, String);
657 fn parse_aws_sts_success_metadata(
658 &self,
659 body: &str,
660 ) -> Result<HashMap<String, String>, String>;
661 fn classify_aws_sts_failure(
662 &self,
663 status: u16,
664 body: &str,
665 ) -> (keyhog_core::VerificationResult, bool);
666 fn classify_aws_sts_http_200(
667 &self,
668 body: &str,
669 ) -> (
670 keyhog_core::VerificationResult,
671 HashMap<String, String>,
672 bool,
673 );
674 fn script_auth_result_for_test(&self, output: &str) -> keyhog_core::VerificationResult;
675 fn valid_aws_format_for_test(&self, access_key: &str, secret_key: &str) -> bool;
676 fn validate_aws_region_for_test(
677 &self,
678 region: &str,
679 ) -> Result<(), keyhog_core::VerificationResult>;
680 fn build_aws_probe_final_for_test(
681 &self,
682 access_key: &str,
683 secret_key: &str,
684 region: &str,
685 ) -> impl std::future::Future<
686 Output = (
687 keyhog_core::VerificationResult,
688 HashMap<String, String>,
689 bool,
690 ),
691 > + Send;
692 fn rate_limit_feedback_sequence(&self) -> (usize, usize, usize, usize, usize);
693 fn retry_loop_records_rate_limit_feedback(
694 &self,
695 ) -> impl std::future::Future<Output = usize> + Send;
696 fn interactsh_client_for_test(
697 &self,
698 server: &str,
699 ) -> Result<crate::oob::InteractshClient, crate::oob::InteractshError>;
700 fn interactsh_client_correlation_id<'a>(
701 &self,
702 client: &'a crate::oob::InteractshClient,
703 ) -> &'a str;
704 fn interactsh_client_mint_url(
705 &self,
706 client: &crate::oob::InteractshClient,
707 ) -> TestMintedUrl;
708 fn oob_session_for_test(
709 &self,
710 client: Arc<crate::oob::InteractshClient>,
711 config: crate::oob::OobConfig,
712 ) -> Arc<crate::oob::OobSession>;
713 fn engine_set_oob_session_for_test(
714 &self,
715 engine: &mut crate::VerificationEngine,
716 session: Arc<crate::oob::OobSession>,
717 );
718 fn oob_session_mint(&self, session: &crate::oob::OobSession) -> TestMintedUrl;
719 fn oob_session_default_timeout(&self, session: &crate::oob::OobSession) -> Duration;
720 fn oob_session_set_degraded_for_test(
725 &self,
726 session: &crate::oob::OobSession,
727 degraded: bool,
728 );
729 fn oob_session_store_and_notify(
730 &self,
731 session: &crate::oob::OobSession,
732 interaction: crate::oob::Interaction,
733 );
734 fn oob_session_waiter_count(&self, session: &crate::oob::OobSession) -> usize;
735 fn oob_session_active_waiter_count(&self, session: &crate::oob::OobSession) -> usize;
736 fn oob_session_poll_generation(&self, session: &crate::oob::OobSession) -> u64;
737 fn oob_session_abort_poller_for_drop(&self, session: &crate::oob::OobSession);
738 fn decrypt_entry_for_test(
739 &self,
740 aes_key: &[u8],
741 b64: &str,
742 ) -> Result<Option<crate::oob::Interaction>, crate::oob::InteractshError>;
743 fn oob_collector_ssrf_check_dns_result(
744 &self,
745 server: &str,
746 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
747 ) -> Result<(), crate::oob::InteractshError>;
748 fn oob_collector_reuses_proxy_client(
752 &self,
753 server: &str,
754 proxy_in_use: bool,
755 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
756 ) -> Result<bool, crate::oob::InteractshError>;
757 fn rate_limiter_initial_last_request(
760 &self,
761 now: std::time::Instant,
762 interval: Duration,
763 ) -> std::time::Instant;
764 fn rate_limiter_burst_waits(
765 &self,
766 interval: Duration,
767 burst: usize,
768 reservations: usize,
769 ) -> Vec<Option<Duration>>;
770 fn retry_loop_preserves_metadata_on_exhaustion(
771 &self,
772 ) -> impl std::future::Future<
773 Output = (keyhog_core::VerificationResult, HashMap<String, String>),
774 > + Send;
775 fn retry_delay_bounds_for_attempt(&self, attempt: usize, base_delay_ms: u64) -> (u64, u64);
776 fn multi_step_rate_limit_service_name<'a>(
777 &self,
778 spec: &'a keyhog_core::VerifySpec,
779 auth: &'a keyhog_core::AuthSpec,
780 ) -> &'a str;
781 fn evaluate_success_for_test(
782 &self,
783 spec: &keyhog_core::SuccessSpec,
784 status: u16,
785 body: &str,
786 ) -> bool;
787 fn evaluate_success_result_for_test(
788 &self,
789 spec: &keyhog_core::SuccessSpec,
790 status: u16,
791 body: &str,
792 ) -> Result<bool, String>;
793 fn body_indicates_error_for_test(&self, body: &str) -> bool;
794 fn extract_metadata_for_test(
795 &self,
796 specs: &[keyhog_core::MetadataSpec],
797 body: &str,
798 ) -> Result<HashMap<String, String>, String>;
799 fn retryable_http_status_for_test(&self, status: u16) -> bool;
800 fn success_spec_is_explicit_for_test(&self, spec: &keyhog_core::SuccessSpec) -> bool;
801 fn resolve_live_verdict_for_test(
802 &self,
803 is_live: bool,
804 success_is_explicit: bool,
805 body: &str,
806 ) -> bool;
807 fn record_inflight_cap_bypass_for_test(&self, max_inflight_keys: usize) -> usize;
808 fn verification_result_is_cacheable_for_test(
809 &self,
810 result: &keyhog_core::VerificationResult,
811 ) -> bool;
812 fn ssrf_check_url_with_resolved_addrs_for_test(
813 &self,
814 raw_url: &str,
815 addrs: &[std::net::SocketAddr],
816 allow_private_ips: bool,
817 ) -> Result<(), keyhog_core::VerificationResult>;
818 fn proxied_request_target_for_test(
819 &self,
820 raw_url: &str,
821 allow_private_ips: bool,
822 allow_http: bool,
823 ) -> impl std::future::Future<Output = Result<(), keyhog_core::VerificationResult>> + Send;
824 fn clear_pinned_request_client_cache(&self);
825 fn pinned_request_client_cache_len(&self) -> usize;
826 fn pinned_request_client_cache_len_for_host(&self, host: &str) -> usize;
827 fn pinned_request_client_for_test(
828 &self,
829 host: &str,
830 addrs: &[std::net::SocketAddr],
831 timeout: Duration,
832 insecure_tls: bool,
833 ) -> Result<(), keyhog_core::VerificationResult>;
834 fn build_finding(
835 &self,
836 group: keyhog_core::DedupedMatch,
837 verification: keyhog_core::VerificationResult,
838 metadata: HashMap<String, String>,
839 ) -> keyhog_core::VerifiedFinding;
840 fn built_request_header_body_for_test(
850 &self,
851 header_templates: &[(&str, &str)],
852 body_template: Option<&str>,
853 credential: &str,
854 companions: &HashMap<String, String>,
855 ) -> (Vec<(String, String)>, Option<String>);
856 }
857
858 impl VerifierTestApi for TestApi {
859 const OOB_COMPANION_URL: &'static str = crate::interpolate::OOB_COMPANION_URL;
860 const OOB_COMPANION_HOST: &'static str = crate::interpolate::OOB_COMPANION_HOST;
861 const OOB_COMPANION_ID: &'static str = crate::interpolate::OOB_COMPANION_ID;
862
863 fn ip_addr_is_bogon(&self, ip: std::net::IpAddr) -> bool {
864 crate::bogon::ip_addr_is_bogon(ip)
865 }
866
867 fn resolve_field(
868 &self,
869 field: &str,
870 credential: &str,
871 companions: &HashMap<String, String>,
872 ) -> String {
873 crate::interpolate::resolve_field(field, credential, companions)
874 }
875
876 fn sanitize_oob_value(&self, s: &str) -> String {
877 crate::interpolate::sanitize_oob_value(s)
878 }
879
880 fn sanitize_raw_value(&self, s: &str) -> String {
881 crate::interpolate::sanitize_raw_value(s)
882 }
883
884 fn interpolate(
885 &self,
886 template: &str,
887 credential: &str,
888 companions: &HashMap<String, String>,
889 ) -> String {
890 crate::interpolate::interpolate(template, credential, companions)
891 }
892
893 fn interpolate_url(
894 &self,
895 template: &str,
896 credential: &str,
897 companions: &HashMap<String, String>,
898 ) -> String {
899 crate::interpolate::interpolate_url(template, credential, companions)
900 }
901
902 fn interpolate_http_value(
903 &self,
904 template: &str,
905 credential: &str,
906 companions: &HashMap<String, String>,
907 ) -> String {
908 crate::interpolate::interpolate_http_value(template, credential, companions)
909 }
910
911 fn companions_with_oob(
912 &self,
913 base: &HashMap<String, String>,
914 minted_host: &str,
915 minted_url: &str,
916 minted_id: &str,
917 ) -> HashMap<String, String> {
918 crate::interpolate::companions_with_oob(base, minted_host, minted_url, minted_id)
919 .into_iter()
920 .map(|(name, value)| (name.to_string(), value))
921 .collect()
922 }
923
924 fn built_request_header_body_for_test(
925 &self,
926 header_templates: &[(&str, &str)],
927 body_template: Option<&str>,
928 credential: &str,
929 companions: &HashMap<String, String>,
930 ) -> (Vec<(String, String)>, Option<String>) {
931 let client = reqwest::Client::new();
932 let builder = client.post("https://verify.example.invalid/probe");
936 let specs: Vec<keyhog_core::HeaderSpec> = header_templates
937 .iter()
938 .map(|(name, value)| keyhog_core::HeaderSpec {
939 name: (*name).to_string(),
940 value: (*value).to_string(),
941 })
942 .collect();
943 let builder = crate::verify::request::apply_header_body_templates(
944 builder,
945 &specs,
946 body_template,
947 credential,
948 companions,
949 );
950 let request = builder
951 .build()
952 .expect("a sanitized verification request must always build");
953 let headers = request
954 .headers()
955 .iter()
956 .map(|(name, value)| {
957 (
958 name.as_str().to_string(),
959 String::from_utf8_lossy(value.as_bytes()).into_owned(),
960 )
961 })
962 .collect();
963 let body = request
964 .body()
965 .and_then(reqwest::Body::as_bytes)
966 .map(|bytes| String::from_utf8_lossy(bytes).into_owned());
967 (headers, body)
968 }
969
970 fn builtin_service_domains(
971 &self,
972 ) -> &'static HashMap<&'static str, &'static [&'static str]> {
973 crate::domain_allowlist::builtin_service_domains()
974 }
975
976 fn effective_allowlist(&self, spec: &keyhog_core::VerifySpec) -> Option<Vec<String>> {
977 crate::domain_allowlist::effective_allowlist(spec)
978 }
979
980 fn host_is_allowed(&self, host: &str, allowlist: &[String]) -> bool {
981 crate::domain_allowlist::host_is_allowed(host, allowlist)
982 }
983
984 fn check_url_against_spec(
985 &self,
986 raw_url: &str,
987 spec: &keyhog_core::VerifySpec,
988 ) -> Result<(), String> {
989 crate::domain_allowlist::check_url_against_spec(raw_url, spec)
990 }
991
992 fn engine_detector_verify_service(
993 &self,
994 engine: &crate::VerificationEngine,
995 detector_id: &str,
996 ) -> Option<String> {
997 engine
998 .detectors
999 .get(detector_id)
1000 .and_then(|detector| detector.verify.as_ref())
1001 .map(|verify| verify.service.clone())
1002 }
1003
1004 fn engine_inflight_count(&self, engine: &crate::VerificationEngine) -> usize {
1005 engine
1006 .inflight_count
1007 .load(std::sync::atomic::Ordering::Acquire)
1008 }
1009
1010 fn format_sigv4_timestamps(&self, unix_secs: u64) -> (String, String) {
1011 crate::sigv4::format_sigv4_timestamps(unix_secs)
1012 }
1013
1014 fn parse_aws_sts_success_metadata(
1015 &self,
1016 body: &str,
1017 ) -> Result<HashMap<String, String>, String> {
1018 crate::verify::parse_aws_sts_success_metadata(body)
1019 }
1020
1021 fn classify_aws_sts_failure(
1022 &self,
1023 status: u16,
1024 body: &str,
1025 ) -> (keyhog_core::VerificationResult, bool) {
1026 crate::verify::classify_aws_sts_failure(status, body)
1027 }
1028
1029 fn classify_aws_sts_http_200(
1030 &self,
1031 body: &str,
1032 ) -> (
1033 keyhog_core::VerificationResult,
1034 HashMap<String, String>,
1035 bool,
1036 ) {
1037 crate::verify::classify_aws_sts_http_200(body)
1038 }
1039
1040 fn script_auth_result_for_test(&self, output: &str) -> keyhog_core::VerificationResult {
1041 crate::verify::script_auth_result(output)
1042 }
1043
1044 fn valid_aws_format_for_test(&self, access_key: &str, secret_key: &str) -> bool {
1045 crate::verify::valid_aws_format(access_key, secret_key)
1046 }
1047
1048 fn validate_aws_region_for_test(
1049 &self,
1050 region: &str,
1051 ) -> Result<(), keyhog_core::VerificationResult> {
1052 crate::verify::validate_aws_region(region)
1053 }
1054
1055 async fn build_aws_probe_final_for_test(
1056 &self,
1057 access_key: &str,
1058 secret_key: &str,
1059 region: &str,
1060 ) -> (
1061 keyhog_core::VerificationResult,
1062 HashMap<String, String>,
1063 bool,
1064 ) {
1065 let client = reqwest::Client::builder()
1066 .no_proxy()
1067 .build()
1068 .expect("test verifier client builds");
1069 let companions = HashMap::<String, String>::new();
1070 match crate::verify::build_aws_probe(
1071 access_key,
1072 secret_key,
1073 &None,
1074 region,
1075 access_key,
1076 &companions,
1077 Duration::from_millis(10),
1078 &client,
1079 false,
1080 false,
1081 false,
1082 false,
1083 )
1084 .await
1085 {
1086 crate::verify::RequestBuildResult::Final {
1087 result,
1088 metadata,
1089 transient,
1090 } => (result, metadata, transient),
1091 crate::verify::RequestBuildResult::Ready(_) => {
1092 panic!("AWS probe preflight unexpectedly reached network-ready request")
1093 }
1094 }
1095 }
1096
1097 fn rate_limit_feedback_sequence(&self) -> (usize, usize, usize, usize, usize) {
1098 crate::verify::rate_limit_feedback_sequence_for_test()
1099 }
1100
1101 async fn retry_loop_records_rate_limit_feedback(&self) -> usize {
1102 crate::verify::retry_loop_records_rate_limit_feedback_for_test().await
1103 }
1104
1105 fn interactsh_client_for_test(
1106 &self,
1107 server: &str,
1108 ) -> Result<crate::oob::InteractshClient, crate::oob::InteractshError> {
1109 crate::oob::InteractshClient::for_test(server)
1110 }
1111
1112 fn interactsh_client_correlation_id<'a>(
1113 &self,
1114 client: &'a crate::oob::InteractshClient,
1115 ) -> &'a str {
1116 client.correlation_id()
1117 }
1118
1119 fn interactsh_client_mint_url(
1120 &self,
1121 client: &crate::oob::InteractshClient,
1122 ) -> TestMintedUrl {
1123 test_minted_url(client.mint_url())
1124 }
1125
1126 fn oob_session_for_test(
1127 &self,
1128 client: Arc<crate::oob::InteractshClient>,
1129 config: crate::oob::OobConfig,
1130 ) -> Arc<crate::oob::OobSession> {
1131 crate::oob::OobSession::for_test(client, config)
1132 }
1133
1134 fn engine_set_oob_session_for_test(
1135 &self,
1136 engine: &mut crate::VerificationEngine,
1137 session: Arc<crate::oob::OobSession>,
1138 ) {
1139 engine.oob_session = Some(session);
1140 }
1141
1142 fn oob_session_mint(&self, session: &crate::oob::OobSession) -> TestMintedUrl {
1143 test_minted_url(session.mint())
1144 }
1145
1146 fn oob_session_default_timeout(&self, session: &crate::oob::OobSession) -> Duration {
1147 session.config_default_timeout()
1148 }
1149
1150 fn oob_session_set_degraded_for_test(
1151 &self,
1152 session: &crate::oob::OobSession,
1153 degraded: bool,
1154 ) {
1155 session.set_degraded_for_test(degraded);
1156 }
1157
1158 fn oob_session_store_and_notify(
1159 &self,
1160 session: &crate::oob::OobSession,
1161 interaction: crate::oob::Interaction,
1162 ) {
1163 session.store_and_notify_for_test(interaction);
1164 }
1165
1166 fn oob_session_waiter_count(&self, session: &crate::oob::OobSession) -> usize {
1167 session.waiter_count_for_test()
1168 }
1169
1170 fn oob_session_active_waiter_count(&self, session: &crate::oob::OobSession) -> usize {
1171 session.active_waiter_count_for_test()
1172 }
1173
1174 fn oob_session_poll_generation(&self, session: &crate::oob::OobSession) -> u64 {
1175 session.poll_generation_for_test()
1176 }
1177
1178 fn oob_session_abort_poller_for_drop(&self, session: &crate::oob::OobSession) {
1179 session.abort_poller_for_drop();
1180 }
1181
1182 fn decrypt_entry_for_test(
1183 &self,
1184 aes_key: &[u8],
1185 b64: &str,
1186 ) -> Result<Option<crate::oob::Interaction>, crate::oob::InteractshError> {
1187 crate::oob::decrypt_entry_for_test(aes_key, b64)
1188 }
1189
1190 fn oob_collector_ssrf_check_dns_result(
1191 &self,
1192 server: &str,
1193 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
1194 ) -> Result<(), crate::oob::InteractshError> {
1195 crate::oob::ssrf_check_collector_dns_result_for_test(server, resolved)
1196 }
1197
1198 fn oob_collector_reuses_proxy_client(
1199 &self,
1200 server: &str,
1201 proxy_in_use: bool,
1202 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
1203 ) -> Result<bool, crate::oob::InteractshError> {
1204 crate::oob::collector_reuses_proxy_client_for_test(server, proxy_in_use, resolved)
1205 }
1206
1207 fn rate_limiter_initial_last_request(
1208 &self,
1209 now: std::time::Instant,
1210 interval: Duration,
1211 ) -> std::time::Instant {
1212 crate::rate_limit::initial_last_request(now, interval)
1213 }
1214
1215 fn rate_limiter_burst_waits(
1216 &self,
1217 interval: Duration,
1218 burst: usize,
1219 reservations: usize,
1220 ) -> Vec<Option<Duration>> {
1221 crate::rate_limit::burst_reservation_waits_for_test(interval, burst, reservations)
1222 }
1223
1224 async fn retry_loop_preserves_metadata_on_exhaustion(
1225 &self,
1226 ) -> (keyhog_core::VerificationResult, HashMap<String, String>) {
1227 crate::verify::retry_loop_preserves_metadata_on_exhaustion_for_test().await
1228 }
1229
1230 fn retry_delay_bounds_for_attempt(&self, attempt: usize, base_delay_ms: u64) -> (u64, u64) {
1231 crate::verify::retry_delay_bounds_for_attempt(attempt, base_delay_ms)
1232 }
1233
1234 fn multi_step_rate_limit_service_name<'a>(
1235 &self,
1236 spec: &'a keyhog_core::VerifySpec,
1237 auth: &'a keyhog_core::AuthSpec,
1238 ) -> &'a str {
1239 crate::verify::multi_step_rate_limit_service_name(spec, auth)
1240 }
1241
1242 fn evaluate_success_for_test(
1243 &self,
1244 spec: &keyhog_core::SuccessSpec,
1245 status: u16,
1246 body: &str,
1247 ) -> bool {
1248 crate::verify::evaluate_success(spec, status, body)
1249 .expect("success contract should evaluate cleanly")
1250 }
1251
1252 fn evaluate_success_result_for_test(
1253 &self,
1254 spec: &keyhog_core::SuccessSpec,
1255 status: u16,
1256 body: &str,
1257 ) -> Result<bool, String> {
1258 crate::verify::evaluate_success(spec, status, body).map_err(|error| error.to_string())
1259 }
1260
1261 fn body_indicates_error_for_test(&self, body: &str) -> bool {
1262 crate::verify::body_indicates_error(body)
1263 }
1264
1265 fn extract_metadata_for_test(
1266 &self,
1267 specs: &[keyhog_core::MetadataSpec],
1268 body: &str,
1269 ) -> Result<HashMap<String, String>, String> {
1270 crate::verify::extract_provider_evidence(specs, body).map_err(|error| error.to_string())
1271 }
1272
1273 fn retryable_http_status_for_test(&self, status: u16) -> bool {
1274 crate::verify::retryable_http_status(status)
1275 }
1276
1277 fn success_spec_is_explicit_for_test(&self, spec: &keyhog_core::SuccessSpec) -> bool {
1278 crate::verify::success_spec_is_explicit(spec)
1279 }
1280
1281 fn resolve_live_verdict_for_test(
1282 &self,
1283 is_live: bool,
1284 success_is_explicit: bool,
1285 body: &str,
1286 ) -> bool {
1287 crate::verify::resolve_live_verdict(is_live, success_is_explicit, body)
1288 }
1289
1290 fn record_inflight_cap_bypass_for_test(&self, max_inflight_keys: usize) -> usize {
1291 crate::verify::note_inflight_cap_bypass(max_inflight_keys)
1292 }
1293
1294 fn verification_result_is_cacheable_for_test(
1295 &self,
1296 result: &keyhog_core::VerificationResult,
1297 ) -> bool {
1298 crate::verify::verification_result_is_cacheable(result)
1299 }
1300
1301 fn ssrf_check_url_with_resolved_addrs_for_test(
1302 &self,
1303 raw_url: &str,
1304 addrs: &[std::net::SocketAddr],
1305 allow_private_ips: bool,
1306 ) -> Result<(), keyhog_core::VerificationResult> {
1307 crate::verify::ssrf_check_url_with_resolved_addrs_for_test(
1308 raw_url,
1309 addrs,
1310 allow_private_ips,
1311 )
1312 }
1313
1314 async fn proxied_request_target_for_test(
1315 &self,
1316 raw_url: &str,
1317 allow_private_ips: bool,
1318 allow_http: bool,
1319 ) -> Result<(), keyhog_core::VerificationResult> {
1320 let client = reqwest::Client::builder()
1321 .no_proxy()
1322 .build()
1323 .expect("test verifier client builds");
1324 crate::verify::resolved_client_for_url(
1325 &client,
1326 raw_url,
1327 Duration::from_millis(10),
1328 allow_private_ips,
1329 allow_http,
1330 true,
1331 false,
1332 )
1333 .await
1334 .map(|_| ())
1335 }
1336
1337 fn clear_pinned_request_client_cache(&self) {
1338 crate::verify::clear_pinned_client_cache_for_test();
1339 }
1340
1341 fn pinned_request_client_cache_len(&self) -> usize {
1342 crate::verify::pinned_client_cache_len_for_test()
1343 }
1344
1345 fn pinned_request_client_cache_len_for_host(&self, host: &str) -> usize {
1346 crate::verify::pinned_client_cache_len_for_host_for_test(host)
1347 }
1348
1349 fn pinned_request_client_for_test(
1350 &self,
1351 host: &str,
1352 addrs: &[std::net::SocketAddr],
1353 timeout: Duration,
1354 insecure_tls: bool,
1355 ) -> Result<(), keyhog_core::VerificationResult> {
1356 crate::verify::pinned_client_for_test(host, addrs, timeout, insecure_tls)
1357 }
1358
1359 fn build_finding(
1360 &self,
1361 group: keyhog_core::DedupedMatch,
1362 verification: keyhog_core::VerificationResult,
1363 metadata: HashMap<String, String>,
1364 ) -> keyhog_core::VerifiedFinding {
1365 crate::into_finding(group, verification, metadata)
1366 }
1367 }
1368}