use gossan_core::Target;
use gossan_keyhog_lite::{Chunk, ChunkMetadata, CompiledScanner};
use secfinding::{Evidence, Finding, Severity};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::RwLock;
static KEYHOG_SCANNER: OnceLock<CompiledScanner> = OnceLock::new();
static RAW_STORE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
pub(crate) fn store_raw_secret(hash: &str, secret: &str) {
let map = RAW_STORE.get_or_init(|| RwLock::new(HashMap::new()));
if let Ok(mut w) = map.write() {
w.insert(hash.to_string(), secret.to_string());
}
}
pub fn get_raw_secret(hash: &str) -> Option<String> {
RAW_STORE
.get()
.and_then(|map| map.read().ok().and_then(|r| r.get(hash).cloned()))
}
pub fn take_raw_secret(hash: &str) -> Option<String> {
RAW_STORE
.get()
.and_then(|map| map.write().ok().and_then(|mut w| w.remove(hash)))
}
pub fn clear_raw_secret(hash: &str) {
let _ = take_raw_secret(hash);
}
fn get_scanner() -> &'static CompiledScanner {
KEYHOG_SCANNER.get_or_init(|| {
let detectors = gossan_keyhog_lite::embedded_detectors();
assert!(
!detectors.is_empty(),
"embedded KeyHog detector corpus is empty; refusing to disable JS secret detection"
);
CompiledScanner::compile(detectors).unwrap_or_else(|e| {
panic!("failed to compile embedded KeyHog detector corpus: {e}")
})
})
}
use sha2::{Digest, Sha256};
pub fn scan(js_url: &str, body: &str, target: &Target) -> Vec<Finding> {
let scanner = get_scanner();
let mut findings = Vec::new();
let chunk = Chunk {
data: body.to_string(),
metadata: ChunkMetadata {
source_type: "js".into(),
path: Some(js_url.to_string()),
..Default::default()
},
};
let matches = scanner.scan(&chunk);
for m in matches {
let severity = map_severity(m.severity);
let mut hasher = Sha256::new();
hasher.update(m.credential.as_bytes());
let hash = hex::encode(hasher.finalize());
store_raw_secret(&hash, &m.credential);
let builder = Finding::builder("js", target.domain().unwrap_or("?"), severity)
.title(format!("Hardcoded {} identified", m.detector_name))
.detail(format!(
"A potential {} was found in {}. Verified credentials represent a high risk of account takeover.",
m.detector_name, js_url
))
.evidence(Evidence::JsSnippet {
url: std::sync::Arc::from(js_url),
line: m.location.line.unwrap_or(0),
snippet: std::sync::Arc::from(
gossan_keyhog_lite::redact(&m.credential).as_str(),
),
})
.tag("secret")
.tag("keyhog")
.tag(format!("det:{}", m.detector_id))
.tag(format!("hash:{}", hash))
.tag(m.service.to_string())
.kind(secfinding::FindingKind::SecretLeak);
if let Some(f) = builder.build_or_log() {
findings.push(f);
}
}
findings
}
fn map_severity(s: gossan_keyhog_lite::Severity) -> Severity {
match s {
gossan_keyhog_lite::Severity::Info => Severity::Info,
gossan_keyhog_lite::Severity::Low => Severity::Low,
gossan_keyhog_lite::Severity::Medium => Severity::Medium,
gossan_keyhog_lite::Severity::High => Severity::High,
gossan_keyhog_lite::Severity::Critical => Severity::Critical,
}
}
#[cfg(test)]
mod tests {
use super::*;
use gossan_core::{HostTarget, Target};
fn dummy_target() -> Target {
Target::Host(HostTarget {
ip: "127.0.0.1".parse().unwrap(),
domain: Some("example.com".into()),
})
}
#[test]
fn get_scanner_compiles_embedded_corpus() {
let scanner = get_scanner();
assert!(
!scanner.is_empty(),
"embedded KeyHog scanner must expose at least one detector"
);
}
#[test]
fn scan_empty_body_returns_empty() {
let target = dummy_target();
let findings = scan("https://example.com/app.js", "", &target);
assert!(findings.is_empty());
}
#[test]
fn scan_very_long_body_does_not_panic() {
let target = dummy_target();
let body = "x".repeat(1_000_000);
let findings = scan("https://example.com/app.js", &body, &target);
let _ = findings.len();
}
#[test]
fn scan_multiline_body_does_not_panic() {
let target = dummy_target();
let body = "\n".repeat(100_000);
let findings = scan("https://example.com/app.js", &body, &target);
assert!(findings.is_empty());
}
#[test]
fn get_raw_secret_does_not_consume() {
store_raw_secret("peek-hash", "super-secret");
assert_eq!(get_raw_secret("peek-hash").as_deref(), Some("super-secret"));
assert_eq!(get_raw_secret("peek-hash").as_deref(), Some("super-secret"));
clear_raw_secret("peek-hash");
assert_eq!(get_raw_secret("peek-hash"), None);
}
use proptest::prelude::*;
proptest! {
#[test]
fn scan_never_panics(body in "\\PC{0,4096}") {
let target = dummy_target();
let _ = scan("https://example.com/app.js", &body, &target);
}
#[test]
fn scan_never_panics_on_arbitrary_url(js_url in "\\PC{0,256}", body in "\\PC{0,4096}") {
let target = dummy_target();
let _ = scan(&js_url, &body, &target);
}
#[test]
fn store_and_take_raw_secret_roundtrips(secret in "\\PC{1,128}") {
let hash = format!("hash_{}", secret.len());
store_raw_secret(&hash, &secret);
let taken = take_raw_secret(&hash);
prop_assert_eq!(taken, Some(secret));
}
}
}