#!/usr/bin/env python3
"""
BastionGuard Mail Security — Native Host
Protocol:
{ "type": "get-config" }
-> { "ok": true, "config": {...} }
{
"type": "scan-email",
"body": "...",
"links": ["url1", ...],
"attachments": [
{
"name": "invoice.zip",
"content_b64": "...",
"content_type": "application/zip",
"size": 184
}
]
}
-> {
"ok": true,
"clean": true/false,
"threats": [...],
"phishing_links": [...],
"links_checked": 0,
"attachments_checked": 0,
"message_keys": [...]
}
"""
import json
import os
import struct
import sys
import base64
import mimetypes
import socket
import urllib.request
import urllib.parse
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
MAIL_CONFIG_PATH = os.path.expanduser("~/.config/BastionGuard/mail.json")
CLAMD_SOCKET = "/var/run/clamav/clamd.ctl"
PHISHING_API = "https://bastionguard.eu/bastionguard-security-intelligence/?q="
PHISHING_TIMEOUT = 8
MAX_LINKS = 10
MAX_WORKERS = 4
MAX_ATTACHMENTS = 10
# ============================================================
# Native Messaging protocol
# ============================================================
def read_message():
raw = sys.stdin.buffer.read(4)
if not raw or len(raw) < 4:
return None
length = struct.unpack("=I", raw)[0]
payload = sys.stdin.buffer.read(length)
return json.loads(payload.decode("utf-8"))
def send_message(data):
encoded = json.dumps(data, ensure_ascii=False).encode("utf-8")
sys.stdout.buffer.write(struct.pack("=I", len(encoded)))
sys.stdout.buffer.write(encoded)
sys.stdout.buffer.flush()
# ============================================================
# Logo -> data URI
# ============================================================
def logo_to_data_uri(path):
if not path or not os.path.isfile(path):
return ""
try:
mime, _ = mimetypes.guess_type(path)
mime = mime or "image/png"
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("ascii")
return f"data:{mime};base64,{data}"
except Exception:
return ""
def enrich_config(config):
for profile in config.get("profiles", []):
sig = profile.get("signature", {})
lp = sig.get("logo_path", "")
if lp and not lp.startswith("data:") and not lp.startswith("http"):
sig["logo"] = logo_to_data_uri(lp)
elif lp.startswith("http"):
sig["logo"] = lp
else:
sig.setdefault("logo", "")
return config
def load_mail_config():
with open(MAIL_CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
return enrich_config(config)
# ============================================================
# ClamAV via Unix socket
# ============================================================
def clamd_scan_bytes(data: bytes) -> dict:
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(30)
s.connect(CLAMD_SOCKET)
s.sendall(b"zINSTREAM\0")
chunk_size = 4096
offset = 0
while offset < len(data):
chunk = data[offset:offset + chunk_size]
s.sendall(struct.pack("!I", len(chunk)))
s.sendall(chunk)
offset += chunk_size
s.sendall(struct.pack("!I", 0))
response = b""
while True:
part = s.recv(4096)
if not part:
break
response += part
if b"\0" in part or b"\n" in part:
break
resp_str = response.decode("utf-8", errors="replace").strip().strip("\0")
if "OK" in resp_str and "FOUND" not in resp_str:
return {
"clean": True,
"threat_name": None,
"threat_code": None,
"note": None,
"error": None,
}
if "FOUND" in resp_str:
threat_name = resp_str.split(":")[-1].replace("FOUND", "").strip()
return {
"clean": False,
"threat_name": threat_name,
"threat_code": "clamav_threat_detected",
"note": None,
"error": None,
}
return {
"clean": True,
"threat_name": None,
"threat_code": None,
"note": resp_str,
"error": None,
}
except Exception as e:
return {
"clean": True,
"threat_name": None,
"threat_code": None,
"note": None,
"error": str(e),
}
def scan_email_body(body: str) -> dict:
return clamd_scan_bytes(body.encode("utf-8"))
def scan_attachment(att: dict) -> dict:
name = att.get("name", "attachment")
content_b64 = att.get("content_b64", "")
try:
if not content_b64:
return {
"clean": True,
"name": name,
"threat_name": None,
"threat_code": None,
"error": "empty_attachment"
}
raw = base64.b64decode(content_b64, validate=False)
result = clamd_scan_bytes(raw)
return {
"clean": result.get("clean", True),
"name": name,
"threat_name": result.get("threat_name"),
"threat_code": result.get("threat_code"),
"error": result.get("error"),
}
except Exception as e:
return {
"clean": True,
"name": name,
"threat_name": None,
"threat_code": None,
"error": str(e),
}
# ============================================================
# Link extraction
# ============================================================
def extract_links(body: str) -> list:
urls = re.findall(r'https?://[^\s"\'<>]+', body)
seen = set()
unique = []
for u in urls:
u = u.rstrip(".,;)")
if u not in seen:
seen.add(u)
unique.append(u)
return unique[:MAX_LINKS]
# ============================================================
# Phishing HTML parsing
# ============================================================
def parse_phishing_html(html: str) -> dict:
card_m = re.search(
r'
',
html,
re.IGNORECASE,
)
badge_cls = badge_m.group(1) if badge_m else ""
if "malicious" in badge_cls:
classification_code = "malicious"
is_phishing = True
elif "warning" in badge_cls:
classification_code = "warning"
is_phishing = False
elif "clean" in badge_cls:
classification_code = "clean"
is_phishing = False
else:
classification_code = "unknown"
is_phishing = False
detail_m = re.search(
r'
CLASSIFICATION\s*([^<]+)',
html,
re.IGNORECASE,
)
detail = detail_m.group(1).strip() if detail_m else classification_code.upper()
sev_m = re.search(
r'
([^<]+)
',
html,
re.IGNORECASE,
)
severity = sev_m.group(1).strip().lower() if sev_m else None
if not severity:
sev_cls_m = re.search(
r'phishing-sev-(low|medium|high|crit)',
html,
re.IGNORECASE,
)
if sev_cls_m:
severity = sev_cls_m.group(1).lower()
return {
"classification_code": classification_code,
"detail": detail,
"severity_code": severity,
"is_phishing": is_phishing,
}
def check_phishing_link(url: str) -> dict:
try:
api_url = PHISHING_API + urllib.parse.quote(url, safe="")
req = urllib.request.Request(
api_url,
headers={
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36 "
"BastionGuard-MailProxy/1.0"
)
},
)
with urllib.request.urlopen(req, timeout=PHISHING_TIMEOUT) as resp:
html = resp.read().decode("utf-8", errors="replace")
result = parse_phishing_html(html)
return {
"url": url,
"phishing": result["is_phishing"],
"classification_code": result["classification_code"],
"detail": result["detail"],
"severity_code": result["severity_code"],
"error_code": None,
"error": None,
}
except Exception as e:
return {
"url": url,
"phishing": False,
"classification_code": "error",
"detail": None,
"severity_code": None,
"error_code": "phishing_check_error",
"error": str(e),
}
def check_links_parallel(links: list) -> list:
if not links:
return []
results = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(check_phishing_link, url): url for url in links}
for future in as_completed(futures):
try:
results.append(future.result())
except Exception as e:
results.append({
"url": futures[future],
"phishing": False,
"classification_code": "error",
"detail": None,
"severity_code": None,
"error_code": "phishing_check_error",
"error": str(e),
})
return results
# ============================================================
# scan-email handler
# ============================================================
def handle_scan_email(message: dict) -> dict:
body = message.get("body", "") or ""
links = message.get("links", []) or []
attachments = message.get("attachments", []) or []
threats = []
phishing_links = []
message_keys = []
clean = True
# 1. ClamAV sul body
if body:
clamav_result = scan_email_body(body)
if not clamav_result.get("clean", True):
clean = False
if "threat_detected" not in message_keys:
message_keys.append("threat_detected")
threats.append({
"engine": "clamav",
"source": "body",
"code": clamav_result.get("threat_code") or "clamav_threat_detected",
"name": clamav_result.get("threat_name") or "unknown_threat",
})
# 2. Estrai link se non forniti
if not links and body:
links = extract_links(body)
# 3. Controllo phishing link
if links:
link_results = check_links_parallel(links)
for r in link_results:
if r.get("phishing"):
clean = False
if "phishing_detected" not in message_keys:
message_keys.append("phishing_detected")
phishing_links.append({
"url": r["url"],
"classification_code": r.get("classification_code", "malicious"),
"detail": r.get("detail", ""),
"severity_code": r.get("severity_code"),
})
# 4. ClamAV sugli allegati
safe_attachments = []
for att in attachments[:MAX_ATTACHMENTS]:
if isinstance(att, dict):
safe_attachments.append(att)
for att in safe_attachments:
result = scan_attachment(att)
if not result.get("clean", True):
clean = False
if "threat_detected" not in message_keys:
message_keys.append("threat_detected")
threats.append({
"engine": "clamav",
"source": "attachment",
"attachment_name": result.get("name", "attachment"),
"code": result.get("threat_code") or "clamav_threat_detected",
"name": result.get("threat_name") or "unknown_threat",
})
if clean:
message_keys.append("scan_clean")
return {
"ok": True,
"clean": clean,
"threats": threats,
"phishing_links": phishing_links,
"links_checked": len(links),
"attachments_checked": len(safe_attachments),
"message_keys": message_keys,
}
# ============================================================
# Main loop
# ============================================================
def main():
while True:
message = read_message()
if message is None:
break
msg_type = message.get("type")
if msg_type == "get-config":
try:
config = load_mail_config()
send_message({"ok": True, "config": config})
except Exception as e:
send_message({
"ok": False,
"error_code": "config_load_failed",
"error": str(e),
})
elif msg_type == "scan-email":
try:
result = handle_scan_email(message)
send_message(result)
except Exception as e:
send_message({
"ok": False,
"error_code": "scan_email_failed",
"error": str(e),
})
else:
send_message({
"ok": False,
"error_code": "unknown_request",
"error": f"unknown request: {msg_type}",
})
if __name__ == "__main__":
main()