Skip to content

Commit e22d446

Browse files
feat(expansion): add email security, SSH fingerprint, and TLS certificate modules
- email_security_check: SPF/DKIM/DMARC/MTA-STS posture assessment for domains (score /5) - ssh_fingerprint: SSH banner and key exchange fingerprint grab for IPs (MitM detection) - tls_certificate_check: TLS cert chain, issuer, SANs, expiry analysis for domains All modules are standalone with zero external API dependencies. Tested on NixOS 25.11 against google.com and 8.8.8.8.
1 parent 99a6ab2 commit e22d446

3 files changed

Lines changed: 445 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import json
2+
3+
try:
4+
import dns.resolver
5+
6+
resolver = dns.resolver.Resolver()
7+
resolver.timeout = 2
8+
resolver.lifetime = 2
9+
except ImportError:
10+
print("dnspython is missing, use 'pip install dnspython' to install it.")
11+
12+
misperrors = {"error": "Error"}
13+
mispattributes = {"input": ["domain", "hostname"], "output": ["text"]}
14+
moduleinfo = {
15+
"version": "0.1",
16+
"author": "Mihai Catalin Teodosiu",
17+
"description": "Check email security posture (SPF, DKIM, DMARC, MTA-STS) for a domain.",
18+
"module-type": ["expansion", "hover"],
19+
"name": "Email Security Check",
20+
"logo": "",
21+
"requirements": ["dnspython"],
22+
"features": (
23+
"The module takes a domain or hostname attribute as input and queries DNS"
24+
" for email security records: SPF (TXT), DMARC (_dmarc), DKIM (common selectors),"
25+
" and MTA-STS (_mta-sts). Results include record content and a pass/fail assessment."
26+
),
27+
"references": [
28+
"https://tools.ietf.org/html/rfc7208",
29+
"https://tools.ietf.org/html/rfc7489",
30+
],
31+
"input": "A domain or hostname attribute.",
32+
"output": "Text containing email security posture assessment.",
33+
}
34+
moduleconfig = ["custom_resolver"]
35+
36+
DKIM_SELECTORS = [
37+
"default",
38+
"google",
39+
"selector1",
40+
"selector2",
41+
"k1",
42+
"mandrill",
43+
"everlytickey1",
44+
"everlytickey2",
45+
"dkim",
46+
"s1",
47+
"s2",
48+
"mailo",
49+
]
50+
51+
52+
def _query_txt(domain):
53+
try:
54+
answers = resolver.resolve(domain, "TXT")
55+
return [str(rdata).strip('"') for rdata in answers]
56+
except Exception:
57+
return []
58+
59+
60+
def _check_spf(domain):
61+
records = _query_txt(domain)
62+
spf = [r for r in records if r.startswith("v=spf1")]
63+
if spf:
64+
return {"status": "FOUND", "record": spf[0]}
65+
return {"status": "MISSING", "record": None}
66+
67+
68+
def _check_dmarc(domain):
69+
records = _query_txt(f"_dmarc.{domain}")
70+
dmarc = [r for r in records if r.startswith("v=DMARC1")]
71+
if dmarc:
72+
policy = "none"
73+
for part in dmarc[0].split(";"):
74+
part = part.strip()
75+
if part.startswith("p="):
76+
policy = part[2:]
77+
return {"status": "FOUND", "record": dmarc[0], "policy": policy}
78+
return {"status": "MISSING", "record": None, "policy": None}
79+
80+
81+
def _check_dkim(domain):
82+
found = []
83+
for selector in DKIM_SELECTORS:
84+
records = _query_txt(f"{selector}._domainkey.{domain}")
85+
dkim = [r for r in records if "DKIM1" in r or "k=" in r or "p=" in r]
86+
if dkim:
87+
found.append({"selector": selector, "record": dkim[0]})
88+
return found
89+
90+
91+
def _check_mta_sts(domain):
92+
records = _query_txt(f"_mta-sts.{domain}")
93+
sts = [r for r in records if r.startswith("v=STSv1")]
94+
if sts:
95+
return {"status": "FOUND", "record": sts[0]}
96+
return {"status": "MISSING", "record": None}
97+
98+
99+
def handler(q=False):
100+
if q is False:
101+
return False
102+
103+
request = json.loads(q)
104+
105+
domain = request.get("domain") or request.get("hostname")
106+
if not domain:
107+
misperrors["error"] = "A domain or hostname attribute is required."
108+
return misperrors
109+
110+
if request.get("config", {}).get("custom_resolver"):
111+
resolver.nameservers = [request["config"]["custom_resolver"]]
112+
113+
spf = _check_spf(domain)
114+
dmarc = _check_dmarc(domain)
115+
dkim = _check_dkim(domain)
116+
mta_sts = _check_mta_sts(domain)
117+
118+
lines = [f"=== Email Security Posture: {domain} ===", ""]
119+
120+
lines.append(f"SPF: {spf['status']}")
121+
if spf["record"]:
122+
lines.append(f" Record: {spf['record']}")
123+
124+
lines.append(f"\nDMARC: {dmarc['status']}")
125+
if dmarc["record"]:
126+
lines.append(f" Policy: {dmarc['policy']}")
127+
lines.append(f" Record: {dmarc['record']}")
128+
129+
if dkim:
130+
lines.append(f"\nDKIM: FOUND ({len(dkim)} selector(s))")
131+
for entry in dkim:
132+
lines.append(f" Selector '{entry['selector']}': {entry['record'][:80]}...")
133+
else:
134+
lines.append("\nDKIM: NOT FOUND (tested common selectors)")
135+
136+
lines.append(f"\nMTA-STS: {mta_sts['status']}")
137+
if mta_sts["record"]:
138+
lines.append(f" Record: {mta_sts['record']}")
139+
140+
score = sum([
141+
1 if spf["status"] == "FOUND" else 0,
142+
1 if dmarc["status"] == "FOUND" else 0,
143+
1 if dmarc.get("policy") in ("reject", "quarantine") else 0,
144+
1 if dkim else 0,
145+
1 if mta_sts["status"] == "FOUND" else 0,
146+
])
147+
lines.append(f"\nSecurity Score: {score}/5")
148+
149+
result_text = "\n".join(lines)
150+
return {"results": [{"types": ["text"], "values": result_text}]}
151+
152+
153+
def introspection():
154+
return mispattributes
155+
156+
157+
def version():
158+
return moduleinfo
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import hashlib
2+
import json
3+
import socket
4+
5+
6+
misperrors = {"error": "Error"}
7+
mispattributes = {"input": ["ip-src", "ip-dst"], "output": ["text"]}
8+
moduleinfo = {
9+
"version": "0.1",
10+
"author": "Mihai Catalin Teodosiu",
11+
"description": "Grab SSH server key fingerprint from an IP address for verification or MitM detection.",
12+
"module-type": ["expansion", "hover"],
13+
"name": "SSH Fingerprint",
14+
"logo": "",
15+
"requirements": [],
16+
"features": (
17+
"The module takes an IP address attribute as input, connects to port 22,"
18+
" performs the SSH protocol version exchange and key exchange init to extract"
19+
" the server host key algorithms and SSH banner. Useful for detecting MitM"
20+
" attacks or verifying server identity changes."
21+
),
22+
"references": ["https://tools.ietf.org/html/rfc4253"],
23+
"input": "An IP address attribute (ip-src or ip-dst).",
24+
"output": "Text containing SSH banner and key exchange information.",
25+
}
26+
moduleconfig = ["port", "timeout"]
27+
28+
29+
def _grab_ssh_banner(ip, port=22, timeout=5):
30+
result = {
31+
"banner": None,
32+
"kex_algorithms": None,
33+
"host_key_algorithms": None,
34+
"error": None,
35+
}
36+
try:
37+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
38+
sock.settimeout(timeout)
39+
sock.connect((ip, port))
40+
41+
banner = sock.recv(256).decode("utf-8", errors="replace").strip()
42+
result["banner"] = banner
43+
44+
sock.sendall(b"SSH-2.0-MISP_Fingerprint_Module\r\n")
45+
46+
kex_data = sock.recv(4096)
47+
if len(kex_data) > 21:
48+
payload = kex_data[5:]
49+
if payload and payload[0:1] == b"\x14":
50+
payload = payload[16:]
51+
if payload and payload[0:1] == b"\x14":
52+
pass
53+
54+
try:
55+
msg_code = payload[0]
56+
if msg_code == 20:
57+
offset = 17
58+
if offset < len(payload):
59+
kex_len = int.from_bytes(
60+
payload[offset : offset + 4], "big"
61+
)
62+
offset += 4
63+
if offset + kex_len <= len(payload):
64+
kex_str = payload[offset : offset + kex_len].decode(
65+
"utf-8", errors="replace"
66+
)
67+
result["kex_algorithms"] = kex_str
68+
offset += kex_len
69+
70+
hk_len = int.from_bytes(
71+
payload[offset : offset + 4], "big"
72+
)
73+
offset += 4
74+
if offset + hk_len <= len(payload):
75+
hk_str = payload[offset : offset + hk_len].decode(
76+
"utf-8", errors="replace"
77+
)
78+
result["host_key_algorithms"] = hk_str
79+
except (IndexError, ValueError):
80+
pass
81+
82+
raw_hash = hashlib.sha256(kex_data).hexdigest()
83+
result["kex_hash"] = raw_hash
84+
85+
sock.close()
86+
except socket.timeout:
87+
result["error"] = "Connection timed out"
88+
except ConnectionRefusedError:
89+
result["error"] = "Connection refused (port closed)"
90+
except Exception as e:
91+
result["error"] = str(e)
92+
93+
return result
94+
95+
96+
def handler(q=False):
97+
if q is False:
98+
return False
99+
100+
request = json.loads(q)
101+
102+
ip = request.get("ip-src") or request.get("ip-dst")
103+
if not ip:
104+
misperrors["error"] = "An IP address attribute is required."
105+
return misperrors
106+
107+
config = request.get("config", {})
108+
port = int(config.get("port") or 22)
109+
timeout = float(config.get("timeout") or 5)
110+
111+
result = _grab_ssh_banner(ip, port, timeout)
112+
113+
lines = [f"=== SSH Fingerprint: {ip}:{port} ===", ""]
114+
115+
if result["error"]:
116+
lines.append(f"Error: {result['error']}")
117+
return {"results": [{"types": ["text"], "values": "\n".join(lines)}]}
118+
119+
if result["banner"]:
120+
lines.append(f"Banner: {result['banner']}")
121+
122+
if result.get("host_key_algorithms"):
123+
lines.append(f"\nHost Key Algorithms: {result['host_key_algorithms']}")
124+
125+
if result.get("kex_algorithms"):
126+
lines.append(f"KEX Algorithms: {result['kex_algorithms']}")
127+
128+
if result.get("kex_hash"):
129+
lines.append(f"\nKEX Init Hash (SHA256): {result['kex_hash']}")
130+
lines.append(" (Compare this hash over time to detect server key changes)")
131+
132+
return {"results": [{"types": ["text"], "values": "\n".join(lines)}]}
133+
134+
135+
def introspection():
136+
return mispattributes
137+
138+
139+
def version():
140+
return moduleinfo

0 commit comments

Comments
 (0)