Skip to content

Commit 93d1ff2

Browse files
LuxVTZclaude
andcommitted
feat: v1.3.0 — Censys, ZoomEye, FOFA, GreyNoise OSINT integration
4 new internet intelligence APIs, all following TDD/SDD methodology: Censys (search.censys.io/api/v2): - censys_lookup(): host services, labels, last_updated - censys_search(): query → list of IPs for bulk scanning - Auth: HTTP Basic (ARGUS_CENSYS_ID + ARGUS_CENSYS_SECRET) ZoomEye (api.zoomeye.org): - zoomeye_lookup(): host info, port/service/geo data - zoomeye_search(): dork → IPs - Auth: JWT token header (ARGUS_ZOOMEYE_KEY) FOFA (fofa.info): - fofa_lookup(): domain/IP → hosts with port/protocol/country/product - fofa_search(): arbitrary query → IPs - Auth: email + API key, query as base64 (ARGUS_FOFA_EMAIL + ARGUS_FOFA_KEY) GreyNoise (api.greynoise.io/v3): - greynoise_lookup(): IP reputation — noise/riot/classification/name - Community endpoint free (no key), Enterprise with ARGUS_GREYNOISE_KEY - Shows if IP is known scanner, botnet, or benign CDN/DNS All 4 APIs run in orchestrator Group 0 (OSINT parallel) alongside Shodan/VT. HTML report: new "OSINT Intelligence" section with GreyNoise badge, Censys services, ZoomEye/FOFA tables. argus bulk now supports: --censys "services.port:443 AND labels:cloud" --zoomeye "hostname:example.com" --fofa 'domain="example.com"' Config: 6 new api_keys fields + env overrides Tests: 33 new tests (8 Censys + 7 ZoomEye + 9 FOFA + 8 GreyNoise) Total: 524 tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e80b39b commit 93d1ff2

14 files changed

Lines changed: 941 additions & 18 deletions

File tree

src/argus_lite/cli.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,10 @@ def run_template(ctx: click.Context, template_path: str, target: str | None) ->
323323

