keyhog-verifier 0.5.41

keyhog-verifier: parallel async credential verification framework
Documentation
use std::collections::HashMap;
use std::sync::Arc;

use keyhog_core::{
    AuthSpec, DedupedMatch, DetectorSpec, MatchLocation, MetadataSpec, Severity, SuccessSpec,
    VerificationResult, VerifySpec,
};
use keyhog_verifier::{VerificationEngine, VerifyConfig};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

async fn malformed_json_server() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    tokio::spawn(async move {
        while let Ok((mut stream, _)) = listener.accept().await {
            tokio::spawn(async move {
                let mut buf = [0u8; 1024];
                let _ = stream.read(&mut buf).await;
                let body = r#"{"valid":true"#;
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
                    body.len()
                );
                let _ = stream.write_all(response.as_bytes()).await;
            });
        }
    });
    format!("http://127.0.0.1:{port}/verify")
}

async fn unauthorized_text_server() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    tokio::spawn(async move {
        while let Ok((mut stream, _)) = listener.accept().await {
            tokio::spawn(async move {
                let mut buf = [0u8; 1024];
                let _ = stream.read(&mut buf).await;
                let body = "invalid credential";
                let response = format!(
                    "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\n\r\n{body}",
                    body.len()
                );
                let _ = stream.write_all(response.as_bytes()).await;
            });
        }
    });
    format!("http://127.0.0.1:{port}/verify")
}

fn detector_for(url: String) -> DetectorSpec {
    DetectorSpec {
        tests: Vec::new(),
        id: "json-contract".into(),
        name: "JSON contract".into(),
        service: "test".into(),
        severity: Severity::High,
        patterns: vec![],
        companions: vec![],
        keywords: vec![],
        min_confidence: None,
        verify: Some(VerifySpec {
            service: "test".into(),
            url: Some(url),
            method: None,
            auth: Some(AuthSpec::None {}),
            headers: vec![],
            body: None,
            success: Some(SuccessSpec {
                status: Some(200),
                json_path: Some("$.valid".into()),
                equals: Some("true".into()),
                ..Default::default()
            }),
            metadata: vec![],
            timeout_ms: None,
            steps: vec![],
            allowed_domains: vec!["127.0.0.1".into()],
            oob: None,
        }),
        ..Default::default()
    }
}

fn group() -> DedupedMatch {
    DedupedMatch {
        detector_id: Arc::from("json-contract"),
        detector_name: Arc::from("JSON contract"),
        service: Arc::from("test"),
        severity: Severity::High,
        credential: keyhog_core::SensitiveString::from("secret"),
        credential_hash: [0u8; 32].into(),
        primary_location: MatchLocation {
            source: Arc::from("fs"),
            file_path: Some(Arc::from("fixture.txt")),
            line: Some(1),
            offset: 0,
            commit: None,
            author: None,
            date: None,
        },
        additional_locations: vec![],
        companions: HashMap::new(),
        entropy: None,
        confidence: Some(1.0),
    }
}

#[tokio::test]
async fn malformed_success_json_returns_error_not_dead() {
    let engine = VerificationEngine::new(
        &[detector_for(malformed_json_server().await)],
        VerifyConfig {
            danger_allow_private_ips: true,
            danger_allow_http: true,
            ..Default::default()
        },
    )
    .unwrap();

    let findings = engine.verify_all(vec![group()]).await;
    assert_eq!(
        findings.len(),
        1,
        "one verified group should return one finding"
    );

    match &findings[0].verification {
        VerificationResult::Error(message) => assert!(
            message.contains("response body is not valid JSON for success selector `$.valid`"),
            "error must explain the malformed success JSON contract, got {message:?}"
        ),
        other => panic!("malformed success JSON must not become {other:?}"),
    }
}

#[tokio::test]
async fn rejected_non_json_response_remains_dead_when_metadata_is_configured() {
    let mut detector = detector_for(unauthorized_text_server().await);
    detector.verify.as_mut().unwrap().metadata = vec![MetadataSpec {
        name: "account".into(),
        json_path: "$.account".into(),
        sensitivity: Default::default(),
    }];
    let engine = VerificationEngine::new(
        &[detector],
        VerifyConfig {
            danger_allow_private_ips: true,
            danger_allow_http: true,
            ..Default::default()
        },
    )
    .unwrap();

    let findings = engine.verify_all(vec![group()]).await;
    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].verification, VerificationResult::Dead);
    assert!(findings[0].metadata.is_empty());
}