ploit_malper/engine/
dedup.rs1use std::collections::HashMap;
2
3use regex::Regex;
4use sha2::{Digest, Sha256};
5
6use crate::analyze::extract_endpoint;
7use crate::models::{DedupResult, ScanRecord};
8
9pub fn normalize_title(title: &str) -> String {
20 let mut text = title.trim().to_lowercase();
21
22 text = Regex::new(r"^\s*(get|post|put|head|delete|patch|options|trace|connect)\s+")
24 .expect("valid method regex")
25 .replace(&text, "")
26 .to_string();
27
28 text = text.trim_start_matches('/').trim_start().to_string();
30 text = text.trim_start_matches(':').trim_start().to_string();
31
32 text =
36 Regex::new(r"\s*(?:(?:see\s*:?\s*)?https?://\S+|\bsee\s*:?\s*cve-\d{4}-\d{3,7})\s*:?\s*$")
37 .expect("valid reference regex")
38 .replace(&text, "")
39 .to_string();
40
41 text = text.split_whitespace().collect::<Vec<_>>().join(" ");
43
44 text = text
46 .trim_end_matches(|c: char| ".,:;\\/".contains(c))
47 .trim()
48 .to_string();
49
50 text
51}
52
53fn composite_key(record: &ScanRecord) -> String {
54 let target = normalize_target(&record.target);
55 let normalized = normalize_title(&record.title);
56 let raw = format!("{}|{}", target, normalized);
57 let mut hasher = Sha256::new();
58 hasher.update(raw.as_bytes());
59 format!("{:x}", hasher.finalize())
60}
61
62fn normalize_target(target: &str) -> String {
64 target.trim().trim_end_matches('/').to_lowercase()
65}
66
67pub fn deduplicate_records(records: Vec<ScanRecord>) -> DedupResult {
68 let total_count = records.len();
69 let mut seen: HashMap<String, usize> = HashMap::new();
70 let mut unique_records = Vec::new();
71
72 for mut record in records {
73 let key = composite_key(&record);
74 record.normalized_title = normalize_title(&record.title);
75 record.merged_count = 1;
76 record.raw_findings = vec![record.title.clone()];
77
78 if let Some(&existing_idx) = seen.get(&key) {
79 merge_record(&mut unique_records[existing_idx], &record);
80 } else {
81 if record.affected_endpoints.is_empty() {
82 record.affected_endpoints = extract_endpoint(&record).into_iter().collect();
83 }
84 seen.insert(key, unique_records.len());
85 unique_records.push(record);
86 }
87 }
88
89 let unique_count = unique_records.len();
90 let removed_count = total_count.saturating_sub(unique_count);
91
92 DedupResult {
93 unique_count,
94 removed_count,
95 records: unique_records,
96 }
97}
98
99fn merge_record(kept: &mut ScanRecord, duplicate: &ScanRecord) {
102 kept.merged_count += 1;
103
104 if let Some(endpoint) = extract_endpoint(duplicate) {
105 if !kept.affected_endpoints.contains(&endpoint) {
106 kept.affected_endpoints.push(endpoint);
107 }
108 }
109 for cve in &duplicate.cves {
110 if !kept.cves.contains(cve) {
111 kept.cves.push(cve.clone());
112 }
113 }
114 if kept.cve.is_none() {
115 kept.cve = duplicate.cve.clone();
116 }
117 if kept.service.is_none() {
118 kept.service = duplicate.service.clone();
119 }
120 if kept.port.is_none() {
121 kept.port = duplicate.port;
122 }
123 if kept.detail.is_empty() {
124 kept.detail = duplicate.detail.clone();
125 }
126 if kept.reference.is_empty() {
127 kept.reference = duplicate.reference.clone();
128 }
129 for raw in &duplicate.raw_findings {
130 if !kept.raw_findings.contains(raw) {
131 kept.raw_findings.push(raw.clone());
132 }
133 }
134
135 if crate::analyze::severity_rank(&duplicate.severity)
137 < crate::analyze::severity_rank(&kept.severity)
138 {
139 kept.severity = duplicate.severity.clone();
140 }
141}
142
143pub fn normalize_title_text(title: &str) -> String {
144 normalize_title(title)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::models::ScanRecord;
151 use serde_json::json;
152
153 fn record(title: &str, target: &str) -> ScanRecord {
154 ScanRecord {
155 target: target.into(),
156 tool: "nikto".into(),
157 title: title.into(),
158 severity: "low".into(),
159 raw: json!({}),
160 ..Default::default()
161 }
162 }
163
164 #[test]
165 fn strips_http_method_and_endpoint_prefix() {
166 assert_eq!(
167 normalize_title("/: Suggested security header missing: content-security-policy"),
168 normalize_title("GET /: Suggested security header missing: content-security-policy")
169 );
170 }
171
172 #[test]
173 fn removes_trailing_punctuation_and_truncated_urls() {
174 let with_url = normalize_title(
175 "GET /: Suggested security header missing: content-security-policy. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP:",
176 );
177 let truncated_url = normalize_title(
178 "/: Suggested security header missing: content-security-policy. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP",
179 );
180 assert_eq!(with_url, truncated_url);
181 assert!(!with_url.contains("See:"));
182 assert!(!with_url.ends_with(':'));
183 }
184
185 #[test]
186 fn merges_etag_findings_with_truncated_see_references() {
187 let with_url = normalize_title(
188 "/: Server may leak inodes via ETags, header found with file /, inode: 917, size: 524, mtime: Wed Nov 11 20:09:58 2020. See: http://cve.mitre",
189 );
190 let with_cve = normalize_title(
191 "GET /: Server may leak inodes via ETags, header found with file /, inode: 917, size: 524, mtime: Wed Nov 11 20:09:58 2020. See: CVE-2003-141",
192 );
193 assert_eq!(with_url, with_cve);
194 assert!(!with_url.contains("See:"));
195 assert!(!with_cve.contains("CVE-2003"));
196 }
197
198 #[test]
199 fn preserves_bare_trailing_cve_id() {
200 assert_eq!(
201 normalize_title("Exposed service CVE-2017-0144"),
202 "exposed service cve-2017-0144"
203 );
204 }
205
206 #[test]
207 fn case_insensitive_and_whitespace_collapsed() {
208 assert_eq!(
209 normalize_title(" APACHE version "),
210 normalize_title("apache version")
211 );
212 }
213
214 #[test]
215 fn deduplicates_duplicated_nikto_csp_findings() {
216 let records = vec![
217 record(
218 "/: Suggested security header missing: content-security-policy. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP",
219 "http://192.168.1.2/",
220 ),
221 record(
222 "GET /: Suggested security header missing: content-security-policy. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP:",
223 "http://192.168.1.2/",
224 ),
225 ];
226 let result = deduplicate_records(records);
227 assert_eq!(result.unique_count, 1);
228 assert_eq!(result.removed_count, 1);
229 assert_eq!(result.records[0].merged_count, 2);
230 assert_eq!(result.records[0].raw_findings.len(), 2);
231 }
232
233 #[test]
234 fn deduplicates_on_normalized_title() {
235 let records = vec![
236 ScanRecord {
237 target: "10.0.0.1".into(),
238 tool: "nmap".into(),
239 title: "Apache Version /".into(),
240 severity: "high".into(),
241 port: Some(80),
242 cve: None,
243 service: Some("apache".into()),
244 raw: json!({}),
245 ..Default::default()
246 },
247 ScanRecord {
248 target: "10.0.0.1".into(),
249 tool: "nmap".into(),
250 title: "apache version".into(),
251 severity: "high".into(),
252 port: Some(80),
253 cve: None,
254 service: Some("apache".into()),
255 raw: json!({}),
256 ..Default::default()
257 },
258 ];
259
260 let result = deduplicate_records(records);
261 assert_eq!(result.unique_count, 1);
262 assert_eq!(result.removed_count, 1);
263 }
264
265 #[test]
266 fn distinct_targets_stay_separate() {
267 let records = vec![
268 record("Alive (200)", "http://192.168.1.2/"),
269 record("Alive (200)", "http://192.168.1.2/index.html"),
270 ];
271 let result = deduplicate_records(records);
272 assert_eq!(result.unique_count, 2);
273 }
274
275 #[test]
276 fn keeps_raw_titles_for_traceability() {
277 let records = vec![
278 record("Apache version", "10.0.0.1"),
279 record("APACHE VERSION", "10.0.0.1"),
280 ];
281 let result = deduplicate_records(records);
282 assert_eq!(
283 result.records[0].raw_findings,
284 vec!["Apache version", "APACHE VERSION"]
285 );
286 assert_eq!(result.records[0].merged_count, 2);
287 }
288}