Cybersecurity-Projects/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md

958 lines
37 KiB
Markdown

# 03-IMPLEMENTATION.md
# Implementation Guide
This document walks through how the code works. We cover the detection engine, file extraction, network analysis, and CLI integration, with code snippets from the actual project.
## File Structure
```
src/dlp_scanner/
├── __init__.py
├── cli.py # Typer entry point
├── engine.py # Scan orchestration
├── config.py # Pydantic config models
├── constants.py # Thresholds, types, defaults
├── models.py # Finding, Location, ScanResult
├── compliance.py # Rule-to-framework mapping
├── redaction.py # Snippet masking
├── log.py # structlog configuration
├── scoring.py # Shared match-to-finding conversion
├── commands/
│ ├── scan.py # file, db, network commands
│ └── report.py # convert, summary commands
├── detectors/
│ ├── base.py # DetectionRule, DetectorMatch
│ ├── pattern.py # Regex + checksum detection
│ ├── context.py # Keyword proximity scoring
│ ├── entropy.py # Shannon entropy detection
│ ├── registry.py # Central detector registry
│ └── rules/
│ ├── pii.py # SSN, email, phone, passport
│ ├── financial.py # Credit cards, IBAN, NHS
│ ├── credentials.py # AWS, GitHub, JWT, Stripe
│ └── health.py # Medical records, DEA, NPI
├── extractors/
│ ├── base.py # Extractor protocol
│ ├── plaintext.py # .txt, .log, .cfg, source code
│ ├── pdf.py # .pdf via PyMuPDF
│ ├── office.py # .docx, .xlsx, .xls
│ ├── structured.py # .csv, .json, .xml, .yaml, .parquet, .avro
│ ├── archive.py # .zip, .tar.gz, .tar.bz2
│ └── email.py # .eml, .msg
├── network/
│ ├── pcap.py # PCAP/PCAPNG packet reader
│ ├── flow_tracker.py # TCP flow reassembly
│ ├── protocols.py # DPI protocol identification
│ └── exfiltration.py # DNS exfil detection
├── reporters/
│ ├── base.py # Reporter protocol
│ ├── console.py # Rich terminal output
│ ├── json_report.py # Structured JSON
│ ├── sarif.py # SARIF 2.1.0
│ └── csv_report.py # Flat CSV
└── scanners/
├── base.py # Scanner protocol
├── file_scanner.py # Directory walking + extraction
├── db_scanner.py # DB schema introspection
└── network_scanner.py # PCAP payload scanning
```
## Building the Detection Engine
### Detection Rules
Every detection rule is a data structure, not a class hierarchy. The `DetectionRule` dataclass holds the regex pattern, base confidence score, optional validator function, context keywords, and compliance framework tags:
```python
@dataclass(frozen=True, slots=True)
class DetectionRule:
rule_id: str
rule_name: str
pattern: re.Pattern[str]
base_score: float
context_keywords: list[str] = field(default_factory=list)
validator: Callable[[str], bool] | None = None
compliance_frameworks: list[str] = field(
default_factory=list
)
```
Rule modules export plain lists of these structs. Here is the SSN rule from `detectors/rules/pii.py`:
```python
SSN_PATTERN = re.compile(
r"\b(?!000|666|9\d{2})\d{3}"
r"[-\s]?"
r"(?!00)\d{2}"
r"[-\s]?"
r"(?!0000)\d{4}\b"
)
PII_RULES: list[DetectionRule] = [
DetectionRule(
rule_id="PII_SSN",
rule_name="US Social Security Number",
pattern=SSN_PATTERN,
base_score=0.45,
context_keywords=SSN_CONTEXT,
validator=_validate_ssn,
compliance_frameworks=[
"HIPAA", "CCPA", "GLBA", "GDPR",
],
),
...
]
```
The regex uses negative lookaheads (`(?!000|666|9\d{2})`) to reject SSN area numbers the Social Security Administration has never assigned. This is a first-pass structural filter. The real validation happens in `_validate_ssn`, which the `PatternDetector` calls for every regex match.
**Why base_score is 0.45, not higher:** A 9-digit number matching the SSN format appears in serial numbers, zip+4 codes, phone fragments, and test data constantly. The string `456-78-9012` matches the SSN pattern and passes area/group/serial validation, but without context it could be anything. A base of 0.45 keeps it in the "medium" severity tier until context boosts push it higher.
### Checksum Validation
The three checksum validators demonstrate different mathematical approaches to the same problem: distinguishing real identifiers from random digit sequences.
**Luhn algorithm** for credit cards (in `detectors/rules/financial.py`):
```python
def luhn_check(number: str) -> bool:
digits = [int(d) for d in number if d.isdigit()]
if len(digits) < 13:
return False
odd_digits = digits[-1::-2]
even_digits = digits[-2::-2]
total = sum(odd_digits)
for d in even_digits:
total += sum(divmod(d * 2, 10))
return total % 10 == 0
```
The algorithm works right-to-left: take every other digit starting from the rightmost, sum them. For the remaining digits, double each, and if the result exceeds 9, subtract 9 (which is what `sum(divmod(d * 2, 10))` does). If the grand total is divisible by 10, the number is valid. A random 16-digit number has about a 10% chance of passing Luhn, so it reduces false positives by roughly 90%.
**Mod-97** for IBANs (ISO 7064):
```python
def iban_check(value: str) -> bool:
cleaned = value.replace(" ", "").upper()
if len(cleaned) < 15 or len(cleaned) > 34:
return False
rearranged = cleaned[4:] + cleaned[:4]
numeric = ""
for char in rearranged:
if char.isalpha():
numeric += str(ord(char) - ord("A") + 10)
else:
numeric += char
return int(numeric) % 97 == 1
```
Move the country code and check digits (first 4 chars) to the end, convert letters to two-digit numbers (A=10, B=11, etc.), then check that the entire number mod 97 equals 1. The false positive rate is approximately 1 in 97.
**Mod-11** for NHS numbers:
```python
def nhs_check(value: str) -> bool:
digits = value.replace("-", "").replace(" ", "")
if len(digits) != 10 or not digits.isdigit():
return False
weights = range(10, 1, -1)
total = sum(
int(d) * w
for d, w in zip(digits[:9], weights, strict=False)
)
remainder = 11 - (total % 11)
if remainder == 11:
remainder = 0
if remainder == 10:
return False
return remainder == int(digits[9])
```
Multiply the first 9 digits by descending weights (10, 9, 8, ..., 2), sum them, compute `11 - (sum mod 11)`, and compare to the check digit. If the result is 10, the number is invalid (NHS never issues these). If the result is 11, the check digit is 0.
**Luhn-80840** for NPIs (in `detectors/rules/health.py`):
```python
def _validate_npi(value: str) -> bool:
digits = value.replace("-", "").replace(" ", "")
if len(digits) != 10 or not digits.isdigit():
return False
prefixed = "80840" + digits
total = 0
for i, d in enumerate(reversed(prefixed)):
n = int(d)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
```
NPI (National Provider Identifier) validation is a Luhn variant. The trick is prepending `80840` (the healthcare industry prefix assigned by ANSI) before running the standard Luhn algorithm. This prefix is not part of the NPI itself, but the ISO standard requires it for check digit computation. A random 10-digit number has about a 10% chance of passing, making this check useful but not definitive. The base score of 0.10 reflects that NPI patterns match many unrelated 10-digit numbers, and context keywords like "provider" or "npi" are needed to push the score into actionable territory.
### Pattern Detection
The `PatternDetector` in `detectors/pattern.py` iterates over all active rules, runs each regex against the input text, filters through the allowlist, and applies checksum validation:
```python
class PatternDetector:
def detect(self, text: str) -> list[DetectorMatch]:
matches: list[DetectorMatch] = []
for rule in self._rules:
for m in rule.pattern.finditer(text):
matched_text = m.group()
if self._is_allowlisted(matched_text):
continue
score = rule.base_score
if rule.validator is not None:
if rule.validator(matched_text):
score = min(1.0, score + CHECKSUM_BOOST)
else:
continue
matches.append(
DetectorMatch(
rule_id=rule.rule_id,
...
score=score,
)
)
return matches
```
When a rule has a validator and the match fails validation, the match is discarded entirely (`continue`). A Visa pattern that matches `4532015112830366` but fails Luhn is not a credit card. When validation passes, the score gets a +0.30 boost (`CHECKSUM_BOOST`). This is aggressive because checksum-passing matches are overwhelmingly real: the Luhn+Visa prefix combination has a false positive rate under 1%.
The allowlist uses a frozen set lookup, defaulting to `KNOWN_TEST_VALUES` (common test card numbers, example SSNs like `123-45-6789`). This prevents DLP tools from flagging their own test data, which is a common complaint in production deployments.
### Context Keyword Scoring
After pattern detection, `apply_context_boost` in `detectors/context.py` scans the surrounding text for keywords that indicate the matched value is actually sensitive data:
```python
def apply_context_boost(
text: str,
matches: list[DetectorMatch],
window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS,
) -> list[DetectorMatch]:
tokens = text.lower().split()
boosted: list[DetectorMatch] = []
for match in matches:
if not match.context_keywords:
boosted.append(match)
continue
char_to_token = _char_offset_to_token_index(
text, match.start
)
window_start = max(
0, char_to_token - window_tokens
)
window_end = min(
len(tokens), char_to_token + window_tokens
)
window_text = " ".join(
tokens[window_start:window_end]
)
boost = _compute_keyword_boost(
window_text,
match.context_keywords,
window_tokens,
)
new_score = min(1.0, match.score + boost)
...
```
The window is bidirectional: 10 tokens in each direction from the match. The boost is distance-weighted: a keyword right next to the match contributes up to `CONTEXT_BOOST_MAX` (0.35), while one at the edge of the window contributes almost nothing. This reflects a real observation: "SSN: 456-78-9012" is almost certainly an SSN, while "SSN" appearing 50 words away from "456-78-9012" is weaker signal.
The `_compute_keyword_boost` function finds the best keyword match in the window and computes `CONTEXT_BOOST_MAX * proximity_factor`, where proximity is `1.0 - (distance / max_distance)`. Only the highest-scoring keyword matters, not the sum of all keywords. This prevents keyword stuffing from inflating scores.
### Co-occurrence Boost
After context boosting, `_apply_cooccurrence_boost` checks whether multiple different PII types appear near each other:
```python
def _apply_cooccurrence_boost(
matches: list[DetectorMatch],
) -> list[DetectorMatch]:
if len(matches) < 2:
return matches
proximity_threshold = 500
for i, match in enumerate(matches):
has_neighbor = False
for j, other in enumerate(matches):
if i == j:
continue
if other.rule_id == match.rule_id:
continue
distance = abs(match.start - other.start)
if distance < proximity_threshold:
has_neighbor = True
break
if has_neighbor:
new_score = min(
1.0, match.score + COOCCURRENCE_BOOST
)
...
```
An SSN near a credit card number is stronger evidence than either alone. The boost is +0.15 (`COOCCURRENCE_BOOST`), and it requires different `rule_id` values (two SSNs next to each other do not trigger it). The 500-character threshold roughly corresponds to a short paragraph or a few database columns.
This heuristic matters in practice. The Capital One breach data contained CSV exports where SSNs, credit card numbers, and addresses appeared in adjacent columns. Co-occurrence detection would have flagged these files as critical priority.
### Shannon Entropy Detection
The `EntropyDetector` in `detectors/entropy.py` finds high-entropy regions that may contain secrets, encrypted data, or base64-encoded credentials:
```python
def shannon_entropy(data: bytes) -> float:
if not data:
return 0.0
counts = Counter(data)
total = len(data)
return -sum(
(c / total) * math.log2(c / total)
for c in counts.values()
)
```
Shannon entropy measures the average information content per byte. English text sits around 3.5-4.5 bits. Base64-encoded data is 5.5-6.0. Truly random bytes approach 8.0 (log2(256)). The detector uses a sliding window of 256 bytes with a 128-byte step:
```python
def detect_high_entropy_regions(
data: bytes,
threshold: float = DEFAULT_ENTROPY_THRESHOLD,
window_size: int = WINDOW_SIZE,
step: int = WINDOW_STEP,
) -> list[tuple[int, int, float]]:
...
while i + window_size <= len(data):
window = data[i:i + window_size]
h = shannon_entropy(window)
if h >= threshold:
end = i + window_size
while end + step <= len(data):
next_window = data[
end - window_size + step:end + step
]
next_h = shannon_entropy(next_window)
if next_h < threshold:
break
h = max(h, next_h)
end += step
regions.append((i, end, h))
i = end
else:
i += step
```
When the entropy exceeds the threshold (default 7.2), the detector extends the region forward until entropy drops below the threshold. This merges adjacent high-entropy windows into a single region rather than reporting dozens of overlapping detections.
The default threshold of 7.2 is intentionally high. Network payloads containing binary protocol data or compressed content often hit 6.0-7.0, which would generate massive false positive volume. At 7.2, the detector primarily catches encrypted blobs, base64-encoded secrets, and random key material.
## File Extraction Pipeline
### The Extractor Protocol
All extractors implement a two-method protocol:
```python
class Extractor(Protocol):
def extract(self, path: str) -> list[TextChunk]: ...
@property
def supported_extensions(self) -> frozenset[str]: ...
```
The `FileScanner` builds an extension-to-extractor map at initialization by iterating over all extractor instances and indexing by their supported extensions. When scanning a file, it looks up the extractor by the file's extension and calls `extract`.
### Plaintext Extraction
The `PlaintextExtractor` reads files in 500-line chunks to keep memory bounded:
```python
class PlaintextExtractor:
def extract(self, path: str) -> list[TextChunk]:
chunks: list[TextChunk] = []
with open(
path, encoding="utf-8", errors="replace",
) as f:
lines: list[str] = []
line_number = 1
chunk_start = 1
for line in f:
lines.append(line)
if len(lines) >= CHUNK_MAX_LINES:
chunks.append(
TextChunk(
text="".join(lines),
location=Location(
source_type="file",
uri=path,
line=chunk_start,
),
)
)
chunk_start = line_number + 1
lines = []
line_number += 1
if lines:
chunks.append(...)
return chunks
```
Each `TextChunk` carries the starting line number in its `Location`, so findings can report where in the file the match occurred. The `errors="replace"` parameter means binary-contaminated text files (common in log files with embedded binary data) will not crash the extractor.
### Extension Map Construction
The `_build_extension_map` function in `file_scanner.py` constructs the mapping from extensions to extractors:
```python
def _build_extension_map() -> dict[str, Extractor]:
extractors: list[Extractor] = [
PlaintextExtractor(),
PDFExtractor(),
DocxExtractor(),
XlsxExtractor(),
XlsExtractor(),
CsvExtractor(),
JsonExtractor(),
XmlExtractor(),
YamlExtractor(),
ParquetExtractor(),
AvroExtractor(),
ArchiveExtractor(),
EmlExtractor(),
MsgExtractor(),
]
ext_map: dict[str, Extractor] = {}
for extractor in extractors:
for ext in extractor.supported_extensions:
ext_map[ext] = extractor
return ext_map
```
Adding a new format means creating an extractor class with `extract` and `supported_extensions`, then adding it to this list. The scanner does not need to know anything about the format.
### File Scanner Walk Logic
The `FileScanner._scan_directory` method applies a chain of filters before dispatching to an extractor:
```python
def _scan_directory(self, directory, result):
iterator = (
directory.rglob("*")
if self._file_config.recursive
else directory.glob("*")
)
for path in iterator:
if not path.is_file():
continue
if self._is_excluded(path, directory):
continue
suffix = _get_full_suffix(path)
if suffix not in self._allowed_extensions:
continue
file_size = path.stat().st_size
if file_size > max_bytes:
continue
if file_size == 0:
continue
self._scan_file(path, result)
result.targets_scanned += 1
```
The `_get_full_suffix` function handles compound extensions like `.tar.gz` and `.tar.bz2` by checking the filename suffix before falling back to `path.suffix.lower()`. The exclusion check matches against the relative path, the filename, and individual path components, so a pattern like `__pycache__` matches regardless of depth.
## Network Analysis
### Scanner Orchestration
The `NetworkScanner` ties together the network modules into a multi-pass pipeline. The old implementation decoded raw packets as UTF-8 and ran detection directly. The rewrite is protocol-aware:
```python
def _scan_pcap(self, path, result):
tracker = FlowTracker()
dns_detector = DnsExfilDetector(
entropy_threshold=(
self._net_config.dns_label_entropy_threshold
),
)
packet_count = 0
for packet in read_pcap(
path,
max_packets=self._net_config.max_packets,
):
packet_count += 1
tracker.add_packet(packet)
if (
packet.protocol == "udp"
and (
packet.src_port == DNS_PORT
or packet.dst_port == DNS_PORT
)
):
self._process_dns_packet(
packet.payload, packet.src_ip,
packet.dst_ip, path, packet_count,
dns_detector, result,
)
if packet.payload:
exfil_indicators = detect_base64_payload(
packet.payload,
src_ip=packet.src_ip,
dst_ip=packet.dst_ip,
)
for indicator in exfil_indicators:
finding = _indicator_to_finding(
indicator, str(path), packet_count,
)
result.findings.append(finding)
txt_indicators = dns_detector.check_txt_volume()
for indicator in txt_indicators:
...
self._scan_reassembled_flows(tracker, path, result)
```
Three things happen during the packet loop: every packet goes into the `FlowTracker` for later TCP reassembly, UDP packets on port 53 are parsed as DNS and fed to the `DnsExfilDetector`, and every payload is checked for base64/hex-encoded data by `detect_base64_payload`. After the loop, TXT query volume ratios are checked and TCP flows are reassembled for content scanning.
The reassembled flow scanning uses protocol-aware text extraction:
```python
def _extract_scannable_text(self, stream, protocol):
if protocol == "http":
return self._extract_http_text(stream)
if protocol in ("tls", "ssh"):
return ""
try:
return stream.decode("utf-8", errors="replace")
except Exception:
return ""
```
HTTP flows get parsed by `parse_http`, which extracts URIs, sensitive headers (`cookie`, `authorization`, `set-cookie`), and bodies. TLS and SSH flows are skipped entirely since the content is encrypted and cannot be scanned. Everything else falls through to a UTF-8 decode attempt.
DNS exfiltration indicators and encoded payload detections are converted to `Finding` objects through `_indicator_to_finding`, which maps indicator types to rule IDs via the `EXFIL_RULE_MAP` lookup table. Regex-based detections from reassembled flows go through `match_to_finding` like the other scanners.
### PCAP Parsing
The `read_pcap` function in `network/pcap.py` reads packets using dpkt and yields `PacketInfo` structs:
```python
def read_pcap(path, max_packets=0):
with open(path, "rb") as f:
try:
pcap = dpkt.pcap.Reader(f)
except ValueError:
f.seek(0)
pcap = dpkt.pcapng.Reader(f)
count = 0
for timestamp, buf in pcap:
if max_packets > 0 and count >= max_packets:
break
packet = _parse_ethernet(timestamp, buf)
if packet is not None:
yield packet
count += 1
```
The try/except fallback handles both PCAP (libpcap) and PCAPNG (Wireshark's newer format). dpkt is used instead of Scapy because it is roughly 100x faster for bulk packet parsing. Scapy constructs rich protocol objects with dissection layers; dpkt does minimal parsing and gives you raw bytes.
### TCP Flow Reassembly
The `FlowTracker` in `network/flow_tracker.py` groups packets into flows and reassembles TCP streams:
```python
def make_flow_key(packet):
forward = (
packet.src_ip, packet.dst_ip,
packet.src_port, packet.dst_port,
)
reverse = (
packet.dst_ip, packet.src_ip,
packet.dst_port, packet.src_port,
)
return min(forward, reverse)
```
The bidirectional key is the lexicographically smaller of the forward and reverse 4-tuples. This means `(A->B)` and `(B->A)` packets land in the same flow. The `reassemble_stream` method sorts segments by TCP sequence number and deduplicates retransmissions:
```python
def reassemble_stream(self, key):
flow = self._flows.get(key)
if flow is None:
return b""
sorted_segments = sorted(
flow.segments, key=lambda s: s[0]
)
seen_offsets: set[int] = set()
parts: list[bytes] = []
for seq, data in sorted_segments:
if seq not in seen_offsets:
seen_offsets.add(seq)
parts.append(data)
return b"".join(parts)
```
TCP retransmissions reuse the same sequence number, so deduplication by sequence number prevents duplicate data in the reassembled stream. This is a simplified reassembly that does not handle overlapping segments (where retransmissions contain different data), but it covers the common case.
### Protocol Identification
The `identify_protocol` function in `network/protocols.py` performs Deep Packet Inspection using byte prefix matching:
```python
def identify_protocol(payload: bytes) -> str:
if not payload:
return "unknown"
if _is_http_request(payload):
return "http"
if payload.startswith(HTTP_RESPONSE_PREFIX):
return "http"
if (len(payload) > 2
and payload[:2] == TLS_RECORD_PREFIX):
return "tls"
if payload.startswith(SSH_PREFIX):
return "ssh"
if payload.startswith(SMTP_BANNER_PREFIX):
return "smtp"
return "unknown"
```
HTTP requests are identified by checking if the first word before a space is a known HTTP method (`GET`, `POST`, `PUT`, etc.). TLS records start with `\x16\x03` (ContentType=Handshake + major version 3). SSH banners start with `SSH-`. SMTP server greetings start with `220`.
This matters for DLP because the same sensitive data requires different handling depending on the transport protocol. An SSN in an HTTP body can be read and flagged with high confidence. The same SSN in a TLS-encrypted stream cannot be read, but you can flag the flow as "encrypted traffic containing unknown data" and correlate with other signals.
### DNS Exfiltration Detection
The `DnsExfilDetector` in `network/exfiltration.py` analyzes DNS queries for patterns that suggest data tunneling:
```python
def _check_subdomain_entropy(self, name, src_ip, dst_ip):
parts = name.split(".")
if len(parts) < 3:
return None
subdomain = ".".join(parts[:-2])
if not subdomain:
return None
entropy = shannon_entropy_str(subdomain)
if entropy > self._entropy_threshold:
return ExfilIndicator(
indicator_type="dns_high_entropy",
description=(
f"High subdomain entropy ({entropy:.2f}) "
f"suggesting DNS tunneling"
),
confidence=min(
0.95,
0.50 + (entropy - 3.0) * 0.15,
),
source_ip=src_ip,
dst_ip=dst_ip,
evidence=name,
)
```
Legitimate subdomains (`www`, `mail`, `api`, `cdn`) have very low entropy. A query like `aGVsbG8gd29ybGQ.evil.com` has subdomain entropy above 4.0 because the base64-encoded data uses most of the alphanumeric character space. The detector extracts everything before the last two domain labels (the registerable domain), computes Shannon entropy, and flags queries above the threshold.
The confidence score scales linearly from 0.50 (at entropy 3.0) to 0.95 (at entropy 6.0). This captures the observation that higher entropy means more confident detection: entropy 4.1 might be a CDN hash, but entropy 5.5 is almost certainly encoded data.
## Compliance and Severity Classification
### Severity Mapping
The `score_to_severity` function in `compliance.py` maps confidence scores to severity levels using a threshold table:
```python
SEVERITY_SCORE_THRESHOLDS = [
(0.85, "critical"),
(0.65, "high"),
(0.40, "medium"),
(0.20, "low"),
]
def score_to_severity(score: float) -> Severity:
for threshold, severity in SEVERITY_SCORE_THRESHOLDS:
if score >= threshold:
return severity
return "low"
```
The thresholds are tuned so that:
- **Critical** (0.85+): checksum-validated matches with context keywords (e.g., SSN near "social security")
- **High** (0.65+): checksum-validated matches or strong context without validation
- **Medium** (0.40+): pattern matches without strong validation or context
- **Low** (0.20+): weak matches that might be false positives
### Framework Mapping
The `RULE_FRAMEWORK_MAP` in `compliance.py` is a static lookup table:
```python
RULE_FRAMEWORK_MAP = {
"PII_SSN": ["HIPAA", "CCPA", "GLBA", "GDPR"],
"PII_DRIVERS_LICENSE_FL": ["CCPA", "HIPAA"],
"FIN_CREDIT_CARD_VISA": ["PCI_DSS", "GLBA"],
"FIN_CREDIT_CARD_MC": ["PCI_DSS", "GLBA"],
"FIN_IBAN": ["GDPR", "GLBA"],
"HEALTH_NPI": ["HIPAA"],
"NET_DNS_EXFIL_HIGH_ENTROPY": [],
...
}
```
Rule IDs match actual detection rules rather than using generic categories. Credit card rules are split by brand (`FIN_CREDIT_CARD_VISA`, `FIN_CREDIT_CARD_MC`, `FIN_CREDIT_CARD_AMEX`, `FIN_CREDIT_CARD_DISC`), each triggering PCI-DSS and GLBA. State-specific driver's license rules (`PII_DRIVERS_LICENSE_FL`, `PII_DRIVERS_LICENSE_IL`) map to CCPA and HIPAA alongside the generic CA pattern. Network exfiltration indicators (`NET_DNS_EXFIL_*`, `NET_ENCODED_*`) carry empty framework lists since DNS tunneling is a detection concern, not a regulatory data type.
SSNs trigger four frameworks because they are considered protected health information (HIPAA), personal information (CCPA), financial identifiers (GLBA), and personal data (GDPR). Every rule also has a corresponding entry in `RULE_REMEDIATION_MAP` with specific guidance text. Unknown rules fall back to a generic default.
The mapping is intentionally conservative. An SSN could trigger SOX if it appears in financial reporting data, but without business context the scanner cannot determine that. The listed frameworks are the ones where the mere presence of the data type creates a compliance obligation.
## Shared Scoring Module
The `match_to_finding` function in `scoring.py` centralizes the conversion from `DetectorMatch` to `Finding`. All three scanners import from this single location instead of duplicating the severity/compliance/redaction logic:
```python
def match_to_finding(
match: DetectorMatch,
text: str,
location: Location,
redaction_style: RedactionStyle,
) -> Finding:
severity = score_to_severity(match.score)
frameworks = get_frameworks_for_rule(match.rule_id)
if match.compliance_frameworks:
combined = (
set(frameworks) | set(match.compliance_frameworks)
)
frameworks = sorted(combined)
remediation = get_remediation_for_rule(match.rule_id)
snippet = redact(
text, match.start, match.end,
style=redaction_style,
)
return Finding(
rule_id=match.rule_id,
rule_name=match.rule_name,
severity=severity,
confidence=match.score,
location=location,
redacted_snippet=snippet,
compliance_frameworks=frameworks,
remediation=remediation,
)
```
The function chains severity classification, compliance framework lookup, remediation guidance, and redaction in one call. The framework merging logic handles the case where a detection rule carries its own `compliance_frameworks` list: those are merged with the frameworks from the compliance module, deduplicated, and sorted for deterministic output.
Each scanner calls this in its match loop:
```python
for match in matches:
if match.score < min_confidence:
continue
finding = match_to_finding(
match, chunk.text, chunk.location,
self._redaction_style,
)
result.findings.append(finding)
```
Adding a new compliance framework or changing severity thresholds affects all three scanners uniformly without touching scanner code.
## Redaction
The `redact` function in `redaction.py` builds a snippet with masked content:
```python
def redact(text, start, end, style="partial"):
matched = text[start:end]
if style == "none":
return _build_snippet(text, start, end, matched)
if style == "full":
return _build_snippet(
text, start, end, REDACTED_LABEL
)
redacted = _partial_redact(matched)
return _build_snippet(text, start, end, redacted)
```
The `_partial_redact` function applies format-aware masking:
```python
def _partial_redact(value):
stripped = value.replace("-", "").replace(" ", "")
if len(stripped) >= 9 and stripped.isdigit():
return MASK_CHAR * (len(value) - 4) + value[-4:]
if "@" in value:
local, domain = value.rsplit("@", maxsplit=1)
masked_local = (
local[0] + MASK_CHAR * (len(local) - 1)
)
return f"{masked_local}@{domain}"
if len(value) > 8:
visible = max(4, len(value) // 4)
return (
MASK_CHAR * (len(value) - visible)
+ value[-visible:]
)
return MASK_CHAR * len(value)
```
For digit sequences (SSNs, credit cards), it preserves the last 4 digits: `***-**-6789`. For emails, it keeps the first character and domain: `j****@example.com`. For other strings (API keys, tokens), it shows the last 25%. Short values under 8 characters are fully masked.
The `_build_snippet` function adds 20 characters of context on each side and prepends/appends `...` when the context is truncated. This gives analysts enough surrounding text to understand what the data was near without exposing full document contents.
## CLI Integration
### Global Option Propagation
The Typer callback stores global options in Click's context dict:
```python
@app.callback()
def main(ctx: typer.Context, config: ..., verbose: ..., version: ...):
ctx.ensure_object(dict)
ctx.obj["config_path"] = config
ctx.obj["verbose"] = verbose
```
Subcommands retrieve these via `ctx.ensure_object(dict)`:
```python
def _run_scan(ctx, scan_type, target, output_format, output_file):
obj: dict[str, Any] = ctx.ensure_object(dict)
config_path = obj.get("config_path", "")
verbose = obj.get("verbose", False)
```
This pattern lets `dlp-scan -v -c custom.yml file ./data` propagate the verbose flag and config path to the file scan command without duplicating those options on every subcommand.
### Logging Strategy
The logging level adapts to the output format:
```python
if verbose:
configure_logging(level="DEBUG")
elif output_format == "console":
configure_logging(level="INFO")
else:
configure_logging(level="WARNING")
```
When output is machine-readable (JSON, SARIF, CSV), logging is set to WARNING so that structlog messages written to stderr do not contaminate stdout. This prevents `dlp-scan file ./data -f json | jq` from breaking because log lines mixed into the JSON output. For console output, INFO-level logging provides progress feedback. Verbose mode enables DEBUG for troubleshooting.
### Report Conversion
The `report convert` command reads a JSON scan result and regenerates it in another format:
```python
@report_app.command("convert")
def convert(input_file, output_format="sarif", output_file=""):
raw = path.read_bytes()
data = orjson.loads(raw)
result = _rebuild_result(data)
config = ScanConfig()
engine = ScanEngine(config)
output = engine.generate_report(result, fmt)
...
```
The `_rebuild_result` function deserializes the JSON structure back into `ScanResult`, `Finding`, and `Location` objects. It reads from the `scan_metadata` section for scan-level fields and iterates `findings` to reconstruct each `Finding` with its `Location`. This is necessary because `orjson.loads` produces plain dicts, but the reporters expect typed dataclass instances.
## Testing Strategy
### Property-Based Testing
The project uses Hypothesis for property-based testing of detection rules. Instead of testing a few known inputs, Hypothesis generates random strings constrained by rule formats and verifies that the detection pipeline handles them correctly.
For validators: Hypothesis generates random digit sequences and verifies that `luhn_check`, `iban_check`, and `nhs_check` only return True for inputs that satisfy the mathematical properties (divisibility by 10, mod 97 = 1, mod 11 check digit match).
For the context boost: Hypothesis generates random text with embedded keywords at varying distances and verifies that the boost is always between 0 and `CONTEXT_BOOST_MAX`, and that closer keywords produce higher boosts.
### Running Tests
```bash
uv run pytest -m unit # fast unit tests
uv run pytest -m integration # tests with file I/O
uv run pytest --cov=src # coverage report
```
The test suite uses markers (`unit`, `integration`, `slow`) to separate fast tests from those requiring real filesystem access. The `conftest.py` provides shared fixtures for temporary directories, sample configs, and test data files.
## Dependencies
- **typer**: CLI framework with type-hint argument declaration. The `Annotated` style avoids decorators stacking up.
- **rich**: Terminal tables with colors. Used by `ConsoleReporter` for severity-colored output.
- **structlog**: Structured logging with stdlib integration. JSON or console rendering based on config.
- **pydantic**: Config validation. Catches invalid YAML values before the scan starts.
- **orjson**: Fast JSON serialization. 3-10x faster than stdlib json for large finding lists.
- **ruamel.yaml**: YAML parser that handles 1.2 spec and preserves comments.
- **dpkt**: PCAP parsing. ~100x faster than Scapy for bulk packet iteration.
- **pymupdf**: PDF text extraction with layout preservation.
- **python-docx/openpyxl/xlrd**: Office format extraction.
- **defusedxml/lxml**: Safe XML parsing (defusedxml blocks XXE attacks).
- **pyarrow/fastavro**: Columnar format extraction (Parquet, Avro).
- **asyncpg/aiomysql/pymongo/aiosqlite**: Async database drivers.
## Next Steps
You have seen how the code works. Now:
1. Try the challenges in [04-CHALLENGES.md](./04-CHALLENGES.md) for extension ideas
2. Modify a detection rule and run the tests to see how the scoring changes
3. Scan your own files with `dlp-scan file ./your-directory` and inspect the output