deploy updated source integrations
CI / compose (push) Successful in 13m17s

This commit is contained in:
Marco0300
2026-09-04 10:03:34 +02:00
parent cb31f2dd04
commit 39dadd6135
6 changed files with 205 additions and 16 deletions
+151 -8
View File
@@ -7,7 +7,14 @@ Adapters never emit or persist credential values.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol, Sequence
import csv, io, random, time
import csv, io, random, time, json
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
try:
from .website_scanner import validate_url
except ImportError:
from website_scanner import validate_url
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "private_key", "credential"}
NETWORK_KINDS = {"google_places", "bing_local", "approved_directory", "public_website", "permitted_social", "ct_logs", "dns", "rdap"}
@@ -104,6 +111,9 @@ class _Base:
kind = ""
source_code = ""
display_name = ""
available = True
optional = False
requires_credentials = False
def validate_config(self, config):
if not isinstance(config, Mapping): return ValidationResult(False, ["config must be an object"])
found = contains_secret(config)
@@ -145,9 +155,142 @@ class CsvSource(_Base):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
return DiscoveryPage([normalize_record({str(k).strip().lower():v for k,v in row.items()}) for row in reader], metadata={"adapter":self.source_code,"columns":reader.fieldnames or []})
records = []
for row in reader:
normalized = {str(k).strip().lower(): v for k, v in row.items()}
if any(str(v or '').strip() for v in normalized.values()):
records.append(normalize_record(normalized))
return DiscoveryPage(records, metadata={"adapter":self.source_code,"columns":reader.fieldnames or [],"record_count":len(records)})
class _HttpJsonSource(_Base):
"""Small, bounded JSON client used only for public standards-based sources."""
max_bytes = 256 * 1024
timeout = 8
def _get_json(self, url):
safe = validate_url(url)
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
with urlopen(request, timeout=self.timeout) as response:
body = response.read(self.max_bytes + 1)
if len(body) > self.max_bytes:
raise ValueError("source_response_too_large")
return json.loads(body.decode("utf-8", "replace")), safe
class PublicWebsiteSource(_Base):
kind = source_code = "public_website"
display_name = "Public website"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
urls = config.get("urls", config.get("url", []))
if isinstance(urls, str): urls = [urls] if urls.strip() else []
if not isinstance(urls, list) or not urls or len(urls) > 50:
return ValidationResult(False, ["urls must contain 1 to 50 public HTTP(S) URLs"])
for value in urls:
try: validate_url(str(value))
except ValueError: return ValidationResult(False, ["unsafe public website URL"])
return ValidationResult(True)
def discover(self, config, cursor=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
urls = config.get("urls", config.get("url"))
if isinstance(urls, str): urls = [urls]
records = []
for raw_url in urls[:50]:
safe = validate_url(str(raw_url))
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
with urlopen(request, timeout=8) as response:
body = response.read(128 * 1024).decode("utf-8", "replace")
final_url = response.geturl()
from html.parser import HTMLParser
parser = HTMLParser()
title = urlparse(final_url).hostname or safe
records.append(normalize_record({"name": title, "website": final_url, "description": body[:1000]}))
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "bounded": True})
class CtLogsSource(_HttpJsonSource):
kind = source_code = "ct_logs"
display_name = "Certificate transparency logs"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
query = str(config.get("domain", config.get("query", ""))).strip()
if not query or len(query) > 253 or any(ch in query for ch in "\r\n"):
return ValidationResult(False, ["domain or query is required"])
return ValidationResult(True)
def discover(self, config, cursor=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
query = str(config.get("domain", config.get("query"))).strip()
endpoint = "https://crt.sh/?" + urlencode({"q": "%25." + query.lstrip("%.") if not query.startswith("%") else query, "output": "json"})
payload, source_url = self._get_json(endpoint)
if not isinstance(payload, list): raise ValueError("invalid_ct_response")
records, seen = [], set()
for item in payload[:500]:
names = str(item.get("name_value", "")) if isinstance(item, dict) else ""
for name in names.splitlines():
name = name.strip().lower().lstrip("*.")
if not name or name in seen or "." not in name: continue
seen.add(name); records.append(normalize_record({"name": name, "website": "https://" + name}))
return DiscoveryPage(records[:100], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": len(records), "signal_only": True})
class DnsSource(_Base):
kind = source_code = "dns"
display_name = "DNS"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
domains = config.get("domains", config.get("domain", []))
if isinstance(domains, str): domains = [domains] if domains.strip() else []
if not isinstance(domains, list) or not domains or len(domains) > 100: return ValidationResult(False, ["domains must contain 1 to 100 names"])
return ValidationResult(True)
def discover(self, config, cursor=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
import socket
domains = config.get("domains", config.get("domain")); domains = [domains] if isinstance(domains, str) else domains
records = []
for domain in domains[:100]:
domain = str(domain).strip().lower().rstrip(".")
if not domain or "." not in domain: continue
try: addresses = sorted({item[4][0] for item in socket.getaddrinfo(domain, 443, type=socket.SOCK_STREAM)})
except socket.gaierror: addresses = []
records.append(normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"a_aaaa": addresses})}))
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "dns_status_only": True})
class RdapSource(_HttpJsonSource):
kind = source_code = "rdap"
display_name = "RDAP"
def validate_config(self, config):
result = super().validate_config(config)
if not result.valid: return result
domain = str(config.get("domain", "")).strip()
if not domain or "." not in domain: return ValidationResult(False, ["domain is required"])
return ValidationResult(True)
def discover(self, config, cursor=None):
result = self.validate_config(config)
if not result.valid: raise ValueError(result.errors[0])
domain = str(config["domain"]).strip().lower().rstrip(".")
payload, source_url = self._get_json("https://rdap.org/domain/" + domain)
return DiscoveryPage([normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"rdap": payload}, default=str)[:1000]})], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": 1, "registration_signal_only": True})
class GatedSource(_Base):
available = False
optional = True
requires_credentials = True
required = "approved"
def validate_config(self, config):
result = super().validate_config(config)
@@ -172,11 +315,7 @@ def _gated(code, name):
GooglePlacesSource = _gated("google_places", "Google Places")
BingLocalSource = _gated("bing_local", "Bing / approved local API")
ApprovedDirectorySource = _gated("approved_directory", "Approved directory")
PublicWebsiteSource = _gated("public_website", "Public website")
PermittedSocialSource = _gated("permitted_social", "Permitted social")
CtLogsSource = _gated("ct_logs", "Certificate transparency logs")
DnsSource = _gated("dns", "DNS")
RdapSource = _gated("rdap", "RDAP")
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
# common aliases used by clients
@@ -186,5 +325,9 @@ def adapter_for(kind: str) -> DiscoverySource:
try: return ADAPTERS[str(kind).strip().lower()]()
except KeyError: raise ValueError("unsupported source kind")
def available_adapters() -> list[dict[str, str]]:
return [{"source_code": cls.source_code, "display_name": cls.display_name} for cls in ADAPTERS.values()]
def available_adapters() -> list[dict[str, object]]:
return [{"source_code": cls.source_code, "display_name": cls.display_name,
"available": bool(getattr(cls, "available", False)),
"optional": bool(getattr(cls, "optional", False)),
"requires_credentials": bool(getattr(cls, "requires_credentials", False))}
for cls in ADAPTERS.values()]