| """ |
| Deduplication Engine for Shield Agents. |
| |
| When VulnAgent finds "SQL Injection" and SAST also finds "SQL Injection", |
| this engine merges them into one finding with multiple sources. This prevents |
| duplicate findings from cluttering reports and inflating risk scores. |
| """ |
|
|
| import logging |
| import re |
| from difflib import SequenceMatcher |
| from typing import Any, Dict, List, Optional, Set, Tuple |
|
|
| logger = logging.getLogger("shield_agents.deduplication") |
|
|
|
|
| class FindingDeduplicator: |
| """Deduplicate findings across multiple agents and scanners. |
| |
| Uses multiple strategies: |
| 1. Exact match: Same file, line, and category |
| 2. Fuzzy match: Similar title/description within same file |
| 3. Category merge: Same vulnerability type at nearby lines |
| """ |
|
|
| def __init__(self, similarity_threshold: float = 0.85, merge_sources: bool = True): |
| """Initialize the deduplicator. |
| |
| Args: |
| similarity_threshold: Threshold for fuzzy matching (0.0-1.0). |
| merge_sources: Whether to merge sources into a single finding. |
| """ |
| self.similarity_threshold = similarity_threshold |
| self.merge_sources = merge_sources |
| self.dedup_stats = { |
| "total_input": 0, |
| "exact_duplicates": 0, |
| "fuzzy_duplicates": 0, |
| "category_merges": 0, |
| "final_count": 0, |
| } |
|
|
| def deduplicate(self, findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| """Deduplicate a list of findings. |
| |
| Args: |
| findings: List of finding dictionaries from multiple sources. |
| |
| Returns: |
| Deduplicated list of findings. |
| """ |
| if not findings: |
| return [] |
|
|
| self.dedup_stats["total_input"] = len(findings) |
| result = [] |
| seen_keys: Set[str] = set() |
| merged_findings: Dict[str, Dict[str, Any]] = {} |
|
|
| |
| for finding in findings: |
| key = self._exact_key(finding) |
| if key in seen_keys: |
| self.dedup_stats["exact_duplicates"] += 1 |
| |
| if self.merge_sources and key in merged_findings: |
| self._merge_into(merged_findings[key], finding) |
| continue |
|
|
| seen_keys.add(key) |
| merged_findings[key] = {**finding} |
| if "sources" not in merged_findings[key]: |
| merged_findings[key]["sources"] = [finding.get("source", finding.get("agent", "unknown"))] |
|
|
| |
| keys_list = list(merged_findings.keys()) |
| to_remove: Set[str] = set() |
|
|
| for i, key1 in enumerate(keys_list): |
| if key1 in to_remove: |
| continue |
| for j in range(i + 1, len(keys_list)): |
| key2 = keys_list[j] |
| if key2 in to_remove: |
| continue |
|
|
| f1 = merged_findings[key1] |
| f2 = merged_findings[key2] |
|
|
| if self._is_fuzzy_duplicate(f1, f2): |
| self.dedup_stats["fuzzy_duplicates"] += 1 |
| self._merge_into(f1, f2) |
| to_remove.add(key2) |
|
|
| |
| for i, key1 in enumerate(keys_list): |
| if key1 in to_remove: |
| continue |
| for j in range(i + 1, len(keys_list)): |
| key2 = keys_list[j] |
| if key2 in to_remove: |
| continue |
|
|
| f1 = merged_findings[key1] |
| f2 = merged_findings[key2] |
|
|
| if self._is_category_duplicate(f1, f2): |
| self.dedup_stats["category_merges"] += 1 |
| self._merge_into(f1, f2) |
| to_remove.add(key2) |
|
|
| |
| for key in keys_list: |
| if key not in to_remove: |
| result.append(merged_findings[key]) |
|
|
| self.dedup_stats["final_count"] = len(result) |
| logger.info( |
| f"Deduplication: {len(findings)} → {len(result)} findings " |
| f"(removed {len(findings) - len(result)} duplicates)" |
| ) |
| return result |
|
|
| def _exact_key(self, finding: Dict[str, Any]) -> str: |
| """Generate an exact deduplication key. |
| |
| Args: |
| finding: Finding dictionary. |
| |
| Returns: |
| String key for exact matching. |
| """ |
| file_path = finding.get("file", "") |
| line = finding.get("line", 0) |
| category = finding.get("category", "").lower() |
| title = finding.get("title", "").lower().strip() |
|
|
| |
| title = re.sub(r'[^a-z0-9]', '', title) |
|
|
| return f"{file_path}:{line}:{category}:{title}" |
|
|
| def _is_fuzzy_duplicate(self, f1: Dict[str, Any], f2: Dict[str, Any]) -> bool: |
| """Check if two findings are fuzzy duplicates. |
| |
| Two findings are fuzzy duplicates if: |
| - They are in the same file |
| - Their titles are similar (above threshold) |
| - Their line numbers are within 5 lines of each other |
| |
| Args: |
| f1: First finding. |
| f2: Second finding. |
| |
| Returns: |
| True if they are likely duplicates. |
| """ |
| |
| if f1.get("file", "") != f2.get("file", ""): |
| return False |
|
|
| |
| line1 = f1.get("line", 0) |
| line2 = f2.get("line", 0) |
| if isinstance(line1, int) and isinstance(line2, int): |
| if abs(line1 - line2) > 5: |
| return False |
|
|
| |
| title1 = f1.get("title", "").lower() |
| title2 = f2.get("title", "").lower() |
| similarity = SequenceMatcher(None, title1, title2).ratio() |
|
|
| if similarity >= self.similarity_threshold: |
| return True |
|
|
| |
| if (f1.get("category", "").lower() == f2.get("category", "").lower() and |
| similarity >= self.similarity_threshold * 0.8): |
| return True |
|
|
| return False |
|
|
| def _is_category_duplicate(self, f1: Dict[str, Any], f2: Dict[str, Any]) -> bool: |
| """Check if two findings are category-level duplicates. |
| |
| Same vulnerability type at nearby lines in the same file. |
| |
| Args: |
| f1: First finding. |
| f2: Second finding. |
| |
| Returns: |
| True if they are category-level duplicates. |
| """ |
| |
| if (f1.get("file", "") != f2.get("file", "") or |
| f1.get("category", "").lower() != f2.get("category", "").lower()): |
| return False |
|
|
| |
| line1 = f1.get("line", 0) |
| line2 = f2.get("line", 0) |
| if isinstance(line1, int) and isinstance(line2, int): |
| if abs(line1 - line2) <= 3: |
| return True |
|
|
| return False |
|
|
| def _merge_into(self, target: Dict[str, Any], source: Dict[str, Any]) -> None: |
| """Merge source finding into target finding. |
| |
| Keeps the higher severity, combines sources, and adds alternate references. |
| |
| Args: |
| target: Target finding (modified in place). |
| source: Source finding to merge from. |
| """ |
| |
| if "sources" not in target: |
| target["sources"] = [target.get("source", target.get("agent", "unknown"))] |
|
|
| source_name = source.get("source", source.get("agent", "unknown")) |
| if source_name not in target["sources"]: |
| target["sources"].append(source_name) |
|
|
| |
| severity_order = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0} |
| target_sev = severity_order.get(target.get("severity", "MEDIUM").upper(), 2) |
| source_sev = severity_order.get(source.get("severity", "MEDIUM").upper(), 2) |
|
|
| if source_sev > target_sev: |
| target["severity"] = source.get("severity", "MEDIUM") |
|
|
| |
| target_conf = target.get("confidence", 0.8) |
| source_conf = source.get("confidence", 0.8) |
| target["confidence"] = max(target_conf, source_conf) |
|
|
| |
| if source.get("description", "") != target.get("description", ""): |
| target.setdefault("alternate_descriptions", []) |
| target["alternate_descriptions"].append(source.get("description", "")) |
|
|
| |
| target["deduplicated"] = True |
|
|
| def get_stats(self) -> Dict[str, int]: |
| """Get deduplication statistics. |
| |
| Returns: |
| Dictionary of deduplication statistics. |
| """ |
| return self.dedup_stats.copy() |
|
|