1#![allow(clippy::too_many_arguments)]
6#![allow(clippy::type_complexity)]
7
8mod bogon;
9mod 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
27pub use keyhog_core::{dedup_matches, DedupScope};
30use reqwest::{Client, Error as ReqwestError};
31use thiserror::Error;
32use tokio::sync::{Notify, Semaphore};
33
34#[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
59pub struct VerificationEngine {
61 client: Client,
62 detectors: Arc<HashMap<Arc<str>, DetectorSpec>>,
63 service_semaphores: Arc<HashMap<Arc<str>, Arc<Semaphore>>>,
65 pub(crate) max_concurrent_per_service: usize,
69 global_semaphore: Arc<Semaphore>,
71 timeout: Duration,
72 cache: Arc<cache::VerificationCache>,
74 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 pub(crate) insecure_tls: bool,
88 pub(crate) proxy_in_use: bool,
92 pub(crate) allow_script_verify: bool,
95 pub(crate) oob_session: Option<Arc<oob::OobSession>>,
101}
102
103pub struct VerifyConfig {
119 pub timeout: Duration,
121 pub max_concurrent_per_service: usize,
123 pub max_concurrent_global: usize,
125 pub max_inflight_keys: usize,
127 pub danger_allow_private_ips: bool,
129 pub danger_allow_http: bool,
133 pub proxy: Option<String>,
140 pub insecure_tls: bool,
144 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
168pub(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 let proxy = reqwest::Proxy::all(&url)
195 .map_err(|_| invalid_proxy_url_diagnostic(Some(&parsed)))?;
196 Ok(builder.proxy(proxy))
197 }
198 }
199}
200
201fn 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
221fn 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
238pub fn proxy_is_active(explicit: Option<&str>) -> bool {
249 matches!(resolve_proxy_mode(explicit), ProxyMode::Explicit(_))
250}
251
252pub(crate) const DEFAULT_HTTPS_PORT: u16 = 443;
256
257pub(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
275pub(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
295pub(crate) fn into_finding(
297 group: DedupedMatch,
298 verification: VerificationResult,
299 mut metadata: HashMap<String, String>,
300) -> VerifiedFinding {
301 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 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#[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 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 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 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 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 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 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 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 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 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 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 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}