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