324324
@main.command("bulk")
325325
@click.argument("sources", nargs=-1)
326-
@click.option("--shodan", "shodan_query", default=None, help="Shodan search query (requires ARGUS_SHODAN_KEY)")
326+
@click.option("--shodan", "shodan_query", default=None, help="Shodan query (requires ARGUS_SHODAN_KEY)")
327+
@click.option("--censys", "censys_query", default=None, help="Censys query (requires ARGUS_CENSYS_ID + ARGUS_CENSYS_SECRET)")
328+
@click.option("--zoomeye", "zoomeye_query", default=None, help="ZoomEye dork (requires ARGUS_ZOOMEYE_KEY)")
329+
@click.option("--fofa", "fofa_query", default=None, help="FOFA query (requires ARGUS_FOFA_EMAIL + ARGUS_FOFA_KEY)")
327330
@click.option("--preset", type=click.Choice(["bulk", "quick", "web", "full", "recon"]), default="bulk",
328331
help="Scan preset for each target")
329332
@click.option("--concurrency", type=int, default=5, help="Max parallel scans")
@@ -333,21 +336,27 @@ def run_template(ctx: click.Context, template_path: str, target: str | None) ->
333336
def bulk_scan(
334337
sources: tuple[str, ...],
335338
shodan_query: str | None,
339+
censys_query: str | None,
340+
zoomeye_query: str | None,
341+
fofa_query: str | None,
336342
preset: str,
337343
concurrency: int,
338344
max_targets: int,
339345
output_format: str,
340346
no_confirm: bool,
341347
) -> None:
342-
"""Bulk scan multiple targets: files, CIDRs, ASNs, or Shodan queries.
348+
"""Bulk scan multiple targets: files, CIDRs, ASNs, or OSINT queries.
343349
344350
\b
345351
Examples:
346352
argus bulk targets.txt
347353
argus bulk 192.168.1.0/24
348354
argus bulk AS12345
349355
argus bulk targets.txt 10.0.1.0/24 --preset web
350-
argus bulk --shodan "org:MyCompany" --concurrency 3
356+
argus bulk --shodan "org:MyCompany"
357+
argus bulk --censys "services.port:443 AND labels:cloud"
358+
argus bulk --zoomeye "hostname:example.com"
359+
argus bulk --fofa 'domain="example.com"'
351360
352361
Generates individual reports per target + a combined summary.html.
353362
IMPORTANT: Only scan systems you have written permission to test.
@@ -371,25 +380,34 @@ def bulk_scan(
371380
config.bulk.max_concurrent = concurrency
372381
config.bulk.max_targets = max_targets
373382

374-
# Build source list
375-
all_sources = list(sources)
376-
if shodan_query:
377-
# Shodan handled via expand_shodan() separately
378-
all_sources_for_expand = list(sources)
379-
else:
380-
all_sources_for_expand = all_sources
383+
has_query = any([shodan_query, censys_query, zoomeye_query, fofa_query])
381384

382-
if not all_sources_for_expand and not shodan_query:
385+
if not sources and not has_query:
383386
console.print("[red]No sources provided. Use positional args or --shodan.[/red]")
384387
raise SystemExit(1)
385388

386389
# Expand targets
387390
expander = TargetExpander(config)
388-
if shodan_query:
389-
targets = asyncio.get_event_loop().run_until_complete(expander.expand_shodan(shodan_query))
391+
targets: list[str] = []
392+
393+
# Expand positional sources (file, CIDR, ASN, plain hosts)
394+
if sources:
390395
targets += asyncio.get_event_loop().run_until_complete(expander.expand(list(sources)))
391-
else:
392-
targets = asyncio.get_event_loop().run_until_complete(expander.expand(all_sources_for_expand))
396+
397+
# Expand OSINT queries
398+
loop = asyncio.get_event_loop()
399+
if shodan_query:
400+
console.print(f"[dim]Querying Shodan: {shodan_query}[/dim]")
401+
targets += loop.run_until_complete(expander.expand_shodan(shodan_query))
402+
if censys_query:
403+
console.print(f"[dim]Querying Censys: {censys_query}[/dim]")
404+
targets += loop.run_until_complete(expander.expand_censys(censys_query))
405+
if zoomeye_query:
406+
console.print(f"[dim]Querying ZoomEye: {zoomeye_query}[/dim]")
407+
targets += loop.run_until_complete(expander.expand_zoomeye(zoomeye_query))
408+
if fofa_query:
409+
console.print(f"[dim]Querying FOFA: {fofa_query}[/dim]")
410+
targets += loop.run_until_complete(expander.expand_fofa(fofa_query))
393411

394412
# Deduplicate & cap
395413
seen: set[str] = set()

src/argus_lite/core/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ class ApiKeysConfig(BaseModel):
7979
shodan: str = ""
8080
virustotal: str = ""
8181
nvd_api_key: str = ""
82+
censys_api_id: str = ""
83+
censys_api_secret: str = ""
84+
zoomeye_api_key: str = ""
85+
fofa_email: str = ""
86+
fofa_api_key: str = ""
87+
greynoise_api_key: str = ""
8288

8389

8490
class NotificationConfig(BaseModel):
@@ -163,6 +169,18 @@ def _apply_env_overrides(config: AppConfig) -> None:
163169
if nvd_key:
164170
config.api_keys.nvd_api_key = nvd_key
165171

172+
for env_name, attr in [
173+
("ARGUS_CENSYS_ID", "censys_api_id"),
174+
("ARGUS_CENSYS_SECRET", "censys_api_secret"),
175+
("ARGUS_ZOOMEYE_KEY", "zoomeye_api_key"),
176+
("ARGUS_FOFA_EMAIL", "fofa_email"),
177+
("ARGUS_FOFA_KEY", "fofa_api_key"),
178+
("ARGUS_GREYNOISE_KEY", "greynoise_api_key"),
179+
]:
180+
val = os.environ.get(env_name)
181+
if val:
182+
setattr(config.api_keys, attr, val)
183+
166184
for env_name, attr in [
167185
("ARGUS_TELEGRAM_TOKEN", "telegram_token"),
168186
("ARGUS_TELEGRAM_CHAT_ID", "telegram_chat_id"),

src/argus_lite/core/orchestrator.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,14 @@ async def _run_subtask(self, name: str, coro) -> None:
174174
self._on_progress(name, "fail")
175175

176176
async def _run_recon(self) -> None:
177+
from argus_lite.modules.recon.censys_api import censys_lookup
177178
from argus_lite.modules.recon.certificates import certificate_info
178179
from argus_lite.modules.recon.dns import dns_enumerate
179180
from argus_lite.modules.recon.dnsx_resolve import parse_dnsx_output
181+
from argus_lite.modules.recon.fofa_api import fofa_lookup
180182
from argus_lite.modules.recon.gau_urls import gau_discover
181183
from argus_lite.modules.recon.gowitness import gowitness_capture
184+
from argus_lite.modules.recon.greynoise_api import greynoise_lookup
182185
from argus_lite.modules.recon.httpx_probe import httpx_probe, httpx_probe_multi
183186
from argus_lite.modules.recon.katana_crawl import katana_crawl
184187
from argus_lite.modules.recon.securitytrails_api import st_lookup
@@ -187,8 +190,9 @@ async def _run_recon(self) -> None:
187190
from argus_lite.modules.recon.tlsx_certs import tlsx_scan
188191
from argus_lite.modules.recon.virustotal_api import vt_lookup
189192
from argus_lite.modules.recon.whois import whois_lookup
193+
from argus_lite.modules.recon.zoomeye_api import zoomeye_lookup
190194

191-
# Group 0: OSINT APIs (no tools needed, run in parallel, fire-and-forget)
195+
# Group 0: OSINT APIs (no tools needed, run in parallel)
192196
api_tasks = []
193197
api_keys = self.config.api_keys
194198

@@ -204,14 +208,40 @@ async def do_vt():
204208
self._tools_used.append("virustotal-api")
205209
api_tasks.append(do_vt())
206210

211+
if api_keys.censys_api_id and api_keys.censys_api_secret:
212+
async def do_censys():
213+
self._recon_result.censys_info = await censys_lookup(
214+
self.target, api_id=api_keys.censys_api_id, api_secret=api_keys.censys_api_secret)
215+
self._tools_used.append("censys-api")
216+
api_tasks.append(do_censys())
217+
218+
if api_keys.zoomeye_api_key:
219+
async def do_zoomeye():
220+
self._recon_result.zoomeye_info = await zoomeye_lookup(
221+
self.target, api_key=api_keys.zoomeye_api_key)
222+
self._tools_used.append("zoomeye-api")
223+
api_tasks.append(do_zoomeye())
224+
225+
if api_keys.fofa_email and api_keys.fofa_api_key:
226+
async def do_fofa():
227+
self._recon_result.fofa_info = await fofa_lookup(
228+
self.target, email=api_keys.fofa_email, api_key=api_keys.fofa_api_key)
229+
self._tools_used.append("fofa-api")
230+
api_tasks.append(do_fofa())
231+
232+
# GreyNoise: enriches the main target IP (works even without API key via community endpoint)
233+
async def do_greynoise():
234+
self._recon_result.greynoise_info = await greynoise_lookup(
235+
self.target, api_key=api_keys.greynoise_api_key)
236+
self._tools_used.append("greynoise-api")
237+
api_tasks.append(do_greynoise())
238+
207239
async def do_st():
208-
# SecurityTrails uses env var ARGUS_SECURITYTRAILS_KEY
209240
import os
210241
st_key = os.environ.get("ARGUS_SECURITYTRAILS_KEY", "")
211242
if st_key:
212243
self._recon_result.securitytrails_info = await st_lookup(self.target, st_key)
213244
self._tools_used.append("securitytrails-api")
214-
215245
api_tasks.append(do_st())
216246

217247
if api_tasks:

src/argus_lite/core/target_expander.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,41 @@ async def expand_shodan(self, query: str) -> list[str]:
6767
"""Expand a Shodan search query → list of IP addresses."""
6868
return await self._expand_shodan(query)
6969

70+
async def expand_censys(self, query: str) -> list[str]:
71+
"""Expand a Censys search query → list of IP addresses."""
72+
from argus_lite.modules.recon.censys_api import censys_search
73+
keys = self._config.api_keys
74+
if not keys.censys_api_id or not keys.censys_api_secret:
75+
logger.warning("Censys search requires ARGUS_CENSYS_ID + ARGUS_CENSYS_SECRET")
76+
return []
77+
results = await censys_search(query, api_id=keys.censys_api_id,
78+
api_secret=keys.censys_api_secret,
79+
max_results=self._max_targets)
80+
return results[:self._max_targets]
81+
82+
async def expand_zoomeye(self, query: str) -> list[str]:
83+
"""Expand a ZoomEye dork → list of IP addresses."""
84+
from argus_lite.modules.recon.zoomeye_api import zoomeye_search
85+
api_key = self._config.api_keys.zoomeye_api_key
86+
if not api_key:
87+
logger.warning("ZoomEye search requires ARGUS_ZOOMEYE_KEY")
88+
return []
89+
results = await zoomeye_search(query, api_key=api_key,
90+
max_results=self._max_targets)
91+
return results[:self._max_targets]
92+
93+
async def expand_fofa(self, query: str) -> list[str]:
94+
"""Expand a FOFA query → list of IP addresses."""
95+
from argus_lite.modules.recon.fofa_api import fofa_search
96+
keys = self._config.api_keys
97+
if not keys.fofa_email or not keys.fofa_api_key:
98+
logger.warning("FOFA search requires ARGUS_FOFA_EMAIL + ARGUS_FOFA_KEY")
99+
return []
100+
results = await fofa_search(query, email=keys.fofa_email,
101+
api_key=keys.fofa_api_key,
102+
max_results=self._max_targets)
103+
return results[:self._max_targets]
104+
70105
# ------------------------------------------------------------------
71106
# Source type detection
72107
# ------------------------------------------------------------------

src/argus_lite/models/recon.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,41 @@ class Screenshot(BaseModel):
116116
response_time_ms: int = 0
117117

118118

119+
class CensysServiceInfo(BaseModel):
120+
port: int = 0
121+
transport: str = ""
122+
service_name: str = ""
123+
banner: str = ""
124+
125+
126+
class CensysHostInfo(BaseModel):
127+
ip: str = ""
128+
services: list[CensysServiceInfo] = []
129+
labels: list[str] = []
130+
last_updated: str = ""
131+
total_results: int = 0
132+
133+
134+
class ZoomEyeHostInfo(BaseModel):
135+
total: int = 0
136+
matches: list[dict] = [] # ip, portinfo.port, geoinfo.country/city
137+
138+
139+
class FofaHostInfo(BaseModel):
140+
total: int = 0
141+
results: list[dict] = [] # ip, port, protocol, country, city, product
142+
143+
144+
class GreyNoiseInfo(BaseModel):
145+
ip: str = ""
146+
noise: bool = False # True = observed scanning the internet
147+
riot: bool = False # True = known benign service (CDN, DNS, etc.)
148+
classification: str = "" # benign | malicious | unknown
149+
name: str = "" # e.g. "Cloudflare", "Google Public DNS"
150+
last_seen: str = ""
151+
message: str = ""
152+
153+
119154
class ReconResult(BaseModel):
120155
"""Aggregated result from recon module."""
121156

@@ -132,3 +167,7 @@ class ReconResult(BaseModel):
132167
shodan_info: ShodanHostInfo | None = None
133168
virustotal_info: VirusTotalInfo | None = None
134169
securitytrails_info: SecurityTrailsInfo | None = None
170+
censys_info: CensysHostInfo | None = None
171+
zoomeye_info: ZoomEyeHostInfo | None = None
172+
fofa_info: FofaHostInfo | None = None
173+
greynoise_info: GreyNoiseInfo | None = None
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Censys Search API v2 integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
import httpx
8+
9+
from argus_lite.models.recon import CensysHostInfo, CensysServiceInfo
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_BASE_URL = "https://search.censys.io/api/v2"
14+
15+
16+
def parse_censys_host_response(data: dict) -> CensysHostInfo:
17+
"""Parse Censys /v2/hosts/{ip} response into CensysHostInfo."""
18+
result = data.get("result", {})
19+
if not result:
20+
return CensysHostInfo()
21+
22+
services = []
23+
for svc in result.get("services", []):
24+
services.append(CensysServiceInfo(
25+
port=svc.get("port", 0),
26+
transport=svc.get("transport_protocol", ""),
27+
service_name=svc.get("service_name", ""),
28+
banner=(svc.get("banner", "") or "")[:200],
29+
))
30+
31+
return CensysHostInfo(
32+
ip=result.get("ip", ""),
33+
services=services,
34+
labels=result.get("labels", []),
35+
last_updated=result.get("last_updated_at", ""),
36+
total_results=1,
37+
)
38+
39+
40+
def parse_censys_search_response(data: dict) -> list[str]:
41+
"""Parse Censys /v2/hosts/search response → list of IPs."""
42+
result = data.get("result", {})
43+
hits = result.get("hits", [])
44+
return [h.get("ip", "") for h in hits if h.get("ip")]
45+
46+
47+
async def censys_lookup(target: str, api_id: str, api_secret: str) -> CensysHostInfo:
48+
"""Look up a host on Censys by IP or domain. Returns CensysHostInfo."""
49+
if not api_id or not api_secret:
50+
return CensysHostInfo()
51+
52+
# Resolve domain to IP if needed
53+
import socket
54+
try:
55+
ip = socket.gethostbyname(target)
56+
except socket.gaierror:
57+
ip = target
58+
59+
url = f"{_BASE_URL}/hosts/{ip}"
60+
try:
61+
async with httpx.AsyncClient(timeout=30) as client:
62+
resp = await client.get(url, auth=(api_id, api_secret))
63+
if resp.status_code != 200:
64+
logger.debug("Censys returned %s for %s", resp.status_code, ip)
65+
return CensysHostInfo()
66+
return parse_censys_host_response(resp.json())
67+
except Exception as exc:
68+
logger.debug("Censys lookup failed for %s: %s", target, exc)
69+
return CensysHostInfo()
70+
71+
72+
async def censys_search(query: str, api_id: str, api_secret: str,
73+
max_results: int = 100) -> list[str]:
74+
"""Search Censys with a query string → list of IP addresses."""
75+
if not api_id or not api_secret:
76+
return []
77+
78+
url = f"{_BASE_URL}/hosts/search"
79+
params = {"q": query, "per_page": min(max_results, 100)}
80+
try:
81+
async with httpx.AsyncClient(timeout=30) as client:
82+
resp = await client.get(url, params=params, auth=(api_id, api_secret))
83+
if resp.status_code != 200:
84+
logger.debug("Censys search returned %s", resp.status_code)
85+
return []
86+
return parse_censys_search_response(resp.json())
87+
except Exception as exc:
88+
logger.debug("Censys search failed: %s", exc)
89+
return []

0 commit comments

Comments
 (0)