Skip to content

Commit ca2b34d

Browse files
committed
feat: add email security posture checks
1 parent 246d6b2 commit ca2b34d

6 files changed

Lines changed: 221 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ It turns quick domain checks into clean JSON reports today, then client-ready HT
1313
- HTTPS reachability
1414
- HTTP security headers
1515
- TLS certificate expiry
16+
- MX, SPF, and DMARC email-security posture
1617
- Risk scoring with actionable findings
1718
- JSON report export
1819

@@ -57,7 +58,7 @@ SentinelDeck is **passive-first**. The MVP avoids intrusive vulnerability scanni
5758
- [x] JSON report export
5859
- [x] HTTP header checks
5960
- [x] TLS expiry check
60-
- [ ] SPF/DMARC/MX checks
61+
- [x] SPF/DMARC/MX checks
6162
- [ ] HTML report
6263
- [ ] PDF export
6364
- [ ] Screenshot evidence

src/sentineldeck/risk/scoring.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,55 @@ def build_findings(checks: dict) -> list[Finding]:
7979
evidence={"days_remaining": tls.get("days_remaining"), "expires_at": tls.get("expires_at")},
8080
))
8181

82+
email = checks.get("email_security", {})
83+
if email:
84+
mx = email.get("mx", {})
85+
spf = email.get("spf", {})
86+
dmarc = email.get("dmarc", {})
87+
if not mx.get("present"):
88+
findings.append(Finding(
89+
id="mx-missing",
90+
title="No MX records found",
91+
severity="medium",
92+
description="The domain does not publish MX records for receiving email.",
93+
recommendation="Publish correct MX records if the domain sends or receives business email.",
94+
evidence=mx,
95+
))
96+
if not spf.get("present"):
97+
findings.append(Finding(
98+
id="spf-missing",
99+
title="SPF record is missing",
100+
severity="medium",
101+
description="The domain does not publish an SPF record to restrict allowed mail senders.",
102+
recommendation="Add an SPF TXT record that lists approved sending services and ends with -all or ~all.",
103+
evidence=spf,
104+
))
105+
elif spf.get("policy") in {None, "+all", "?all", "~all"}:
106+
findings.append(Finding(
107+
id="spf-weak-policy",
108+
title="SPF policy is weak",
109+
severity="low",
110+
description="The SPF record does not use a strict fail policy.",
111+
recommendation="Review sending sources and move toward a stricter -all policy when safe.",
112+
evidence=spf,
113+
))
114+
if not dmarc.get("present"):
115+
findings.append(Finding(
116+
id="dmarc-missing",
117+
title="DMARC record is missing",
118+
severity="medium",
119+
description="The domain does not publish DMARC, making spoofed-email handling unclear.",
120+
recommendation="Add a DMARC TXT record at _dmarc with at least p=none for monitoring, then move to quarantine or reject.",
121+
evidence=dmarc,
122+
))
123+
elif dmarc.get("policy") in {None, "none"}:
124+
findings.append(Finding(
125+
id="dmarc-monitor-only",
126+
title="DMARC is monitor-only",
127+
severity="low",
128+
description="The domain publishes DMARC but does not request enforcement.",
129+
recommendation="After monitoring legitimate mail flow, move DMARC policy to quarantine or reject.",
130+
evidence=dmarc,
131+
))
132+
82133
return findings

src/sentineldeck/scanner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from sentineldeck.models import ScanReport
44
from sentineldeck.risk.scoring import build_findings, grade, score_findings
55
from sentineldeck.scanners.domain import normalize_domain, resolve_domain
6+
from sentineldeck.scanners.email_security import analyze_email_security
67
from sentineldeck.scanners.http_headers import fetch_headers, missing_security_headers
78
from sentineldeck.scanners.tls import inspect_tls
89

@@ -14,12 +15,14 @@ def scan_domain(target: str) -> ScanReport:
1415
dns = resolve_domain(domain)
1516
http = fetch_headers(domain)
1617
tls = inspect_tls(domain)
18+
email_security = analyze_email_security(domain)
1719

