Skip to content

Commit ae8178c

Browse files
authored
Merge pull request #972 from MISP/codex/expansion-shodan-internetdb
Add Shodan InternetDB expansion module
2 parents 9fe6543 + 87772e9 commit ae8178c

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Shodan InternetDB
2+
3+
Look up exposed services, hostnames, CPEs and vulnerabilities in the free Shodan InternetDB API.
4+
5+
No API key is required. Supports IPv4 and IPv6. A 404 means no information is available. Hostnames and vulnerabilities are pivotable attributes; ports are grouped in an ip-port object. Service tags are retained as context, without automatically tagging the source as malicious.
6+
7+
Input: ip-src, ip-dst. Output: An ip-port object and hostname, CPE and vulnerability attributes.
8+
9+
Configuration: none. Required: none.
10+
Requests use a 30 second timeout and verify TLS certificates. Errors distinguish
11+
missing configuration, invalid input, authentication failures, rate limits and
12+
invalid responses. No API credentials are included in error messages.
13+
14+
All new enrichment attributes use `to_ids=false`. Source attributes retain their
15+
UUID and metadata. Returned objects reference the source attribute where possible.
16+
Empty lookups return the source attribute without fabricated enrichment.
17+
18+
References:
19+
20+
- https://internetdb.shodan.io/
21+
22+
Example request:
23+
24+
```json
25+
{
26+
"module": "shodan_internetdb",
27+
"attribute": {
28+
"type": "ip-dst",
29+
"value": "1.1.1.1",
30+
"uuid": "32876e4a-916d-4935-9e51-7b69a76718f8"
31+
},
32+
"config": {}
33+
}
34+
```
35+
36+
Validation: `pytest tests/test_shodan_internetdb.py` uses mocked service responses;
37+
no paid API credentials are needed.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import json
2+
from ipaddress import ip_address
3+
from urllib.parse import quote
4+
5+
import requests
6+
from pymisp import MISPEvent, MISPObject
7+
8+
misperrors = {"error": "Error"}
9+
mispattributes = {"input": ["ip-src", "ip-dst"], "format": "misp_standard"}
10+
moduleconfig = []
11+
moduleinfo = {
12+
"version": "1",
13+
"author": "MISP Project",
14+
"name": "Shodan InternetDB",
15+
"description": "Look up exposed services, hostnames, CPEs and vulnerabilities in the free Shodan InternetDB API.",
16+
"module-type": ["expansion"],
17+
"logo": "",
18+
"requirements": ["requests"],
19+
"features": (
20+
"No API key is required. Supports IPv4 and IPv6. A 404 means no information is available. Hostnames and"
21+
" vulnerabilities are pivotable attributes; ports are grouped in an ip-port object. Service tags are retained"
22+
" as context, without automatically tagging the source as malicious."
23+
),
24+
"references": ["https://internetdb.shodan.io/"],
25+
"input": "ip-src, ip-dst MISP attribute.",
26+
"output": "An ip-port object and hostname, CPE and vulnerability attributes.",
27+
}
28+
TIMEOUT = 30
29+
30+
31+
class ModuleError(ValueError):
32+
"""An actionable module error safe to return to the caller."""
33+
34+
35+
def _get(url, **kwargs):
36+
try:
37+
response = requests.get(url, timeout=TIMEOUT, allow_redirects=False, **kwargs)
38+
except requests.RequestException:
39+
raise ModuleError("The service request failed or timed out.") from None
40+
if response.status_code == 404:
41+
return None
42+
if response.status_code == 204:
43+
return None
44+
if response.status_code != 200:
45+
raise ModuleError(f"The service returned HTTP {response.status_code}.")
46+
try:
47+
return response.json()
48+
except ValueError:
49+
raise ModuleError("The service returned invalid JSON.") from None
50+
51+
52+
def _ip(value):
53+
return str(ip_address(value))
54+
55+
56+
def _annotation(event, attribute, title, data):
57+
obj = MISPObject("annotation")
58+
obj.comment = title
59+
obj.add_attribute("text", value=json.dumps(data, sort_keys=True, indent=2), to_ids=False, disable_correlation=True)
60+
obj.add_reference(attribute["uuid"], "describes")
61+
event.add_object(obj)
62+
63+
64+
def _results(event):
65+
data = json.loads(event.to_json())
66+
return {"results": {key: data.get(key, []) for key in ("Attribute", "Object")}}
67+
68+
69+
def handler(q=False):
70+
if q is False:
71+
return False
72+
try:
73+
request = json.loads(q)
74+
if not isinstance(request, dict):
75+
raise ModuleError("The request must be a JSON object.")
76+
attribute = request.get("attribute")
77+
if not isinstance(attribute, dict) or not all((attribute.get(k) for k in ("type", "value", "uuid"))):
78+
raise ModuleError("An attribute containing type, value and uuid is required.")
79+
if attribute["type"] not in mispattributes["input"]:
80+
raise ModuleError("Unsupported attribute type.")
81+
if not isinstance(attribute["value"], str) or not attribute["value"].strip():
82+
raise ModuleError("The attribute value must be a nonempty string.")
83+
config = request.get("config") or {}
84+
if not isinstance(config, dict):
85+
raise ModuleError("The config must be a JSON object.")
86+
event = MISPEvent()
87+
event.add_attribute(**attribute)
88+
return _expand(event, attribute, config)
89+
except ModuleError as exc:
90+
return {"error": str(exc)}
91+
except (ValueError, TypeError, KeyError, OverflowError, OSError):
92+
return {"error": "Invalid request or service response."}
93+
94+
95+
def introspection():
96+
return mispattributes
97+
98+
99+
def version():
100+
return dict(moduleinfo, config=moduleconfig)
101+
102+
103+
def _expand(event, attribute, config):
104+
address = _ip(attribute["value"].strip())
105+
data = _get("https://internetdb.shodan.io/" + quote(address, safe=""))
106+
if data is None:
107+
return _results(event)
108+
if not isinstance(data, dict) or _ip(data.get("ip", "")) != address:
109+
raise ModuleError("InternetDB returned an unexpected IP address or response.")
110+
for key in ("ports", "hostnames", "cpes", "vulns", "tags"):
111+
if not isinstance(data.get(key, []), list):
112+
raise ModuleError("InternetDB returned an unexpected response.")
113+
if data.get("ports"):
114+
obj = MISPObject("ip-port")
115+
obj.add_attribute(attribute["type"], value=address, to_ids=False)
116+
relation = attribute["type"].replace("ip-", "") + "-port"
117+
for port in dict.fromkeys(data["ports"]):
118+
if isinstance(port, bool) or not isinstance(port, int) or (not 0 <= port <= 65535):
119+
raise ModuleError("InternetDB returned an invalid port.")
120+
obj.add_attribute(relation, value=port, to_ids=False)
121+
obj.add_reference(attribute["uuid"], "describes")
122+
event.add_object(obj)
123+
for key, kind in (("hostnames", "hostname"), ("cpes", "cpe"), ("vulns", "vulnerability")):
124+
for value in dict.fromkeys(data.get(key, [])):
125+
if not isinstance(value, str) or not value:
126+
raise ModuleError("InternetDB returned an invalid value.")
127+
event.add_attribute(kind, value, to_ids=False, comment="Shodan InternetDB")
128+
if data.get("tags"):
129+
_annotation(event, attribute, "Shodan InternetDB service tags", {"tags": data["tags"]})
130+
return _results(event)

