Skip to content

Commit 8d61a5e

Browse files
authored
Merge pull request #989 from MISP/codex/expansion-proofpoint-et-intelligence
Add Proofpoint ET Intelligence expansion module
2 parents f4864f1 + de1fc7d commit 8d61a5e

3 files changed

Lines changed: 449 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Proofpoint ET Intelligence
2+
3+
Enrich domains, IPv4 addresses and MD5 hashes using Proofpoint ET Intelligence.
4+
5+
Uses the v1 API with Authorization header authentication. IP and domain queries return reputation plus passive DNS pivots; MD5 queries return sample details. IPv6 is rejected before querying because the service contract is IPv4. max_results defaults to 25 (1-100). Reputation scores are retained as context, including score zero, and do not change source IDS flags.
6+
7+
Input: domain, hostname, ip-src, ip-dst, md5.
8+
9+
Configuration: apikey, max_results. Required: apikey.
10+
11+
Requests verify TLS, reject redirects and use a 30 second timeout. Credentials stay in authentication headers or request bodies. Service errors are reported without echoing credentials or response bodies.
12+
13+
New attributes use `to_ids=false`; the source attribute retains its UUID and metadata. Results reference the source. Empty lookups return the source without fabricated enrichment.
14+
15+
References:
16+
17+
- https://eti.emergingthreats.net/
18+
19+
Example request:
20+
21+
```json
22+
{
23+
"module": "proofpoint_et_intelligence",
24+
"attribute": {
25+
"type": "domain",
26+
"value": "example.com",
27+
"uuid": "32876e4a-916d-4935-9e51-7b69a76718f8"
28+
},
29+
"config": {
30+
"apikey": "YOUR_API_KEY"
31+
}
32+
}
33+
```
34+
35+
Validation: `pytest tests/test_proofpoint_et_intelligence.py`. Tests use mocked provider responses; authenticated live API calls were not exercised.
36+
37+
Sample `file_size` is mapped to the file object `size-in-bytes` relation, including zero-byte files.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import json
2+
import re
3+
import xml.etree.ElementTree as ET
4+
from ipaddress import ip_address
5+
from urllib.parse import quote, urlsplit
6+
7+
import requests
8+
from pymisp import MISPEvent, MISPObject
9+
10+
misperrors = {"error": "Error"}
11+
mispattributes = {"input": ["domain", "hostname", "ip-src", "ip-dst", "md5"], "format": "misp_standard"}
12+
moduleconfig = ["apikey", "max_results"]
13+
moduleinfo = {
14+
"version": "1",
15+
"author": "MISP Project",
16+
"name": "Proofpoint ET Intelligence",
17+
"description": "Enrich domains, IPv4 addresses and MD5 hashes using Proofpoint ET Intelligence.",
18+
"module-type": ["expansion"],
19+
"logo": "",
20+
"requirements": ["requests"],
21+
"features": (
22+
"Uses the v1 API with Authorization header authentication. IP and domain queries return reputation plus passive"
23+
" DNS pivots; MD5 queries return sample details. IPv6 is rejected before querying because the service contract"
24+
" is IPv4. max_results defaults to 25 (1-100). Reputation scores are retained as context, including score zero,"
25+
" and do not change source IDS flags."
26+
),
27+
"references": ["https://eti.emergingthreats.net/"],
28+
"input": "domain, hostname, ip-src, ip-dst, md5 MISP attribute.",
29+
"output": "Referenced enrichment objects and pivotable attributes.",
30+
}
31+
TIMEOUT = 30
32+
HASH_LENGTHS = {"md5": 32, "sha1": 40, "sha256": 64}
33+
34+
35+
class ModuleError(ValueError):
36+
"""An actionable error that contains no request credentials."""
37+
38+
39+
def _request(method, url, response_format="json", **kwargs):
40+
try:
41+
response = requests.request(method, url, timeout=TIMEOUT, allow_redirects=False, verify=True, **kwargs)
42+
except requests.RequestException:
43+
raise ModuleError("The service request failed or timed out.") from None
44+
if response.status_code in (404, 204):
45+
return None
46+
if response.status_code not in (200, 201, 202):
47+
raise ModuleError(f"The service returned HTTP {response.status_code}.")
48+
if response_format == "bytes":
49+
return response.content
50+
if response_format == "xml":
51+
if b"<!ENTITY" in response.content.upper() or re.search(b"<!DOCTYPE[^>]*\\[", response.content, re.I):
52+
raise ModuleError("The service returned XML with a forbidden declaration.")
53+
try:
54+
return ET.fromstring(response.content)
55+
except ET.ParseError:
56+
raise ModuleError("The service returned invalid XML.") from None
57+
try:
58+
return response.json()
59+
except ValueError:
60+
raise ModuleError("The service returned invalid JSON.") from None
61+
62+
63+
def _domain(value):
64+
if not isinstance(value, str):
65+
raise ModuleError("Invalid domain or hostname.")
66+
value = value.rstrip(".").encode("idna").decode("ascii").lower()
67+
if len(value) > 253 or not all(
68+
(re.fullmatch("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", part) for part in value.split("."))
69+
):
70+
raise ModuleError("Invalid domain or hostname.")
71+
return value
72+
73+
74+
def _hash(kind, value):
75+
if not isinstance(value, str) or not re.fullmatch("[0-9a-fA-F]{%d}" % HASH_LENGTHS[kind], value):
76+
raise ModuleError(f"Invalid {kind} hash.")
77+
return value.lower()
78+
79+
80+
def _value(attribute):
81+
kind, value = (attribute["type"], attribute["value"].strip())
82+
if kind in HASH_LENGTHS:
83+
return _hash(kind, value)
84+
if kind in ("ip-src", "ip-dst"):
85+
return str(ip_address(value))
86+
if kind in ("domain", "hostname"):
87+
return _domain(value)
88+
if kind == "url":
89+
parts = urlsplit(value)
90+
if parts.scheme not in ("http", "https") or not parts.hostname or parts.username or parts.password:
91+
raise ModuleError("Invalid HTTP(S) URL.")
92+
return value
93+
94+
95+
def _limit(config, key="max_results", default=25, maximum=100):
96+
value = config.get(key)
97+
if value is None or value == "":
98+
return default
99+
if isinstance(value, bool) or not str(value).isdigit() or (not 1 <= int(value) <= maximum):
100+
raise ModuleError(f"{key} must be an integer between 1 and {maximum}.")
101+
return int(value)
102+
103+
104+
def _annotation(event, attribute, title, data):
105+
obj = MISPObject("annotation")
106+
obj.comment = title
107+
obj.add_attribute("text", value=json.dumps(data, sort_keys=True, indent=2), to_ids=False, disable_correlation=True)
108+
obj.add_reference(attribute["uuid"], "describes")
109+
event.add_object(obj)
110+
111+
112+
def _file(event, attribute, data, mapping=None):
113+
mapping = mapping or {"md5": "md5", "sha1": "sha1", "sha256": "sha256"}
114+
obj = MISPObject("file")
115+
for kind, key in mapping.items():
116+
if data.get(key):
117+
obj.add_attribute(kind, value=_hash(kind, data[key]), to_ids=False)
118+
for relation, key in (("filename", "filename"), ("mimetype", "mimetype"), ("size-in-bytes", "size")):
119+
if data.get(key) is not None and data[key] != "":
120+
obj.add_attribute(relation, value=data[key], to_ids=False)
121+
if obj.attributes:
122+
obj.add_reference(attribute["uuid"], "related-to")
123+
event.add_object(obj)
124+
125+
126+
def _cap(event, attribute, rows, limit, more=False):
127+
if len(rows) > limit or more:
128+
_annotation(
129+
event, attribute, moduleinfo["name"] + " result limit", {"limit": limit, "more_results_available": True}
130+
)
131+
return rows[:limit]
132+
133+
134+
def _results(event):
135+
data = json.loads(event.to_json())
136+
return {"results": {key: data.get(key, []) for key in ("Attribute", "Object")}}
137+
138+
139+
def handler(q=False):
140+
if q is False:
141+
return False
142+
try:
143+
request = json.loads(q)
144+
if not isinstance(request, dict):
145+
raise ModuleError("The request must be a JSON object.")
146+
attribute = request.get("attribute")
147+
if not isinstance(attribute, dict) or not all((attribute.get(k) for k in ("type", "value", "uuid"))):
148+
raise ModuleError("An attribute containing type, value and uuid is required.")
149+
if attribute["type"] not in mispattributes["input"]:
150+
raise ModuleError("Unsupported attribute type.")
151+
if not isinstance(attribute["value"], str) or not attribute["value"].strip():
152+
raise ModuleError("The attribute value must be a nonempty string.")
153+
config = request.get("config")
154+
if config is None:
155+
config = {}
156+
if not isinstance(config, dict):
157+
raise ModuleError("The config must be a JSON object.")
158+
for key in ["apikey"]:
159+
if not isinstance(config.get(key), str) or not config[key].strip():
160+
raise ModuleError(f"Missing {key} configuration.")
161+
event = MISPEvent()
162+
event.add_attribute(**attribute)
163+
return _expand(event, attribute, config)
164+
except ModuleError as exc:
165+
return {"error": str(exc)}
166+
except (ValueError, TypeError, KeyError, OverflowError, OSError, AttributeError, IndexError):
167+
return {"error": "Invalid request or service response."}
168+
169+
170+
def introspection():
171+
return mispattributes
172+
173+
174+
def version():
175+
return dict(moduleinfo, config=moduleconfig)
176+
177+
178+
def _payload(data):
179+
if not isinstance(data, dict) or data.get("success") is not True or "response" not in data:
180+
raise ModuleError("Proofpoint ET Intelligence rejected the lookup or returned an unexpected response.")
181+
return data["response"]
182+
183+
184+
def _expand(event, attribute, config):
185+
value, kind, limit = (_value(attribute), attribute["type"], _limit(config))
186+
headers = {"Authorization": config["apikey"]}
187+
if kind in ("ip-src", "ip-dst") and ip_address(value).version != 4:
188+
raise ModuleError("Proofpoint ET Intelligence requires an IPv4 address.")
189+
collection = "samples" if kind == "md5" else "ips" if kind in ("ip-src", "ip-dst") else "domains"
190+
base = "https://api.emergingthreats.net/v1/" + collection + "/" + quote(value, safe="")
191+
if kind == "md5":
192+
data = _request("GET", base, headers=headers)
193+
if data is None:
194+
return _results(event)
195+
detail = _payload(data)
196+
if not isinstance(detail, dict) or _hash("md5", detail.get("md5sum", "")) != value:
197+
raise ModuleError("Proofpoint ET Intelligence returned a different sample.")
198+
_annotation(event, attribute, "Proofpoint sample details", detail)
199+
_file(
200+
event,
201+
attribute,
202+
dict(detail, size=detail.get("file_size", detail.get("size"))),
203+
{"md5": "md5sum", "sha256": "sha256"},
204+
)
205+
return _results(event)
206+
for suffix in ("reputation", "domains" if collection == "ips" else "ips"):
207+
data = _request("GET", base + "/" + suffix, headers=headers)
208+
if data is None:
209+
continue
210+
rows = _payload(data)
211+
if not isinstance(rows, list) or not all((isinstance(row, dict) for row in rows)):
212+
raise ModuleError("Proofpoint ET Intelligence returned invalid results.")
213+
for row in _cap(event, attribute, rows, limit):
214+
_annotation(event, attribute, "Proofpoint " + suffix, row)
215+
if suffix == "domains" and row.get("domain"):
216+
event.add_attribute("domain", _domain(row["domain"]), to_ids=False, comment="Proofpoint passive DNS")
217+
if suffix == "ips" and row.get("ip"):
218+
event.add_attribute(
219+
"ip-dst", str(ip_address(row["ip"])), to_ids=False, comment="Proofpoint passive DNS"
220+
)
221+
return _results(event)

0 commit comments

Comments
 (0)