1820
report.checks = {
1921
"dns": dns,
2022
"http": http,
2123
"missing_security_headers": missing_security_headers(http.get("headers", {})),
2224
"tls": tls,
25+
"email_security": email_security,
2326
}
2427
report.findings = build_findings(report.checks)
2528
report.risk_score = score_findings(report.findings)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import subprocess
5+
from typing import Callable
6+
7+
DnsQuery = Callable[[str, str], str]
8+
9+
10+
def query_dns(name: str, record_type: str) -> str:
11+
"""Query DNS using common system tools without adding runtime dependencies."""
12+
commands = [
13+
["dig", "+short", record_type, name],
14+
["host", "-t", record_type, name],
15+
]
16+
for command in commands:
17+
try:
18+
completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=10)
19+
except (FileNotFoundError, subprocess.TimeoutExpired):
20+
continue
21+
if completed.returncode == 0 and completed.stdout.strip():
22+
return completed.stdout
23+
return ""
24+
25+
26+
def parse_txt_records(output: str) -> list[str]:
27+
records: list[str] = []
28+
for line in output.splitlines():
29+
parts = re.findall(r'"([^"]*)"', line)
30+
if parts:
31+
records.append("".join(parts).strip())
32+
elif line.strip():
33+
records.append(line.strip())
34+
return records
35+
36+
37+
def parse_mx_records(output: str) -> list[str]:
38+
records: list[str] = []
39+
for line in output.splitlines():
40+
line = line.strip()
41+
if not line:
42+
continue
43+
if " mail is handled by " in line:
44+
records.append(line.split(" mail is handled by ", 1)[1])
45+
else:
46+
records.append(line)
47+
return records
48+
49+
50+
def extract_spf_policy(record: str | None) -> str | None:
51+
if not record:
52+
return None
53+
mechanisms = record.lower().split()
54+
for mechanism in ("-all", "~all", "?all", "+all"):
55+
if mechanism in mechanisms:
56+
return mechanism
57+
return None
58+
59+
60+
def extract_dmarc_policy(record: str | None) -> str | None:
61+
if not record:
62+
return None
63+
for part in record.split(";"):
64+
key, _, value = part.strip().partition("=")
65+
if key.lower() == "p":
66+
return value.strip().lower() or None
67+
return None
68+
69+
70+
def analyze_email_security(domain: str, query: DnsQuery = query_dns) -> dict:
71+
mx_records = parse_mx_records(query(domain, "MX"))
72+
txt_records = parse_txt_records(query(domain, "TXT"))
73+
dmarc_records = parse_txt_records(query(f"_dmarc.{domain}", "TXT"))
74+
75+
spf_records = [record for record in txt_records if record.lower().startswith("v=spf1")]
76+
dmarc_policy_records = [record for record in dmarc_records if record.lower().startswith("v=dmarc1")]
77+
spf_record = spf_records[0] if spf_records else None
78+
dmarc_record = dmarc_policy_records[0] if dmarc_policy_records else None
79+
80+
return {
81+
"mx": {"present": bool(mx_records), "records": mx_records},
82+
"spf": {
83+
"present": bool(spf_records),
84+
"records": spf_records,
85+
"policy": extract_spf_policy(spf_record),
86+
},
87+
"dmarc": {
88+
"present": bool(dmarc_policy_records),
89+
"records": dmarc_policy_records,
90+
"policy": extract_dmarc_policy(dmarc_record),
91+
},
92+
}

tests/test_email_security.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from sentineldeck.risk.scoring import build_findings
2+
from sentineldeck.scanners.email_security import analyze_email_security, parse_txt_records
3+
4+
5+
def test_parse_txt_records_joins_split_segments():
6+
output = '"v=spf1 include:_spf.example.com " "-all"\n"other=value"\n'
7+
8+
assert parse_txt_records(output) == ["v=spf1 include:_spf.example.com -all", "other=value"]
9+
10+
11+
def test_parse_mx_records_preserves_null_mx():
12+
from sentineldeck.scanners.email_security import parse_mx_records
13+
14+
assert parse_mx_records("0 .\n") == ["0 ."]
15+
16+
17+
def test_analyze_email_security_detects_present_controls():
18+
dns_query = {
19+
("example.com", "MX"): "10 mail.example.com.\n",
20+
("example.com", "TXT"): '"v=spf1 mx -all"\n',
21+
("_dmarc.example.com", "TXT"): '"v=DMARC1; p=reject; rua=mailto:dmarc@example.com"\n',
22+
}
23+
24+
result = analyze_email_security("example.com", query=lambda name, record_type: dns_query[(name, record_type)])
25+
26+
assert result["mx"]["present"] is True
27+
assert result["spf"]["present"] is True
28+
assert result["spf"]["policy"] == "-all"
29+
assert result["dmarc"]["present"] is True
30+
assert result["dmarc"]["policy"] == "reject"
31+
32+
33+
def test_analyze_email_security_handles_missing_records():
34+
result = analyze_email_security("example.com", query=lambda name, record_type: "")
35+
36+
assert result["mx"]["present"] is False
37+
assert result["spf"]["present"] is False
38+
assert result["dmarc"]["present"] is False
39+
40+
41+
def test_build_findings_flags_missing_dmarc_and_weak_spf():
42+
checks = {
43+
"email_security": {
44+
"mx": {"present": True, "records": ["10 mail.example.com."]},
45+
"spf": {"present": True, "records": ["v=spf1 include:mail.example.com ~all"], "policy": "~all"},
46+
"dmarc": {"present": False, "records": [], "policy": None},
47+
}
48+
}
49+
50+
findings = build_findings(checks)
51+
finding_ids = {finding.id for finding in findings}
52+
53+
assert "dmarc-missing" in finding_ids
54+
assert "spf-weak-policy" in finding_ids

tests/test_scanner.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from sentineldeck.scanner import scan_domain
2+
3+
4+
def test_scan_domain_includes_email_security(monkeypatch):
5+
monkeypatch.setattr("sentineldeck.scanner.resolve_domain", lambda domain: {"resolved": True, "addresses": ["127.0.0.1"]})
6+
monkeypatch.setattr("sentineldeck.scanner.fetch_headers", lambda domain: {"reachable": True, "headers": {}})
7+
monkeypatch.setattr("sentineldeck.scanner.inspect_tls", lambda domain: {"valid": True, "days_remaining": 90})
8+
monkeypatch.setattr(
9+
"sentineldeck.scanner.analyze_email_security",
10+
lambda domain: {
11+
"mx": {"present": True, "records": ["10 mail.example.com."]},
12+
"spf": {"present": True, "records": ["v=spf1 mx -all"], "policy": "-all"},
13+
"dmarc": {"present": True, "records": ["v=DMARC1; p=reject"], "policy": "reject"},
14+
},
15+
)
16+
17+
report = scan_domain("example.com")
18+
19+
assert report.checks["email_security"]["dmarc"]["policy"] == "reject"

0 commit comments

Comments
 (0)