pub mod cache;
mod interpolate;
mod ssrf;
mod verify;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use keyhog_core::{
DedupedMatch, DetectorSpec, VerificationResult, VerifiedFinding,
redact,
};
pub use keyhog_core::{DedupScope, dedup_matches};
use reqwest::Client;
use thiserror::Error;
use tokio::sync::{Notify, Semaphore};
#[derive(Debug, Error)]
pub enum VerifyError {
#[error(
"failed to send HTTP request: {0}. Fix: check network access, proxy settings, and the verification endpoint"
)]
Http(#[from] reqwest::Error),
#[error(
"failed to build configured HTTP client: {0}. Fix: use a valid timeout and supported TLS/network configuration"
)]
ClientBuild(reqwest::Error),
#[error(
"failed to resolve verification field: {0}. Fix: use `match` or `companion.<name>` fields that exist in the detector spec"
)]
FieldResolution(String),
}
pub struct VerificationEngine {
client: Client,
detectors: HashMap<String, DetectorSpec>,
service_semaphores: HashMap<String, Arc<Semaphore>>,
global_semaphore: Arc<Semaphore>,
timeout: Duration,
cache: Arc<cache::VerificationCache>,
inflight: Arc<DashMap<(String, String), Arc<Notify>>>,
max_inflight_keys: usize,
}
pub struct VerifyConfig {
pub timeout: Duration,
pub max_concurrent_per_service: usize,
pub max_concurrent_global: usize,
pub max_inflight_keys: usize,
}
impl Default for VerifyConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(5),
max_concurrent_per_service: 5,
max_concurrent_global: 20,
max_inflight_keys: 10_000,
}
}
}
pub(crate) fn into_finding(
group: DedupedMatch,
verification: VerificationResult,
metadata: HashMap<String, String>,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: group.detector_id,
detector_name: group.detector_name,
service: group.service,
severity: group.severity,
credential_redacted: redact(&group.credential),
location: group.primary_location,
verification,
metadata,
additional_locations: group.additional_locations,
confidence: group.confidence,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interpolate::interpolate;
use crate::ssrf::{is_private_url, parse_url_host};
const MAX_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
use keyhog_core::{
AuthSpec, DetectorSpec, HttpMethod, MatchLocation, RawMatch, Severity, SuccessSpec,
VerificationResult,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn verify_url_with_unicode_hostname() {
let unicode_urls = vec![
"https://münchen.example.com/api",
"https://日本語.example.com/verify",
"https://test.домен.рф/check",
"https://example.ä¸å›½/path",
];
for url in unicode_urls {
let host = parse_url_host(url);
match host {
Some(h) => {
assert!(
!h.is_empty(),
"Parsed host should not be empty for URL: {}",
url
);
}
None => {
}
}
}
let interpolated = interpolate("https://example.com/日本語/{{match}}", "test-key", None);
assert!(
interpolated.contains("test-key")
|| interpolated.contains("%7B%7Bmatch%7D%7D")
|| interpolated.contains("%2D"),
"Interpolated URL should contain credential or encoding: {}",
interpolated
);
}
#[test]
fn verify_url_with_percent_encoded_path_traversal() {
let traversal_urls = vec![
"https://example.com/api/%2e%2e/%2e%2e/etc/passwd",
"https://example.com/api/%2e%2e%2fadmin",
"https://example.com/%252e%252e/admin", "https://example.com/api/..%2f..%2fsecret",
];
for url in traversal_urls {
let parsed = reqwest::Url::parse(url);
assert!(
parsed.is_ok(),
"URL with percent-encoding should parse: {}",
url
);
assert!(
!is_private_url(url),
"Public URL with path traversal encoding should not be private: {}",
url
);
}
let traversal_cred = "../../../etc/passwd";
let interpolated = interpolate("https://api.example.com/{{match}}", traversal_cred, None);
assert!(
!interpolated.contains("../"),
"Path traversal in credential should be encoded: {}",
interpolated
);
assert!(
interpolated.contains("%2F") || interpolated.contains("."),
"Credential should be encoded or preserved but not traverse: {}",
interpolated
);
}
#[test]
fn verify_with_sql_injection_credential() {
let sql_injection_creds = vec![
"' OR '1'='1",
"'; DROP TABLE users; --",
"' UNION SELECT * FROM passwords --",
"1' AND 1=1 --",
"admin'--",
"1'; DELETE FROM credentials WHERE '1'='1",
];
for cred in sql_injection_creds {
let interpolated = interpolate("{{match}}", cred, None);
assert_eq!(
interpolated, cred,
"SQL injection credential should be preserved literally"
);
let url_interpolated =
interpolate("https://api.example.com/?key={{match}}", cred, None);
assert!(
!url_interpolated.contains(" "),
"Spaces should be encoded in URL: {}",
url_interpolated
);
assert!(
url_interpolated.contains("%27") || url_interpolated.contains("%22"),
"Quotes should be encoded: {}",
url_interpolated
);
}
}
#[tokio::test]
async fn verify_with_crlf_injection_credential() {
let crlf_payloads = vec![
"value\r\nHost: evil.com",
"token\r\n\r\nGET /admin HTTP/1.1\r\nHost: attacker.com",
"key\nX-Injected: malicious",
"secret\r\nContent-Length: 0\r\n\r\n",
];
for payload in crlf_payloads {
let interpolated_url =
interpolate("https://api.example.com/?token={{match}}", payload, None);
assert!(
!interpolated_url.contains('\r') && !interpolated_url.contains('\n'),
"CRLF characters must be encoded in URL: {:?}",
interpolated_url
);
assert!(
interpolated_url.contains("%0D") || interpolated_url.contains("%0A"),
"CRLF should be percent-encoded: {:?}",
interpolated_url
);
let interpolated_literal = interpolate("{{match}}", payload, None);
assert!(
!interpolated_literal.contains('\r') && !interpolated_literal.contains('\n'),
"CRLF should be stripped from raw interpolation: {:?}",
interpolated_literal
);
}
}
#[test]
fn verify_with_base64_encoded_credential() {
fn base64_encode(input: &str) -> String {
const CHARSET: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut result = String::new();
for chunk in bytes.chunks(3) {
let b = match chunk.len() {
1 => [chunk[0], 0, 0],
2 => [chunk[0], chunk[1], 0],
3 => [chunk[0], chunk[1], chunk[2]],
_ => [0, 0, 0],
};
let idx1 = (b[0] >> 2) as usize;
let idx2 = (((b[0] & 0b11) << 4) | (b[1] >> 4)) as usize;
let idx3 = (((b[1] & 0b1111) << 2) | (b[2] >> 6)) as usize;
let idx4 = (b[2] & 0b111111) as usize;
result.push(CHARSET[idx1] as char);
result.push(CHARSET[idx2] as char);
result.push(if chunk.len() > 1 { CHARSET[idx3] } else { b'=' } as char);
result.push(if chunk.len() > 2 { CHARSET[idx4] } else { b'=' } as char);
}
result
}
let original_cred = format!("sk_live_{}", "4242424242424242");
let base64_encoded = base64_encode(&original_cred);
assert_ne!(
original_cred, base64_encoded,
"Base64 encoding should produce different string"
);
let interpolated_original = interpolate("{{match}}", &original_cred, None);
let interpolated_base64 = interpolate("{{match}}", &base64_encoded, None);
assert_ne!(
interpolated_original, interpolated_base64,
"Original and base64 credentials should produce different interpolations"
);
assert!(
base64_encoded
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='),
"Base64 should only contain alphanumeric, +, /, = characters"
);
let double_encoded = base64_encode(&base64_encoded);
let interpolated_double = interpolate("{{match}}", &double_encoded, None);
assert_ne!(
interpolated_double, interpolated_base64,
"Double-encoded should differ from single-encoded"
);
}
#[tokio::test]
async fn verify_timeout_of_exactly_zero_ms() {
let zero_duration = Duration::from_millis(0);
let result = VerificationEngine::new(
&[],
VerifyConfig {
timeout: zero_duration,
max_concurrent_per_service: 1,
max_concurrent_global: 1,
max_inflight_keys: 100,
},
);
match result {
Ok(_) => {
}
Err(_) => {
}
}
}
#[test]
fn verify_timeout_of_u64_max_ms() {
let max_duration = Duration::from_millis(u64::MAX);
let result = std::panic::catch_unwind(|| {
VerificationEngine::new(
&[],
VerifyConfig {
timeout: max_duration,
max_concurrent_per_service: 1,
max_concurrent_global: 1,
max_inflight_keys: 100,
},
)
});
assert!(result.is_ok(), "u64::MAX timeout should not cause panic");
}
#[tokio::test]
async fn verify_with_empty_credential_string() {
let empty_cred = "";
let interpolated = interpolate("https://api.example.com/?key={{match}}", empty_cred, None);
assert_eq!(
interpolated, "https://api.example.com/?key=",
"Empty credential should result in empty query param"
);
let cache = cache::VerificationCache::default_ttl();
cache.put(
empty_cred,
"test-detector",
VerificationResult::Dead,
HashMap::new(),
);
let cached = cache.get(empty_cred, "test-detector");
assert!(cached.is_some(), "Empty credential should be cacheable");
assert!(
matches!(cached.unwrap().0, VerificationResult::Dead),
"Empty credential cache should return correct result"
);
}
#[tokio::test]
async fn verify_with_credential_longer_than_1mb() {
let mb_credential = "x".repeat(1024 * 1024 + 1024); assert!(
mb_credential.len() > MAX_RESPONSE_BODY_BYTES,
"Test credential should be > 1MB"
);
let interpolated = interpolate("{{match}}", &mb_credential, None);
assert_eq!(
interpolated.len(),
mb_credential.len(),
"Interpolated credential should preserve size"
);
let url_interpolated = interpolate(
"https://api.example.com/?key={{match}}",
&mb_credential,
None,
);
assert!(
url_interpolated.len() > mb_credential.len(),
"URL-encoded credential should be larger"
);
let cache = cache::VerificationCache::default_ttl();
cache.put(
&mb_credential,
"test-detector",
VerificationResult::Live,
HashMap::new(),
);
let cached = cache.get(&mb_credential, "test-detector");
assert!(
cached.is_some(),
"Large credential should be cacheable (stores hash)"
);
}
#[tokio::test]
async fn verify_two_detectors_same_credential_simultaneously() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let request_count = Arc::new(AtomicUsize::new(0));
let count_clone = request_count.clone();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let count = count_clone.clone();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
count.fetch_add(1, Ordering::SeqCst);
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"valid\": true}",
)
.await;
});
}
});
let detector1 = DetectorSpec {
id: "detector-1".into(),
name: "Detector 1".into(),
service: "test-service".into(),
severity: Severity::High,
patterns: vec![],
companion: None,
verify: Some(keyhog_core::VerifySpec {
method: HttpMethod::Get,
url: format!("http://127.0.0.1:{}/verify1", addr.port()),
auth: AuthSpec::None,
headers: vec![],
body: None,
success: SuccessSpec {
status: Some(200),
status_not: None,
body_contains: None,
body_not_contains: None,
json_path: None,
equals: None,
},
metadata: vec![],
timeout_ms: None,
}),
keywords: vec![],
};
let detector2 = DetectorSpec {
id: "detector-2".into(),
name: "Detector 2".into(),
service: "test-service".into(), severity: Severity::High,
patterns: vec![],
companion: None,
verify: Some(keyhog_core::VerifySpec {
method: HttpMethod::Get,
url: format!("http://127.0.0.1:{}/verify2", addr.port()),
auth: AuthSpec::None,
headers: vec![],
body: None,
success: SuccessSpec {
status: Some(200),
status_not: None,
body_contains: None,
body_not_contains: None,
json_path: None,
equals: None,
},
metadata: vec![],
timeout_ms: None,
}),
keywords: vec![],
};
let engine = VerificationEngine::new(
&[detector1.clone(), detector2.clone()],
VerifyConfig {
timeout: Duration::from_secs(2),
max_concurrent_per_service: 10,
max_concurrent_global: 20,
max_inflight_keys: 1000,
},
)
.unwrap();
let shared_credential = "shared-secret-key-12345";
let make_match = |detector: &DetectorSpec| RawMatch {
detector_id: detector.id.clone(),
detector_name: detector.name.clone(),
service: detector.service.clone(),
severity: Severity::High,
credential: shared_credential.into(),
companion: None,
location: MatchLocation {
source: "fs".into(),
file_path: Some("test.txt".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
entropy: None,
confidence: Some(0.9),
};
let match1 = make_match(&detector1);
let match2 = make_match(&detector2);
let group1 = dedup_matches(vec![match1], &DedupScope::Credential).pop().unwrap();
let group2 = dedup_matches(vec![match2], &DedupScope::Credential).pop().unwrap();
let findings = engine.verify_all(vec![group1, group2]).await;
assert_eq!(findings.len(), 2, "Should have 2 findings");
let detector_ids: Vec<_> = findings.iter().map(|f| &f.detector_id).collect();
assert!(detector_ids.contains(&&"detector-1".to_string()));
assert!(detector_ids.contains(&&"detector-2".to_string()));
}
#[test]
fn verify_url_with_no_path() {
let no_path_urls = vec!["https://api.example.com", "https://api.example.com:443"];
for url in no_path_urls {
let parsed = reqwest::Url::parse(url);
assert!(parsed.is_ok(), "URL without path should parse: {}", url);
let parsed = parsed.unwrap();
assert_eq!(
parsed.path(),
"/",
"URL without explicit path should default to /"
);
assert!(
!is_private_url(url),
"Public URL without path should not be private"
);
}
let interpolated = interpolate("https://api.example.com?key={{match}}", "test-value", None);
assert!(
interpolated == "https://api.example.com?key=test-value"
|| interpolated == "https://api.example.com?key=test%2Dvalue",
"Interpolation should add query to no-path URL: got {}",
interpolated
);
}
#[test]
fn verify_url_with_username_password_in_host() {
let urls_with_auth = vec![
"https://user:pass@api.example.com/endpoint",
"https://admin:secret123@host.com:8080/api",
"https://user%40domain:p%40ss@example.com/path",
];
for url in urls_with_auth {
let parsed = reqwest::Url::parse(url);
assert!(parsed.is_ok(), "URL with auth info should parse: {}", url);
let parsed = parsed.unwrap();
assert!(
parsed.username().is_empty() || !parsed.username().is_empty(),
"Username may or may not be present after normalization"
);
}
let interpolated = interpolate(
"https://{{match}}@api.example.com/endpoint",
"user:pass",
None,
);
assert!(
interpolated.contains("%40") || interpolated.contains("@"),
"URL interpolation should handle auth-like patterns"
);
}
#[test]
fn verify_spec_with_contradicting_success_criteria() {
let contradictory_spec = SuccessSpec {
status: Some(200),
status_not: Some(200),
body_contains: None,
body_not_contains: None,
json_path: None,
equals: None,
};
assert!(
contradictory_spec.status.is_some() && contradictory_spec.status_not.is_some(),
"Spec has both status and status_not defined"
);
assert_eq!(
contradictory_spec.status, contradictory_spec.status_not,
"Spec requires status to be {:?} and NOT be {:?}",
contradictory_spec.status, contradictory_spec.status_not
);
let body_contradiction = SuccessSpec {
status: Some(200),
status_not: None,
body_contains: Some("success".into()),
body_not_contains: Some("success".into()),
json_path: None,
equals: None,
};
assert_eq!(
body_contradiction.body_contains, body_contradiction.body_not_contains,
"Spec requires body to contain '{:?}' and NOT contain '{:?}'",
body_contradiction.body_contains, body_contradiction.body_not_contains
);
fn status_matches(status: Option<u16>, status_not: Option<u16>, code: u16) -> bool {
if let Some(expected) = status {
if code != expected {
return false;
}
}
if let Some(not_expected) = status_not {
if code == not_expected {
return false;
}
}
true
}
assert!(
!status_matches(Some(200), Some(200), 200),
"Contradictory spec should fail for status 200"
);
assert!(
!status_matches(Some(200), Some(200), 201),
"Contradictory spec should fail for status 201"
);
assert!(
!status_matches(Some(200), Some(200), 404),
"Contradictory spec should fail for status 404"
);
}
#[test]
fn body_analysis_on_deeply_nested_json() {
let mut deep_json = String::new();
for _ in 0..100 {
deep_json.push_str(r#"{"level": "#);
}
deep_json.push_str("\"value\"");
for _ in 0..100 {
deep_json.push('}');
}
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&deep_json);
assert!(parsed.is_ok(), "100-level deep JSON should parse");
let value = parsed.unwrap();
let mut current = &value;
for _ in 0..100 {
current = current
.get("level")
.expect("Should have 'level' key at each depth");
}
assert_eq!(current, &serde_json::Value::String("value".into()));
let mut deep_error_json = String::new();
for _ in 0..99 {
deep_error_json.push_str(r#"{"nested": "#);
}
deep_error_json.push_str(r#"{"error": "deep failure"}"#);
for _ in 0..99 {
deep_error_json.push('}');
}
let parsed_error: Result<serde_json::Value, _> = serde_json::from_str(&deep_error_json);
assert!(
parsed_error.is_ok(),
"Deep JSON with error should also parse"
);
let error_value = parsed_error.unwrap();
let mut current = &error_value;
for _ in 0..99 {
current = current.get("nested").expect("Should have 'nested' key");
}
assert!(
current.get("error").is_some(),
"Should be able to access deep error field"
);
}
#[test]
fn cache_behavior_same_credential_different_detectors() {
let cache = cache::VerificationCache::default_ttl();
let credential = "shared-credential-abc123";
cache.put(
credential,
"detector-1",
VerificationResult::Live,
HashMap::from([("source".into(), "det1".into())]),
);
cache.put(
credential,
"detector-2",
VerificationResult::Dead,
HashMap::from([("source".into(), "det2".into())]),
);
let cached1 = cache.get(credential, "detector-1");
assert!(cached1.is_some(), "Detector 1 should have cached result");
let (result1, meta1) = cached1.unwrap();
assert!(
matches!(result1, VerificationResult::Live),
"Detector 1 should have Live result"
);
assert_eq!(meta1.get("source"), Some(&"det1".to_string()));
let cached2 = cache.get(credential, "detector-2");
assert!(cached2.is_some(), "Detector 2 should have cached result");
let (result2, meta2) = cached2.unwrap();
assert!(
matches!(result2, VerificationResult::Dead),
"Detector 2 should have Dead result"
);
assert_eq!(meta2.get("source"), Some(&"det2".to_string()));
let cached3 = cache.get(credential, "detector-3");
assert!(
cached3.is_none(),
"Detector 3 should not have cached result"
);
assert_eq!(
cache.len(),
2,
"Cache should have 2 entries (one per detector)"
);
}
#[test]
fn verify_with_reversed_companion() {
let credential = "ABC123XYZ";
let reversed: String = credential.chars().rev().collect();
assert_eq!(reversed, "ZYX321CBA");
let interpolated = interpolate(
"https://api.example.com/?key={{match}}&companion={{companion.secret}}",
credential,
Some(&reversed),
);
assert!(
interpolated.contains("ABC123XYZ"),
"Interpolated URL should contain original credential"
);
assert!(
interpolated.contains("ZYX321CBA"),
"Interpolated URL should contain reversed companion"
);
let resolved =
crate::interpolate::resolve_field("companion.secret", credential, Some(&reversed));
assert_eq!(
resolved, reversed,
"Companion resolution should return reversed value"
);
}
#[test]
fn verify_auth_header_with_null_bytes() {
let null_byte_values = vec![
"Bearer token\0extra",
"ApiKey \x00null_injected",
"token\x00\x00double_null",
];
for value in null_byte_values {
let interpolated = interpolate("{{match}}", value, None);
assert_eq!(
interpolated, value,
"Null bytes should be preserved when template is exactly {{match}}"
);
let url_interpolated =
interpolate("https://api.example.com/?token={{match}}", value, None);
assert!(
url_interpolated.contains("%00") || !url_interpolated.contains('\0'),
"Null bytes should be encoded in URL context"
);
}
let header_template = "Bearer {{match}}";
let credential_with_null = "token\0null";
let interpolated_header = interpolate(header_template, credential_with_null, None);
assert!(
interpolated_header.contains("%00"),
"Embedded credential with null should be URL-encoded (contains %00): got {}",
interpolated_header
);
assert!(
!interpolated_header.contains('\0'),
"Raw null byte should not appear in interpolated result"
);
}
#[tokio::test]
async fn verify_rate_limiting_100_concurrent_requests() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let active_requests = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let active_clone = active_requests.clone();
let max_clone = max_concurrent.clone();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let active = active_clone.clone();
let max = max_clone.clone();
tokio::spawn(async move {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
loop {
let prev_max = max.load(Ordering::SeqCst);
if current <= prev_max
|| max
.compare_exchange(
prev_max,
current,
Ordering::SeqCst,
Ordering::SeqCst,
)
.is_ok()
{
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
active.fetch_sub(1, Ordering::SeqCst);
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\n{\"valid\": true}",
)
.await;
});
}
});
let detector = DetectorSpec {
id: "rate-limit-test".into(),
name: "Rate Limit Test".into(),
service: "rate-limited-service".into(),
severity: Severity::High,
patterns: vec![],
companion: None,
verify: Some(keyhog_core::VerifySpec {
method: HttpMethod::Get,
url: format!("http://127.0.0.1:{}/verify", addr.port()),
auth: AuthSpec::None,
headers: vec![],
body: None,
success: SuccessSpec {
status: Some(200),
status_not: None,
body_contains: None,
body_not_contains: None,
json_path: None,
equals: None,
},
metadata: vec![],
timeout_ms: None,
}),
keywords: vec![],
};
let per_service_limit = 5;
let engine = VerificationEngine::new(
&[detector.clone()],
VerifyConfig {
timeout: Duration::from_secs(5),
max_concurrent_per_service: per_service_limit,
max_concurrent_global: 100,
max_inflight_keys: 1000,
},
)
.unwrap();
let mut groups = Vec::new();
for i in 0..100 {
let m = RawMatch {
detector_id: "rate-limit-test".into(),
detector_name: "Rate Limit Test".into(),
service: "rate-limited-service".into(),
severity: Severity::High,
credential: format!("credential-{}", i),
companion: None,
location: MatchLocation {
source: "fs".into(),
file_path: Some(format!("test{}.txt", i)),
line: Some(i),
offset: 0,
commit: None,
author: None,
date: None,
},
entropy: None,
confidence: Some(0.9),
};
groups.push(dedup_matches(vec![m], &DedupScope::Credential).pop().unwrap());
}
let findings = engine.verify_all(groups).await;
assert_eq!(findings.len(), 100, "All 100 verifications should complete");
let actual_max = max_concurrent.load(Ordering::SeqCst);
println!("Max concurrent requests observed: {}", actual_max);
}
#[tokio::test]
async fn verify_response_with_infinite_chunked_transfer() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
.await;
loop {
let chunk = "5\r\nhello\r\n";
if stream.write_all(chunk.as_bytes()).await.is_err() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
});
}
});
let detector = DetectorSpec {
id: "infinite-chunk-test".into(),
name: "Infinite Chunk Test".into(),
service: "chunk-test-service".into(),
severity: Severity::High,
patterns: vec![],
companion: None,
verify: Some(keyhog_core::VerifySpec {
method: HttpMethod::Get,
url: format!("http://127.0.0.1:{}/chunked", addr.port()),
auth: AuthSpec::None,
headers: vec![],
body: None,
success: SuccessSpec {
status: Some(200),
status_not: None,
body_contains: None,
body_not_contains: None,
json_path: None,
equals: None,
},
metadata: vec![],
timeout_ms: Some(500), }),
keywords: vec![],
};
let engine = VerificationEngine::new(
&[detector],
VerifyConfig {
timeout: Duration::from_millis(500), max_concurrent_per_service: 5,
max_concurrent_global: 20,
max_inflight_keys: 1000,
},
)
.unwrap();
let m = RawMatch {
detector_id: "infinite-chunk-test".into(),
detector_name: "Infinite Chunk Test".into(),
service: "chunk-test-service".into(),
severity: Severity::High,
credential: "test-credential".into(),
companion: None,
location: MatchLocation {
source: "fs".into(),
file_path: Some("test.txt".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
entropy: None,
confidence: Some(0.9),
};
let group = dedup_matches(vec![m], &DedupScope::Credential).pop().unwrap();
let start = std::time::Instant::now();
let findings = engine.verify_all(vec![group]).await;
let elapsed = start.elapsed();
assert_eq!(findings.len(), 1);
assert!(
elapsed < Duration::from_secs(5),
"Should complete within timeout, took {:?}",
elapsed
);
}
#[tokio::test]
async fn verify_dns_resolution_nxdomain() {
use std::net::ToSocketAddrs;
let nxdomain_hosts = vec![
"this-definitely-does-not-exist-12345.invalid",
"nonexistent-domain-xyz123.example",
];
for host in nxdomain_hosts {
let addr_result = format!("{}:443", host).to_socket_addrs();
assert!(
addr_result.is_err() || addr_result.unwrap().next().is_none(),
"NXDOMAIN host {} should fail to resolve",
host
);
}
let valid_host = "localhost:443";
let valid_result = valid_host.to_socket_addrs();
assert!(
valid_result.is_ok(),
"localhost should resolve to addresses"
);
}
}