‎tests/test_shodan_internetdb.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import json
2+
from unittest.mock import Mock, patch
3+
4+
import pytest
5+
import requests
6+
7+
from misp_modules.modules.expansion import shodan_internetdb as module
8+
9+
UUID = "32876e4a-916d-4935-9e51-7b69a76718f8"
10+
SECRET = "test-api-secret"
11+
12+
13+
def query(kind="ip-dst", value="1.1.1.1", config=None):
14+
return json.dumps(
15+
{
16+
"attribute": {"type": kind, "value": value, "uuid": UUID, "to_ids": True, "comment": "source metadata"},
17+
"config": {} if config is None else config,
18+
}
19+
)
20+
21+
22+
def response(payload=None, status=200):
23+
result = Mock(status_code=status, headers={})
24+
result.json.return_value = payload
25+
result.iter_content.return_value = iter([b"sample bytes"])
26+
return result
27+
28+
29+
def handle(payload, **kwargs):
30+
with patch.object(module.requests, "get", return_value=response(payload)) as get:
31+
result = module.handler(query(**kwargs))
32+
return result, get
33+
34+
35+
def objects(result, name):
36+
return [obj for obj in result["results"]["Object"] if obj["name"] == name]
37+
38+
39+
def values(obj):
40+
return {item["object_relation"]: item["value"] for item in obj["Attribute"]}
41+
42+
43+
def assert_source(result):
44+
source = next(item for item in result["results"]["Attribute"] if item["uuid"] == UUID)
45+
assert source["to_ids"] is True
46+
assert source["comment"] == "source metadata"
47+
for obj in result["results"]["Object"]:
48+
assert any(ref["referenced_uuid"] == UUID for ref in obj["ObjectReference"])
49+
assert all(item["to_ids"] is False for item in obj["Attribute"])
50+
assert all(item["to_ids"] is False for item in result["results"]["Attribute"] if item["uuid"] != UUID)
51+
52+
53+
def test_discovery_contract():
54+
assert module.handler(False) is False
55+
assert module.introspection()["format"] == "misp_standard"
56+
assert module.version()["config"] == module.moduleconfig
57+
assert "expansion" in module.version()["module-type"]
58+
59+
60+
@pytest.mark.parametrize(
61+
"raw", ["{", "[]", "null", "{}", '{"attribute": []}', '{"attribute":{"type":"ip-dst","value":"1.1.1.1"}}']
62+
)
63+
def test_invalid_request_never_calls_service(raw):
64+
with patch.object(module.requests, "get") as get:
65+
assert "error" in module.handler(raw)
66+
get.assert_not_called()
67+
68+
69+
def test_unsupported_type_never_calls_service():
70+
with patch.object(module.requests, "get") as get:
71+
assert "error" in module.handler(query(kind="comment"))
72+
get.assert_not_called()
73+
74+
75+
@pytest.mark.parametrize("value", ["", " ", 123, ["example.com"]])
76+
def test_invalid_values_never_call_service(value):
77+
with patch.object(module.requests, "get") as get:
78+
assert "error" in module.handler(query(value=value))
79+
get.assert_not_called()
80+
81+
82+
@pytest.mark.parametrize("status", [401, 403, 429, 500, 302])
83+
def test_http_failures_are_reported(status):
84+
with patch.object(module.requests, "get", return_value=response(status=status)):
85+
result = module.handler(query())
86+
assert str(status) in result["error"]
87+
assert SECRET not in result["error"]
88+
89+
90+
@pytest.mark.parametrize("failure", [requests.Timeout, requests.ConnectionError])
91+
def test_network_failures_do_not_leak_credentials(failure):
92+
with patch.object(module.requests, "get", side_effect=failure("url?apikey=" + SECRET)):
93+
result = module.handler(query())
94+
assert "error" in result
95+
assert SECRET not in result["error"]
96+
97+
98+
@pytest.mark.parametrize("status", [404, 204])
99+
def test_empty_service_response_preserves_source(status):
100+
with patch.object(module.requests, "get", return_value=response(status=status)):
101+
result = module.handler(query())
102+
assert_source(result)
103+
assert result["results"]["Object"] == []
104+
105+
106+
def test_invalid_service_json_is_reported():
107+
reply = response()
108+
reply.json.side_effect = ValueError("invalid JSON " + SECRET)
109+
with patch.object(module.requests, "get", return_value=reply):
110+
result = module.handler(query())
111+
assert "error" in result
112+
assert SECRET not in result["error"]
113+
114+
115+
@pytest.mark.parametrize("payload", [[], "wrong", 1])
116+
def test_unexpected_service_shape_is_reported(payload):
117+
result, _ = handle(payload)
118+
assert "error" in result
119+
120+
121+
PAYLOAD = {
122+
"ip": "1.1.1.1",
123+
"ports": [53, 443, 443],
124+
"hostnames": ["one.one.one.one"],
125+
"cpes": ["cpe:/a:example:server:1"],
126+
"vulns": ["CVE-2025-1234"],
127+
"tags": ["cdn"],
128+
}
129+
130+
131+
def test_host_intelligence_is_pivotable_and_referenced():
132+
result, get = handle(PAYLOAD)
133+
assert_source(result)
134+
obj = objects(result, "ip-port")[0]
135+
assert values(obj)["ip-dst"] == "1.1.1.1"
136+
assert [x["value"] for x in obj["Attribute"] if x["object_relation"] == "dst-port"] == ["53", "443"]
137+
assert {x["type"] for x in result["results"]["Attribute"]} == {"ip-dst", "hostname", "cpe", "vulnerability"}
138+
assert json.loads(values(objects(result, "annotation")[0])["text"])["tags"] == ["cdn"]
139+
get.assert_called_once_with("https://internetdb.shodan.io/1.1.1.1", timeout=30, allow_redirects=False)
140+
141+
142+
def test_ipv6_source_port_direction():
143+
payload = dict(PAYLOAD, ip="2001:4860:4860::8888")
144+
result, get = handle(payload, kind="ip-src", value=payload["ip"])
145+
assert "src-port" in values(objects(result, "ip-port")[0])
146+
assert "%3A" in get.call_args.args[0]
147+
148+
149+
@pytest.mark.parametrize("changes", [{"ip": "8.8.8.8"}, {"ports": "443"}, {"ports": [65536]}, {"ports": [True]}])
150+
def test_mismatched_or_invalid_results(changes):
151+
result, _ = handle(dict(PAYLOAD, **changes))
152+
assert "error" in result

0 commit comments

Comments
 (0)