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
+34 -4
View File
@@ -1311,8 +1311,24 @@ class ApiHandler(BaseHTTPRequestHandler):
else:seen.add(key);accepted.append(b)
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
def list_sources(self,db,org):
cols='id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at'
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute(f"SELECT {cols} FROM sources WHERE organization_id=? ORDER BY id",(org,))]})
cols='id,organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at'
items=[]
adapter_meta={a["source_code"]:a for a in available_adapters()}
for raw in db.execute(f"SELECT {cols} FROM sources WHERE organization_id=? ORDER BY id",(org,)):
item=row_json(raw); meta=adapter_meta.get(item.get("source_code") or item.get("kind"), {})
try: config=json.loads(raw["config_json"] or "{}")
except (TypeError,ValueError): config={}
try: policy=json.loads(raw["policy_json"] or "{}")
except (TypeError,ValueError): policy={}
item.update({"available": bool(meta.get("available", False)), "optional": bool(meta.get("optional", False)),
"configured": bool(meta.get("available", False) and (config.get("csv") or config.get("rows") is not None or raw["approved"])),
"credential_status": "Not required" if not meta.get("requires_credentials") else "Required / not configured",
"terms_status": "Provided" if policy.get("terms_url") or config.get("terms_url") else "Not reviewed",
"owner": policy.get("owner") or config.get("owner") or "Not assigned",
"rate_limit": policy.get("rate_limit") or config.get("rate_limit") or "Not set"})
item.pop("config_json", None)
items.append(item)
return self.send_json(200,{"organization_id":org,"items":items})
def list_queries(self,db,org):
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute("SELECT * FROM discovery_queries WHERE organization_id=? ORDER BY id",(org,))]})
def list_source_records(self,db,org,q):
@@ -1342,9 +1358,23 @@ class ApiHandler(BaseHTTPRequestHandler):
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"})
self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()))
def update_source(self,sid,payload,db,user):
if not db.execute("SELECT id FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone():return self.send_json(404,{"error":"not_found"})
source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone()
if not source:return self.send_json(404,{"error":"not_found"})
if 'enabled' not in payload:return self.send_json(400,{"error":"enabled_required"})
value=int(bool(payload['enabled']));db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
value=int(bool(payload['enabled']))
if value:
adapter=adapter_for(source['kind'])
try: config=json.loads(source['config_json'] or '{}')
except (TypeError,ValueError): config={}
metadata=next((item for item in available_adapters() if item['source_code']==source['kind']), {})
if not metadata.get('available', False): return self.send_json(409,{"error":"source_unavailable"})
validation=adapter.validate_config(config)
# A blank manual source is a deliberate staging point: the query or
# ingest payload can provide rows later. Other adapters must be ready
# before they are enabled.
if not validation.valid and not (source['kind'] == 'manual' and not config):
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
def create_query(self,payload,db,user):
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
selected=payload.get('selected_adapters',payload.get('sources',[])); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
+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()]
+17 -1
View File
@@ -2,9 +2,17 @@ import json, os, sqlite3, threading, unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.main import create_server
from app.sources import CsvSource, ManualSource
from app.sources import CsvSource, ManualSource, available_adapters
class SourceAdapterTests(unittest.TestCase):
def test_adapter_catalog_exposes_ready_and_gated_sources(self):
catalog = {item['source_code']: item for item in available_adapters()}
for code in ('manual', 'csv', 'public_website', 'ct_logs', 'dns', 'rdap'):
self.assertTrue(catalog[code]['available'], code)
for code in ('google_places', 'bing_local', 'approved_directory', 'permitted_social'):
self.assertFalse(catalog[code]['available'], code)
self.assertTrue(catalog[code]['optional'], code)
def test_csv_adapter_is_deterministic_and_normalizes(self):
src = CsvSource()
a = src.discover({'csv': 'Name,Website,Email\n Acme ,https://acme.test,a@acme.test\n'})
@@ -58,6 +66,14 @@ class SourceApiTests(unittest.TestCase):
def test_sources_require_auth(self):
self.cookie=None; self.assertEqual(self.req('GET','/api/v1/sources',cookie=False)[0],401)
def test_unavailable_or_unconfigured_sources_cannot_be_enabled(self):
status, gated = self.req('POST', '/api/v1/sources', {'name': 'Google', 'kind': 'google_places', 'config': {}})
self.assertEqual(status, 201)
self.assertEqual(self.req('PATCH', f"/api/v1/sources/{gated['id']}", {'enabled': True})[0], 409)
status, website = self.req('POST', '/api/v1/sources', {'name': 'Web', 'kind': 'public_website', 'config': {}})
self.assertEqual(status, 201)
self.assertEqual(self.req('PATCH', f"/api/v1/sources/{website['id']}", {'enabled': True})[0], 409)
def test_fresh_schema_accepts_optional_source_kind_fail_closed(self):
status, source = self.req('POST', '/api/v1/sources', {'name': 'RDAP', 'kind': 'rdap', 'config': {}})
self.assertEqual(status, 201)