use gossan_core::Target;
use secfinding::{Finding, FindingKind, Severity};
use crate::correlation::scope;
const SOURCE_SIGNALS: &[&str] = &[
".git",
".env",
"source map",
"sourcemap",
"swagger",
"openapi",
"directory listing",
"backup file",
"debug",
"profiler",
"phpinfo",
"actuator",
];
const SECRET_SIGNALS: &[&str] = &[
"secret",
"api key",
"access key",
"private key",
"token",
"credential",
"password",
"aws",
"stripe",
"github pat",
"jwt",
];
pub struct SourceCodeSecretsRule;
impl super::super::CorrelationRule for SourceCodeSecretsRule {
fn name(&self) -> &'static str {
"source_code_secrets"
}
fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
let is_source = |f: &Finding| {
let lower = f.title().to_lowercase();
SOURCE_SIGNALS.iter().any(|sig| lower.contains(sig))
};
let is_secret = |f: &Finding| {
let lower = f.title().to_lowercase();
SECRET_SIGNALS.iter().any(|sig| lower.contains(sig))
};
let mut chains = Vec::new();
for (_host, group) in scope::group_by(findings, scope::host_scope) {
if !scope::has_distinct_pair(&group, is_source, is_secret) {
continue;
}
let source_exposures: Vec<&Finding> =
group.iter().copied().filter(|&f| is_source(f)).collect();
let secret_findings: Vec<&Finding> =
group.iter().copied().filter(|&f| is_secret(f)).collect();
let source_types: Vec<String> = source_exposures
.iter()
.map(|f| f.title().to_string())
.take(3)
.collect();
let secret_types: Vec<String> = secret_findings
.iter()
.map(|f| f.title().to_string())
.take(3)
.collect();
let chain = Finding::builder(
"correlation",
source_exposures[0].target(),
Severity::Critical,
)
.title("Source Code Exposure → Credential Extraction Chain")
.detail(format!(
"Source code is exposed ({}) and contains hardcoded secrets ({}) on the same target. \
An attacker can follow this chain: discover exposed source → \
extract credentials → authenticate as the application. \
This is a direct path to compromise. \
Fix: remove source code from production AND rotate all exposed credentials.",
source_types.join(", "),
secret_types.join(", "),
))
.kind(FindingKind::Vulnerability)
.tag("chain")
.tag("source-exposure")
.tag("credential-leak")
.build_or_log();
chains.extend(chain);
}
chains
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::correlation::CorrelationRule;
fn finding(scanner: &str, target: &str, title: &str) -> Finding {
Finding::builder(scanner, target, Severity::High)
.title(title)
.build()
.expect("test finding")
}
#[test]
fn fires_when_source_and_secrets_present() {
let rule = SourceCodeSecretsRule;
let findings = vec![
finding("hidden", "example.com", ".git/config exposed"),
finding("js", "example.com", "AWS Access Key in JavaScript"),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert!(chains[0].title().contains("Source Code Exposure"));
}
#[test]
fn does_not_fire_with_only_source_exposure() {
let rule = SourceCodeSecretsRule;
let findings = vec![finding("hidden", "example.com", ".git/config exposed")];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn does_not_fire_with_only_secrets() {
let rule = SourceCodeSecretsRule;
let findings = vec![finding("js", "example.com", "AWS Access Key in JavaScript")];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn does_not_chain_across_unrelated_hosts() {
let rule = SourceCodeSecretsRule;
let findings = vec![
finding("hidden", "example.com", ".git/config exposed"),
finding("js", "unrelated-target.com", "AWS Access Key in JavaScript"),
];
let chains = rule.check(&findings, &[]);
assert!(
chains.is_empty(),
"cross-host chain emitted: {:?}",
chains.iter().map(Finding::title).collect::<Vec<_>>()
);
}
#[test]
fn single_finding_matching_both_vocabularies_does_not_self_chain() {
let rule = SourceCodeSecretsRule;
for title in [
"Spring Boot Actuator /actuator/env exposes credentials",
"Source map exposes API key",
"Debug endpoint leaks JWT token",
"phpinfo() page reveals database password",
] {
let findings = vec![finding("hidden", "example.com", title)];
let chains = rule.check(&findings, &[]);
assert!(
chains.is_empty(),
"single finding {title:?} self-chained into {:?}",
chains.iter().map(Finding::title).collect::<Vec<_>>()
);
}
}
#[test]
fn dual_vocab_finding_plus_distinct_partner_still_chains() {
let rule = SourceCodeSecretsRule;
let findings = vec![
finding("hidden", "example.com", "Debug endpoint leaks JWT token"),
finding("hidden", "example.com", ".git/config exposed"),
];
let chains = rule.check(&findings, &[]);
assert_eq!(
chains.len(),
1,
"two distinct findings (one dual-vocab) must still chain"
);
assert_eq!(chains[0].target(), "example.com");
}
#[test]
fn chains_when_source_and_secret_on_same_host() {
let rule = SourceCodeSecretsRule;
let findings = vec![
finding("hidden", "example.com", ".git/config exposed"),
finding("js", "example.com", "AWS Access Key in JavaScript"),
finding("hidden", "other-host.com", ".git/config exposed"), ];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].target(), "example.com");
}
}