-
Notifications
You must be signed in to change notification settings - Fork 134
feat: Shodan host-lookup task (passive recon via the Shodan SDK) #1233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
1d952b8
9954da3
0c22981
13d489b
e02355a
efa0c45
1fe1309
3105971
6b7024d
aa09878
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import contextlib | ||
| import ipaddress | ||
| import os | ||
| import socket | ||
| import unittest.mock | ||
|
|
||
| from secator.config import CONFIG | ||
| from secator.decorators import task | ||
| from secator.definitions import HOST, IP | ||
| from secator.output_types import ( | ||
| Error, Ip, Port, Subdomain, Tag, Technology, Vulnerability, Warning | ||
| ) | ||
| from secator.runners import PythonRunner | ||
|
|
||
|
|
||
| @task() | ||
| class shodan(PythonRunner): | ||
| """Passive host recon via the Shodan API (ports, services, CVEs, hostnames).""" | ||
| input_types = [HOST, IP] | ||
| output_types = [Ip, Subdomain, Port, Technology, Vulnerability, Tag] | ||
| tags = ['shodan', 'recon', 'osint', 'passive'] | ||
| install_cmd = 'pip install shodan' | ||
| opts = { | ||
| # Empty default + runtime fallback (never a CONFIG default — it would leak | ||
| # the configured key into the secator-api UI form, like the `ai` task). | ||
| 'api_key': {'type': str, 'default': '', 'help': 'Shodan API key (defaults to configured key)'}, | ||
| 'history': {'is_flag': True, 'default': False, 'help': 'Include historical (non-current) banners'}, | ||
| 'minify': {'is_flag': True, 'default': False, 'help': 'Only ports + general host info (no banners)'}, | ||
| } | ||
|
Comment on lines
+16
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Use the repo’s This new tool is wired up as a As per coding guidelines, 🧰 Tools🪛 Ruff (0.15.20)[warning] 19-19: Mutable default value for class attribute (RUF012) [warning] 20-20: Mutable default value for class attribute (RUF012) [warning] 21-21: Mutable default value for class attribute (RUF012) [warning] 23-29: Mutable default value for class attribute (RUF012) 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| def yielder(self): | ||
| try: | ||
| import shodan as shodan_sdk | ||
| except ImportError: | ||
| yield Error(message="The 'shodan' package is not installed. Run: pip install shodan") | ||
| return | ||
|
|
||
| api_key = ( | ||
| self.get_opt_value('api_key') | ||
| or CONFIG.addons.shodan.api_key | ||
| or os.environ.get('SHODAN_API_KEY', '') | ||
| ) | ||
| if not api_key: | ||
| yield Error(message='Shodan API key not configured (set the api_key opt, ' | ||
| 'CONFIG.addons.shodan.api_key, or the SHODAN_API_KEY env var).') | ||
| return | ||
|
|
||
| api = shodan_sdk.Shodan(api_key) | ||
| history = self.get_opt_value('history') | ||
| minify = self.get_opt_value('minify') | ||
|
|
||
| for target in self.inputs: | ||
| ip, hostname = target, '' | ||
| if not self._is_ip(target): | ||
| hostname = target | ||
| try: | ||
| ip = socket.gethostbyname(target) | ||
| except (socket.gaierror, OSError) as e: | ||
| yield Error(message=f'Could not resolve {target}: {e}') | ||
| continue | ||
| try: | ||
| data = api.host(ip, history=history, minify=minify) | ||
| except shodan_sdk.APIError as e: | ||
| msg = str(e) | ||
| if 'No information available' in msg: | ||
| yield Warning(message=f'No Shodan data for {ip}') | ||
| else: | ||
| yield Error(message=f'Shodan API error for {ip}: {msg}') | ||
| continue | ||
| host = hostname or (data.get('hostnames') or [''])[0] | ||
| yield from self._map_host(data, ip, host) | ||
|
|
||
| def _map_host(self, h, ip, host): | ||
| ip_str = h.get('ip_str', ip) | ||
| yield Ip( | ||
| ip=ip_str, host=host, alive=True, | ||
| extra_data=self._compact({ | ||
| 'os': h.get('os'), 'org': h.get('org'), 'isp': h.get('isp'), | ||
| 'asn': h.get('asn'), 'country': h.get('country_name'), | ||
| }), | ||
| tags=['shodan'], | ||
| ) | ||
| seen = set() | ||
| for name in (h.get('hostnames') or []) + (h.get('domains') or []): | ||
| if name and name not in seen: | ||
| seen.add(name) | ||
| yield Subdomain(host=name, domain=self._registered_domain(name), sources=['shodan']) | ||
| for key, label in (('org', 'shodan_org'), ('isp', 'shodan_isp'), | ||
| ('asn', 'shodan_asn'), ('os', 'shodan_os')): | ||
| val = h.get(key) | ||
| if val: | ||
| yield Tag(name=label, value=str(val), match=ip_str, category='info', tags=['shodan']) | ||
| for cve in (h.get('vulns') or []): | ||
| yield Vulnerability(name=cve, id=cve, matched_at=ip_str, ip=ip_str, | ||
| provider='shodan', confidence='low', tags=['shodan']) | ||
|
Comment on lines
+199
to
+201
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve Shodan host vuln metadata 🧰 Tools🪛 Flake8 (7.3.0)[error] 95-95: continuation line under-indented for visual indent (E128) 🤖 Prompt for AI Agents |
||
| for b in (h.get('data') or []): | ||
| port = b.get('port') | ||
| try: | ||
| port = int(port) | ||
| except (TypeError, ValueError): | ||
| continue | ||
| yield Port( | ||
| port=port, ip=ip_str, host=host, state='open', | ||
| protocol=b.get('transport', 'tcp'), | ||
| service_name=b.get('product', '') or '', | ||
| cpes=b.get('cpe', []) or [], | ||
| confidence='low', service_confidence='low', | ||
| extra_data=self._compact({'version': b.get('version'), 'banner': self._excerpt(b.get('data'))}), | ||
| tags=['shodan'], | ||
| ) | ||
| product = b.get('product') | ||
| if product: | ||
| yield Technology( | ||
| product=product, match=f'{ip_str}:{port}', version=b.get('version'), | ||
| extra_data=self._compact({'cpe': b.get('cpe')}), tags=['shodan'], | ||
| ) | ||
| for cve, meta in (b.get('vulns') or {}).items(): | ||
| cvss = 0.0 | ||
| if isinstance(meta, dict) and meta.get('cvss') is not None: | ||
| try: | ||
| cvss = float(meta.get('cvss')) | ||
| except (TypeError, ValueError): | ||
| cvss = 0.0 | ||
| yield Vulnerability( | ||
| name=cve, id=cve, matched_at=f'{ip_str}:{port}', ip=ip_str, | ||
| provider='shodan', confidence='low', cvss_score=cvss, | ||
| description=(meta.get('summary', '') if isinstance(meta, dict) else ''), | ||
| tags=['shodan'], | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _is_ip(value): | ||
| try: | ||
| ipaddress.ip_address(value) | ||
| return True | ||
| except ValueError: | ||
| return False | ||
|
|
||
| @staticmethod | ||
| def _registered_domain(hostname): | ||
| parts = hostname.split('.') | ||
| return '.'.join(parts[-2:]) if len(parts) >= 2 else hostname | ||
|
|
||
| @staticmethod | ||
| def _excerpt(text, length=500): | ||
| return (text or '')[:length] | ||
|
|
||
| @staticmethod | ||
| def _compact(d): | ||
| return {k: v for k, v in d.items() if v not in (None, '', [], {})} | ||
|
|
||
| @classmethod | ||
| def get_mock_context(cls, fixture): | ||
| """Mock the Shodan SDK + DNS for the PythonRunner unit-test harness (no network).""" | ||
| @contextlib.contextmanager | ||
| def _ctx(): | ||
| mock_api = unittest.mock.MagicMock() | ||
| mock_api.host.return_value = fixture | ||
| patch_shodan = unittest.mock.patch('shodan.Shodan', return_value=mock_api) | ||
| patch_dns = unittest.mock.patch('socket.gethostbyname', return_value='10.0.0.1') | ||
| with patch_shodan, patch_dns: | ||
| yield | ||
| return _ctx() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "ip_str": "10.0.0.1", | ||
| "os": "Linux 3.x", | ||
| "org": "Example Org", | ||
| "isp": "Example ISP", | ||
| "asn": "AS65000", | ||
| "country_name": "Wonderland", | ||
| "hostnames": ["host1.example.com", "www.example.com"], | ||
| "domains": ["example.com"], | ||
| "vulns": ["CVE-2021-40438"], | ||
| "ports": [80, 443], | ||
| "data": [ | ||
| { | ||
| "port": 80, | ||
| "transport": "tcp", | ||
| "product": "Apache httpd", | ||
| "version": "2.4.49", | ||
| "cpe": ["cpe:/a:apache:http_server:2.4.49"], | ||
| "data": "HTTP/1.1 200 OK\r\nServer: Apache/2.4.49\r\n", | ||
| "vulns": { | ||
| "CVE-2021-41773": {"cvss": "7.5", "summary": "Path traversal in Apache 2.4.49"} | ||
| } | ||
| }, | ||
| { | ||
| "port": 443, | ||
| "transport": "tcp", | ||
| "product": "Apache httpd", | ||
| "version": "2.4.49", | ||
| "cpe": ["cpe:/a:apache:http_server:2.4.49"], | ||
| "data": "HTTP/1.1 200 OK\r\n" | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import json | ||
| import os | ||
| import unittest | ||
| import unittest.mock | ||
|
|
||
|
|
||
| class TestShodanConfig(unittest.TestCase): | ||
| def test_addon_defaults(self): | ||
| from secator.config import CONFIG | ||
| self.assertFalse(CONFIG.addons.shodan.enabled) | ||
| self.assertEqual(CONFIG.addons.shodan.api_key, '') | ||
|
|
||
|
|
||
| def _load_fixture(): | ||
| path = os.path.join(os.path.dirname(__file__), '..', 'fixtures', 'shodan_output.json') | ||
| with open(path) as f: | ||
| return json.load(f) | ||
|
|
||
|
|
||
| class TestShodanMapping(unittest.TestCase): | ||
| def _run(self): | ||
| from secator.output_types import Ip, Subdomain, Port, Technology, Vulnerability, Tag | ||
| from secator.tasks.shodan import shodan | ||
| task = shodan.__new__(shodan) | ||
| return list(task._map_host(_load_fixture(), '10.0.0.1', 'host1.example.com')) | ||
|
Comment on lines
+27
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Drop the unused Line 22 is unused and already trips Flake8 As per coding guidelines, 🧰 Tools🪛 Flake8 (7.3.0)[error] 22-22: 'secator.output_types.Ip' imported but unused (F401) [error] 22-22: 'secator.output_types.Subdomain' imported but unused (F401) [error] 22-22: 'secator.output_types.Port' imported but unused (F401) [error] 22-22: 'secator.output_types.Technology' imported but unused (F401) [error] 22-22: 'secator.output_types.Vulnerability' imported but unused (F401) [error] 22-22: 'secator.output_types.Tag' imported but unused (F401) 🤖 Prompt for AI AgentsSources: Coding guidelines, Linters/SAST tools |
||
|
|
||
| def test_emits_ip(self): | ||
| from secator.output_types import Ip | ||
| ips = [r for r in self._run() if isinstance(r, Ip)] | ||
| self.assertEqual(len(ips), 1) | ||
| self.assertEqual(ips[0].ip, '10.0.0.1') | ||
| self.assertTrue(ips[0].alive) | ||
| self.assertEqual(ips[0].extra_data.get('org'), 'Example Org') | ||
|
|
||
| def test_emits_subdomains_deduped(self): | ||
| from secator.output_types import Subdomain | ||
| subs = [r for r in self._run() if isinstance(r, Subdomain)] | ||
| hosts = sorted(s.host for s in subs) | ||
| self.assertEqual(hosts, ['example.com', 'host1.example.com', 'www.example.com']) | ||
|
|
||
| def test_emits_one_port_per_banner(self): | ||
| from secator.output_types import Port | ||
| ports = sorted(p.port for p in self._run() if isinstance(p, Port)) | ||
| self.assertEqual(ports, [80, 443]) | ||
|
|
||
| def test_ports_are_low_confidence(self): | ||
| from secator.output_types import Port | ||
| for p in [r for r in self._run() if isinstance(r, Port)]: | ||
| self.assertEqual(p.confidence, 'low') | ||
|
|
||
| def test_emits_technology_with_match_hostport(self): | ||
| from secator.output_types import Technology | ||
| techs = [r for r in self._run() if isinstance(r, Technology)] | ||
| self.assertTrue(any(t.product == 'Apache httpd' and t.match == '10.0.0.1:80' for t in techs)) | ||
|
|
||
| def test_emits_vulns_low_confidence(self): | ||
| from secator.output_types import Vulnerability | ||
| vulns = [r for r in self._run() if isinstance(r, Vulnerability)] | ||
| names = {v.name for v in vulns} | ||
| self.assertIn('CVE-2021-40438', names) # top-level | ||
| self.assertIn('CVE-2021-41773', names) # per-banner | ||
| for v in vulns: | ||
| self.assertEqual(v.confidence, 'low') | ||
| banner_vuln = next(v for v in vulns if v.name == 'CVE-2021-41773') | ||
| self.assertEqual(banner_vuln.cvss_score, 7.5) | ||
| self.assertEqual(banner_vuln.matched_at, '10.0.0.1:80') | ||
|
|
||
| def test_emits_tags_for_metadata(self): | ||
| from secator.output_types import Tag | ||
| tags = {t.name for t in self._run() if isinstance(t, Tag)} | ||
| self.assertEqual(tags, {'shodan_org', 'shodan_isp', 'shodan_asn', 'shodan_os'}) | ||
|
|
||
|
|
||
| class TestShodanErrorPaths(unittest.TestCase): | ||
| """Test yielder() error branches: missing key, no-data warning, generic API error.""" | ||
|
|
||
| def _make_task(self, **run_opts): | ||
| from secator.tasks.shodan import shodan | ||
| task = shodan.__new__(shodan) | ||
| task.run_opts = run_opts | ||
| task.inputs = ['10.0.0.1'] | ||
| return task | ||
|
|
||
| def test_missing_api_key_yields_single_error(self): | ||
| """No api_key opt, no config key, no env var → exactly one Error, no findings.""" | ||
| from secator.output_types import Error | ||
| task = self._make_task(api_key='') | ||
| mock_cfg = unittest.mock.MagicMock() | ||
| mock_cfg.addons.shodan.api_key = '' | ||
| env_without_key = {k: v for k, v in os.environ.items() if k != 'SHODAN_API_KEY'} | ||
| with unittest.mock.patch('secator.tasks.shodan.CONFIG', mock_cfg), \ | ||
| unittest.mock.patch.dict(os.environ, env_without_key, clear=True): | ||
| results = list(task.yielder()) | ||
| errors = [r for r in results if isinstance(r, Error)] | ||
| self.assertEqual(len(results), 1) | ||
| self.assertEqual(len(errors), 1) | ||
| self.assertIn('API key', errors[0].message) | ||
|
|
||
| def test_no_information_available_yields_warning_not_error(self): | ||
| """shodan.APIError('No information available...') → one Warning, zero Errors.""" | ||
| import shodan as shodan_sdk | ||
| from secator.output_types import Error, Warning | ||
| task = self._make_task(api_key='testkey') | ||
| mock_api = unittest.mock.MagicMock() | ||
| mock_api.host.side_effect = shodan_sdk.APIError('No information available for that IP.') | ||
| with unittest.mock.patch('shodan.Shodan', return_value=mock_api): | ||
| results = list(task.yielder()) | ||
| warnings = [r for r in results if isinstance(r, Warning)] | ||
| errors = [r for r in results if isinstance(r, Error)] | ||
| self.assertEqual(len(warnings), 1) | ||
| self.assertEqual(len(errors), 0) | ||
|
|
||
| def test_generic_api_error_yields_error(self): | ||
| """Non-'No information' shodan.APIError → one Error containing the message.""" | ||
| import shodan as shodan_sdk | ||
| from secator.output_types import Error, Warning | ||
| task = self._make_task(api_key='testkey') | ||
| mock_api = unittest.mock.MagicMock() | ||
| mock_api.host.side_effect = shodan_sdk.APIError('Invalid API key') | ||
| with unittest.mock.patch('shodan.Shodan', return_value=mock_api): | ||
| results = list(task.yielder()) | ||
| errors = [r for r in results if isinstance(r, Error)] | ||
| warnings = [r for r in results if isinstance(r, Warning)] | ||
| self.assertEqual(len(errors), 1) | ||
| self.assertEqual(len(warnings), 0) | ||
| self.assertIn('Invalid API key', errors[0].message) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: freelabz/secator
Length of output: 11136
Pin the Shodan install hint to match the dependency.
pyproject.tomlalready constrainsshodanto<2, butsecator/tasks/shodan.py:22still advertisespip install shodan, so the built-in install path can pull an unsupported release.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents