Skip to main content

keyhog_verifier/
lib.rs

1//! Live credential verification: confirms whether detected secrets are actually
2//! active by making HTTP requests to the service's API endpoint as specified in
3//! each detector's `[detector.verify]` configuration.
4
5#![allow(clippy::too_many_arguments)]
6#![allow(clippy::type_complexity)]
7
8/// Local HTTP compatibility shim backed by reqwest..
9pub mod reqwest {
10    pub use reqwest::*;
11}
12
13/// Shared in-memory verification cache.
14pub mod cache;
15pub mod domain_allowlist;
16pub mod interpolate;
17pub mod oob;
18pub mod rate_limit;
19mod ssrf;
20mod verify;
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::Duration;
25
26use dashmap::DashMap;
27use keyhog_core::{redact, DedupedMatch, DetectorSpec, VerificationResult, VerifiedFinding};
28
29// Re-export dedup types from core so existing consumers (`use keyhog_verifier::DedupedMatch`)
30// continue to work without source changes.
31use crate::reqwest::{Client, Error as ReqwestError};
32pub use keyhog_core::{dedup_matches, DedupScope};
33use thiserror::Error;
34use tokio::sync::{Notify, Semaphore};
35
36/// Errors returned while constructing or executing live verification.
37#[derive(Debug, Error)]
38pub enum VerifyError {
39    #[error(
40        "failed to send HTTP request: {0}. Fix: check network access, proxy settings, and the verification endpoint"
41    )]
42    Http(#[from] ReqwestError),
43    #[error(
44        "failed to build configured HTTP client: {0}. Fix: use a valid timeout and supported TLS/network configuration"
45    )]
46    ClientBuild(ReqwestError),
47    #[error(
48        "failed to resolve verification field: {0}. Fix: use `match` or `companion.<name>` fields that exist in the detector spec"
49    )]
50    FieldResolution(String),
51}
52
53/// Live-verification engine with shared client, cache, and concurrency limits.
54pub struct VerificationEngine {
55    client: Client,
56    detectors: Arc<HashMap<Arc<str>, DetectorSpec>>,
57    /// Per-service concurrency limit to avoid hammering APIs.
58    service_semaphores: Arc<HashMap<Arc<str>, Arc<Semaphore>>>,
59    /// Global concurrency limit.
60    global_semaphore: Arc<Semaphore>,
61    timeout: Duration,
62    /// Response cache to avoid re-verifying the same credential.
63    cache: Arc<cache::VerificationCache>,
64    /// One in-flight request per (detector_id, credential). DashMap (per-shard
65    /// locking) replaces the previous parking_lot::Mutex<HashMap> which was an
66    /// async anti-pattern — see audits/legendary-2026-04-26.
67    pub(crate) inflight: Arc<DashMap<(Arc<str>, Arc<str>), Arc<Notify>>>,
68    pub(crate) max_inflight_keys: usize,
69    pub(crate) danger_allow_private_ips: bool,
70    pub(crate) danger_allow_http: bool,
71    /// Optional OOB session. When `Some`, detectors with `[detector.verify.oob]`
72    /// receive a per-finding callback URL and the engine waits for the
73    /// service to call back. When `None`, those detectors fall through to
74    /// HTTP-only success criteria. Set via [`VerificationEngine::enable_oob`].
75    pub(crate) oob_session: Option<Arc<oob::OobSession>>,
76}
77
78/// Runtime configuration for live verification.
79pub struct VerifyConfig {
80    /// End-to-end timeout for one verification attempt.
81    pub timeout: Duration,
82    /// Maximum concurrent requests allowed per service.
83    pub max_concurrent_per_service: usize,
84    /// Maximum concurrent verification tasks overall.
85    pub max_concurrent_global: usize,
86    /// Upper bound for distinct in-flight deduplication keys.
87    pub max_inflight_keys: usize,
88    /// Whether to skip SSRF protection for private IP addresses.
89    pub danger_allow_private_ips: bool,
90    /// Whether to allow plaintext HTTP verification URLs. Default `false`:
91    /// production paths must use HTTPS so credentials are never sent in the
92    /// clear. Test fixtures (mock HTTP servers, in-memory listeners) opt in.
93    pub danger_allow_http: bool,
94}
95
96impl Default for VerifyConfig {
97    fn default() -> Self {
98        Self {
99            timeout: Duration::from_secs(5),
100            max_concurrent_per_service: 5,
101            max_concurrent_global: 20,
102            max_inflight_keys: 10_000,
103            danger_allow_private_ips: false,
104            danger_allow_http: false,
105        }
106    }
107}
108
109/// Convert a [`DedupedMatch`] into a [`VerifiedFinding`] with the given verification result.
110pub(crate) fn into_finding(
111    group: DedupedMatch,
112    verification: VerificationResult,
113    metadata: HashMap<String, String>,
114) -> VerifiedFinding {
115    VerifiedFinding {
116        detector_id: group.detector_id,
117        detector_name: group.detector_name,
118        service: group.service,
119        severity: group.severity,
120        credential_redacted: redact(&group.credential),
121        credential_hash: group.credential_hash,
122        location: group.primary_location,
123        verification,
124        metadata,
125        additional_locations: group.additional_locations,
126        confidence: group.confidence,
127    }
128}