From b5f1254ee8130cb8aa3e7ebcd2d7bc6dbef9a5b6 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Thu, 25 Jun 2026 23:06:44 +0200 Subject: [PATCH] feat(shodan): add Shodan threat-intel integration Host enrichment, search/count, DNS resolve/reverse, domain info, api-info and scan status as form-based GET commands; active scan is code-first (form-encoded POST) and marked destructive. API key sent as the `key` query parameter. Co-Authored-By: Claude Opus 4.8 --- integrations/shodan/manifest.yaml | 170 ++++++++++++++++++++++++++++ integrations/shodan/scripts/scan.py | 39 +++++++ 2 files changed, 209 insertions(+) create mode 100644 integrations/shodan/manifest.yaml create mode 100644 integrations/shodan/scripts/scan.py diff --git a/integrations/shodan/manifest.yaml b/integrations/shodan/manifest.yaml new file mode 100644 index 0000000..22e55e5 --- /dev/null +++ b/integrations/shodan/manifest.yaml @@ -0,0 +1,170 @@ +id: shodan +name: Shodan +version: 1.0.0 +description: "Shodan — host & network intelligence: IP enrichment, search, DNS lookups, domain info, and on-demand scanning." +changelog: "1.0.0 — Initial release: host lookup, search, count, DNS resolve/reverse, domain info, api-info, scan status, and active scan." +category: enrichment + +config_schema: + properties: + base_url: + type: string + description: Shodan REST API base URL + default: https://api.shodan.io + api_key: + type: string + description: Shodan API key (Account → API key) + x-soar-sensitive: true + required: + - api_key + +# Shodan authenticates with the API key as the `key` query parameter on every request. +auth: + - id: apikey + type: api_key + in: query + name: key + value_template: "{{secret}}" + secret_field: api_key + +commands: + # ── Enrichment / read ───────────────────────────────────────────────────── + - id: host_lookup + name: shodan-host-lookup + description: All services Shodan has observed on an IP (open ports, banners, vulns, org, location). + risk: read + inputs_schema: + properties: + ip: { type: string, description: "IPv4 or IPv6 address" } + history: { type: string, description: "Set to true to include historical banners" } + minify: { type: string, description: "Set to true for a slimmed-down result" } + required: [ip] + outputs_schema: { properties: {} } + request: + method: GET + path: /shodan/host/{ip} + query: [history, minify] + auth_ref: apikey + + - id: search + name: shodan-search + description: Search the Shodan database for hosts matching a query (consumes query credits). + risk: read + inputs_schema: + properties: + query: { type: string, description: "Shodan search query, e.g. 'apache port:443 country:FR'" } + facets: { type: string, description: "Comma-separated facets for a summary, e.g. 'country,org'" } + page: { type: number, description: "Result page (100 results per page, 1-based)" } + required: [query] + outputs_schema: { properties: {} } + request: + method: GET + path: /shodan/host/search + query: [query, facets, page] + auth_ref: apikey + + - id: host_count + name: shodan-host-count + description: Count results for a query without returning hosts (does not consume query credits). + risk: read + inputs_schema: + properties: + query: { type: string, description: "Shodan search query" } + facets: { type: string, description: "Comma-separated facets for a summary" } + required: [query] + outputs_schema: { properties: {} } + request: + method: GET + path: /shodan/host/count + query: [query, facets] + auth_ref: apikey + + - id: dns_resolve + name: shodan-dns-resolve + description: Resolve hostnames to IP addresses. + risk: read + inputs_schema: + properties: + hostnames: { type: string, description: "Comma-separated hostnames, e.g. 'google.com,example.com'" } + required: [hostnames] + outputs_schema: { properties: {} } + request: + method: GET + path: /dns/resolve + query: [hostnames] + auth_ref: apikey + + - id: dns_reverse + name: shodan-dns-reverse + description: Reverse-DNS lookup for IP addresses. + risk: read + inputs_schema: + properties: + ips: { type: string, description: "Comma-separated IP addresses" } + required: [ips] + outputs_schema: { properties: {} } + request: + method: GET + path: /dns/reverse + query: [ips] + auth_ref: apikey + + - id: domain_info + name: shodan-domain-info + description: Subdomains and DNS records Shodan has gathered for a domain. + risk: read + inputs_schema: + properties: + domain: { type: string, description: "Domain, e.g. 'example.com'" } + type: { type: string, description: "Filter by DNS record type, e.g. 'A', 'MX'" } + page: { type: number, description: "Result page (1-based)" } + required: [domain] + outputs_schema: { properties: {} } + request: + method: GET + path: /dns/domain/{domain} + query: [type, page] + auth_ref: apikey + + - id: api_info + name: shodan-api-info + description: Plan, remaining query/scan credits and usage limits for the API key (use as a connectivity test). + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + request: + method: GET + path: /api-info + auth_ref: apikey + + - id: scan_status + name: shodan-scan-status + description: Check the progress of an on-demand scan by its id. + risk: read + inputs_schema: + properties: + id: { type: string, description: "Scan id returned by shodan-scan" } + required: [id] + outputs_schema: { properties: {} } + request: + method: GET + path: /shodan/scan/{id} + auth_ref: apikey + + # ── Active action ───────────────────────────────────────────────────────── + # Code-first (scripts/scan.py): Shodan's scan endpoint requires an + # application/x-www-form-urlencoded body, which the form-based generator + # (JSON body) cannot produce. Marked destructive: it triggers an active, + # outbound crawl of the target and consumes scan credits. + - id: scan + name: shodan-scan + description: Request Shodan to actively crawl an IP or netblock (consumes scan credits). + risk: destructive + inputs_schema: + properties: + ips: { type: string, description: "Comma-separated IPs or CIDR netblocks to scan" } + service: { type: string, description: "Optional advanced service list, e.g. '22/tcp'" } + required: [ips] + outputs_schema: { properties: {} } diff --git a/integrations/shodan/scripts/scan.py b/integrations/shodan/scripts/scan.py new file mode 100644 index 0000000..7ca89e3 --- /dev/null +++ b/integrations/shodan/scripts/scan.py @@ -0,0 +1,39 @@ +import json, os, sys, urllib.request, urllib.parse, urllib.error + + +def main(): + secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + base = secrets.get("base_url", "https://api.shodan.io").rstrip("/") + key = secrets.get("api_key", "") + + ips = str(inputs.get("ips", "")).strip() + if not ips: + print(json.dumps({"error": "ips is required"})) + sys.exit(1) + + # Shodan's POST /shodan/scan expects an application/x-www-form-urlencoded body. + form = {"ips": ips} + if inputs.get("service"): + form["service"] = str(inputs["service"]) + + url = base + "/shodan/scan?" + urllib.parse.urlencode({"key": key}) + data = urllib.parse.urlencode(form).encode("utf-8") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + print(json.dumps(json.loads(raw) if raw else {})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)