Field baseline deviations now show distinct Mark expected, Mark false positive, and Mark confirmed
This commit is contained in:
@@ -19,20 +19,29 @@ def is_public_ip(value: str | None) -> bool:
|
||||
|
||||
|
||||
class ThreatIntelClient:
|
||||
def __init__(self, *, cache_file: str = "state/threat-intel-cache.json", ttl_seconds: int = 86400, enabled: bool | None = None) -> None:
|
||||
def __init__(self, *, cache_file: str = "state/threat-intel-cache.json", ttl_seconds: int | None = None, enabled: bool | None = None) -> None:
|
||||
self.enabled = os.getenv("FGAI_THREAT_INTEL", "").lower() in {"1", "true", "yes", "on"} if enabled is None else enabled
|
||||
self.abuseipdb_key = os.getenv("ABUSEIPDB_API_KEY")
|
||||
self.virustotal_key = os.getenv("VIRUSTOTAL_API_KEY")
|
||||
self.provider = os.getenv("FGAI_THREAT_INTEL_PROVIDER", "auto").lower()
|
||||
self.max_age_days = int(os.getenv("ABUSEIPDB_MAX_AGE_DAYS", "90"))
|
||||
self.cache_path = Path(cache_file)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.ttl_seconds = ttl_seconds if ttl_seconds is not None else int(os.getenv("FGAI_THREAT_INTEL_TTL_SECONDS", "604800"))
|
||||
self.error_ttl_seconds = int(os.getenv("FGAI_THREAT_INTEL_ERROR_TTL_SECONDS", "3600"))
|
||||
self.daily_limit = int(os.getenv("FGAI_THREAT_INTEL_DAILY_LIMIT", "100"))
|
||||
self.cache = self._read_cache()
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
provider = self._select_provider()
|
||||
has_key = bool(self.abuseipdb_key if provider == "abuseipdb" else self.virustotal_key)
|
||||
return {"enabled": self.enabled, "provider": provider, "configured": has_key}
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"provider": provider,
|
||||
"configured": has_key,
|
||||
"cache_ttl_seconds": self.ttl_seconds,
|
||||
"daily_limit": self.daily_limit,
|
||||
"requests_today": self._requests_today(provider),
|
||||
}
|
||||
|
||||
def _read_cache(self) -> dict[str, dict[str, object]]:
|
||||
if not self.cache_path.exists():
|
||||
@@ -46,6 +55,39 @@ class ThreatIntelClient:
|
||||
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_path.write_text(json.dumps(self.cache, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
def _cache_fresh(self, cached: dict[str, object], now: int) -> bool:
|
||||
ttl = self.ttl_seconds if cached.get("status") == "ok" else self.error_ttl_seconds
|
||||
return now - int(cached.get("cached_at", 0)) < ttl
|
||||
|
||||
def _today(self) -> str:
|
||||
return time.strftime("%Y-%m-%d", time.gmtime())
|
||||
|
||||
def _requests_today(self, provider: str) -> int:
|
||||
meta = self.cache.get("_meta", {})
|
||||
if not isinstance(meta, dict):
|
||||
return 0
|
||||
requests_by_day = meta.get("requests_by_day", {})
|
||||
if not isinstance(requests_by_day, dict):
|
||||
return 0
|
||||
provider_counts = requests_by_day.get(provider, {})
|
||||
return int(provider_counts.get(self._today(), 0)) if isinstance(provider_counts, dict) else 0
|
||||
|
||||
def _record_request(self, provider: str) -> None:
|
||||
meta = self.cache.setdefault("_meta", {})
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
self.cache["_meta"] = meta
|
||||
requests_by_day = meta.setdefault("requests_by_day", {})
|
||||
if not isinstance(requests_by_day, dict):
|
||||
requests_by_day = {}
|
||||
meta["requests_by_day"] = requests_by_day
|
||||
provider_counts = requests_by_day.setdefault(provider, {})
|
||||
if not isinstance(provider_counts, dict):
|
||||
provider_counts = {}
|
||||
requests_by_day[provider] = provider_counts
|
||||
today = self._today()
|
||||
provider_counts[today] = int(provider_counts.get(today, 0)) + 1
|
||||
|
||||
def lookup_ip(self, ip: str) -> dict[str, object]:
|
||||
if not is_public_ip(ip):
|
||||
return {"ip": ip, "provider": "local", "status": "skipped", "reason": "not_public_ip", "score": 0}
|
||||
@@ -53,10 +95,18 @@ class ThreatIntelClient:
|
||||
cache_key = f"{provider}:{ip}"
|
||||
cached = self.cache.get(cache_key)
|
||||
now = int(time.time())
|
||||
if cached and now - int(cached.get("cached_at", 0)) < self.ttl_seconds:
|
||||
if isinstance(cached, dict) and self._cache_fresh(cached, now):
|
||||
return cached
|
||||
if not self.enabled:
|
||||
return {"ip": ip, "provider": "none", "status": "disabled", "score": 0}
|
||||
if self.daily_limit > 0 and self._requests_today(provider) >= self.daily_limit:
|
||||
return {
|
||||
"ip": ip,
|
||||
"provider": provider,
|
||||
"status": "daily_limit_reached",
|
||||
"score": 0,
|
||||
"reason": "external lookup budget reached; cached results remain available",
|
||||
}
|
||||
if provider == "abuseipdb":
|
||||
if not self.abuseipdb_key:
|
||||
return {"ip": ip, "provider": "abuseipdb", "status": "missing_api_key", "score": 0}
|
||||
@@ -68,6 +118,7 @@ class ThreatIntelClient:
|
||||
else:
|
||||
return {"ip": ip, "provider": provider, "status": "unsupported_provider", "score": 0}
|
||||
|
||||
self._record_request(provider)
|
||||
result["cached_at"] = now
|
||||
self.cache[cache_key] = result
|
||||
self._write_cache()
|
||||
|
||||
Reference in New Issue
Block a user