use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::finding::SastFinding;
const TARGET_ALL: &str = "security";
const SOURCE_PREFIX: &str = "linthis-";
pub fn filter_suppressed(findings: Vec<SastFinding>, scan_dir: &Path) -> Vec<SastFinding> {
let mut cache: HashMap<PathBuf, Option<Vec<String>>> = HashMap::new();
findings
.into_iter()
.filter(|finding| {
let lines = cache
.entry(finding.file_path.clone())
.or_insert_with(|| read_lines(&finding.file_path, scan_dir));
match lines {
Some(lines) => !is_suppressed(finding, lines),
None => true,
}
})
.collect()
}
fn read_lines(file_path: &Path, scan_dir: &Path) -> Option<Vec<String>> {
let content = std::fs::read_to_string(file_path)
.or_else(|_| std::fs::read_to_string(scan_dir.join(file_path)))
.ok()?;
Some(content.lines().map(|l| l.to_string()).collect())
}
fn is_suppressed(finding: &SastFinding, lines: &[String]) -> bool {
let Some(idx) = finding.line.checked_sub(1) else {
return false;
};
if let Some(line) = lines.get(idx) {
if let Some(target) = parse_ignore_directive(line) {
if target_matches(&target, finding) {
return true;
}
}
}
if let Some(prev_idx) = idx.checked_sub(1) {
if let Some(prev) = lines.get(prev_idx) {
if let Some(target) = parse_ignore_next_line_directive(prev) {
if target_matches(&target, finding) {
return true;
}
}
}
}
false
}
fn target_matches(target: &str, finding: &SastFinding) -> bool {
if target == TARGET_ALL {
return true;
}
let tool = finding
.source
.strip_prefix(SOURCE_PREFIX)
.unwrap_or(&finding.source);
target == tool || target == finding.source || target == finding.rule_id
}
pub fn parse_ignore_directive(line: &str) -> Option<String> {
let marker = "linthis:ignore";
let pos = line.find(marker)?;
let after_marker = &line[pos + marker.len()..];
if after_marker.starts_with("-next-line") {
return None;
}
extract_ignore_target(after_marker)
}
pub fn parse_ignore_next_line_directive(line: &str) -> Option<String> {
let marker = "linthis:ignore-next-line";
let pos = line.find(marker)?;
let after_marker = &line[pos + marker.len()..];
extract_ignore_target(after_marker)
}
fn extract_ignore_target(after_marker: &str) -> Option<String> {
let trimmed = after_marker.trim();
if trimmed.is_empty() {
return None;
}
let target = trimmed
.split_whitespace()
.next()?
.trim_end_matches("*/")
.trim();
if target.is_empty() {
return None;
}
Some(target.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::vulnerability::Severity;
fn finding(source: &str, rule_id: &str, line: usize) -> SastFinding {
SastFinding {
rule_id: rule_id.to_string(),
severity: Severity::High,
message: "test".to_string(),
file_path: PathBuf::from("test.py"),
line,
column: None,
end_line: None,
end_column: None,
code_snippet: None,
fix_suggestion: None,
category: "test".to_string(),
cwe_ids: Vec::new(),
source: source.to_string(),
language: "python".to_string(),
}
}
#[test]
fn parses_same_line_target() {
assert_eq!(
parse_ignore_directive("x = 1 # linthis:ignore security"),
Some("security".to_string())
);
assert_eq!(
parse_ignore_directive("x = 1 // linthis:ignore opengrep"),
Some("opengrep".to_string())
);
assert_eq!(parse_ignore_directive("x = 1 # no directive"), None);
}
#[test]
fn next_line_marker_is_not_a_same_line_directive() {
assert_eq!(
parse_ignore_directive("# linthis:ignore-next-line security"),
None
);
assert_eq!(
parse_ignore_next_line_directive("# linthis:ignore-next-line security"),
Some("security".to_string())
);
}
#[test]
fn extracts_target_from_block_comment() {
assert_eq!(
parse_ignore_directive("x = 1 /* linthis:ignore bandit */"),
Some("bandit".to_string())
);
}
#[test]
fn security_target_matches_any_tool() {
assert!(target_matches(
"security",
&finding("opengrep", "python.audit.exec", 1)
));
assert!(target_matches(
"security",
&finding("linthis-secrets", "secrets/aws-access-key", 1)
));
}
#[test]
fn tool_target_matches_by_short_and_full_source() {
assert!(target_matches(
"secrets",
&finding("linthis-secrets", "secrets/aws-access-key", 1)
));
assert!(target_matches(
"linthis-secrets",
&finding("linthis-secrets", "secrets/aws-access-key", 1)
));
assert!(target_matches(
"opengrep",
&finding("opengrep", "python.audit.exec", 1)
));
assert!(!target_matches(
"bandit",
&finding("opengrep", "python.audit.exec", 1)
));
}
#[test]
fn rule_id_target_matches_exact_rule_only() {
let f = finding("opengrep", "python.audit.exec", 1);
assert!(target_matches("python.audit.exec", &f));
assert!(!target_matches("python.audit.other", &f));
}
#[test]
fn suppresses_on_same_line() {
let lines: Vec<String> = vec!["exec(cmd) # linthis:ignore opengrep".to_string()];
let f = finding("opengrep", "python.audit.exec", 1);
assert!(is_suppressed(&f, &lines));
}
#[test]
fn suppresses_on_next_line() {
let lines: Vec<String> = vec![
"# linthis:ignore-next-line security".to_string(),
"exec(cmd)".to_string(),
];
let f = finding("opengrep", "python.audit.exec", 2);
assert!(is_suppressed(&f, &lines));
}
#[test]
fn does_not_suppress_unrelated_tool() {
let lines: Vec<String> = vec!["exec(cmd) # linthis:ignore bandit".to_string()];
let f = finding("opengrep", "python.audit.exec", 1);
assert!(!is_suppressed(&f, &lines));
}
#[test]
fn does_not_suppress_without_directive() {
let lines: Vec<String> = vec!["exec(cmd)".to_string()];
let f = finding("opengrep", "python.audit.exec", 1);
assert!(!is_suppressed(&f, &lines));
}
}