|
| 1 | +"""Export Groupint incidents to Atlos API v2.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import logging |
| 6 | +import time |
| 7 | +from typing import Any |
| 8 | +from urllib.parse import urljoin |
| 9 | + |
| 10 | +import httpx |
| 11 | + |
| 12 | +from core.incidents.config import ( |
| 13 | + apply_atlos_secrets, |
| 14 | + default_atlos_api_token, |
| 15 | + default_atlos_base_url, |
| 16 | +) |
| 17 | + |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | + |
| 20 | +DEFAULT_SENSITIVE = ["Not Sensitive"] |
| 21 | +DEFAULT_STATUS = "To Do" |
| 22 | +REQUEST_TIMEOUT = 60.0 |
| 23 | + |
| 24 | + |
| 25 | +def normalize_base_url(url: str) -> str: |
| 26 | + return (url or "").strip().rstrip("/") |
| 27 | + |
| 28 | + |
| 29 | +def atlos_config() -> dict[str, str]: |
| 30 | + """Neo4j saved settings override env defaults (local Docker by default).""" |
| 31 | + from db.dal import GraphManager |
| 32 | + |
| 33 | + apply_atlos_secrets() |
| 34 | + row = GraphManager.get_incident_monitor_config() |
| 35 | + base = normalize_base_url(row.get("atlos_base_url") or "") or default_atlos_base_url() |
| 36 | + token = (row.get("atlos_api_token") or "").strip() or default_atlos_api_token() |
| 37 | + return {"base_url": base, "api_token": token} |
| 38 | + |
| 39 | + |
| 40 | +def incident_to_atlos_payload( |
| 41 | + inc: dict, |
| 42 | + *, |
| 43 | + sensitive: list[str] | None = None, |
| 44 | + status: str = DEFAULT_STATUS, |
| 45 | +) -> dict[str, Any]: |
| 46 | + category = (inc.get("category") or "other").strip() |
| 47 | + location = (inc.get("location_text") or "").strip() |
| 48 | + summary = (inc.get("summary") or "").strip() |
| 49 | + occurred = inc.get("occurred_at") or "" |
| 50 | + parts = [f"[{category}]"] |
| 51 | + if location: |
| 52 | + parts.append(location) |
| 53 | + if summary: |
| 54 | + parts.append(summary) |
| 55 | + if occurred: |
| 56 | + parts.append(f"Occurred: {occurred}") |
| 57 | + description = "\n\n".join(parts).strip() |
| 58 | + if len(description) < 8: |
| 59 | + description = (description + " — Groupint OSINT incident export").strip() |
| 60 | + if len(description) < 8: |
| 61 | + description = "Groupint OSINT incident export." |
| 62 | + |
| 63 | + payload: dict[str, Any] = { |
| 64 | + "description": description, |
| 65 | + "sensitive": sensitive or DEFAULT_SENSITIVE, |
| 66 | + "status": status, |
| 67 | + "tags": [category], |
| 68 | + } |
| 69 | + lat, lon = inc.get("lat"), inc.get("lon") |
| 70 | + if lat is not None and lon is not None: |
| 71 | + payload["geolocation"] = f"{float(lat)},{float(lon)}" |
| 72 | + urls = [] |
| 73 | + for u in inc.get("source_urls") or []: |
| 74 | + s = str(u).strip() |
| 75 | + if s.startswith("http://") or s.startswith("https://"): |
| 76 | + urls.append(s) |
| 77 | + if urls: |
| 78 | + payload["urls"] = urls |
| 79 | + return payload |
| 80 | + |
| 81 | + |
| 82 | +def _extract_slug(data: dict | list | None) -> str | None: |
| 83 | + if not data: |
| 84 | + return None |
| 85 | + if isinstance(data, dict): |
| 86 | + for key in ("slug", "incident_slug"): |
| 87 | + if data.get(key): |
| 88 | + return str(data[key]) |
| 89 | + for nested in ("incident", "data", "result"): |
| 90 | + inner = data.get(nested) |
| 91 | + if isinstance(inner, dict): |
| 92 | + found = _extract_slug(inner) |
| 93 | + if found: |
| 94 | + return found |
| 95 | + return None |
| 96 | + |
| 97 | + |
| 98 | +def create_atlos_incident( |
| 99 | + client: httpx.Client, |
| 100 | + base_url: str, |
| 101 | + token: str, |
| 102 | + payload: dict[str, Any], |
| 103 | +) -> str: |
| 104 | + url = urljoin(base_url + "/", "api/v2/incidents/new") |
| 105 | + resp = client.post( |
| 106 | + url, |
| 107 | + json=payload, |
| 108 | + headers={"Authorization": f"Bearer {token}"}, |
| 109 | + timeout=REQUEST_TIMEOUT, |
| 110 | + ) |
| 111 | + resp.raise_for_status() |
| 112 | + try: |
| 113 | + body = resp.json() |
| 114 | + except Exception: |
| 115 | + body = {} |
| 116 | + slug = _extract_slug(body if isinstance(body, dict) else {}) |
| 117 | + if slug: |
| 118 | + return slug |
| 119 | + raise ValueError(f"Atlos response missing slug: {body!r}") |
| 120 | + |
| 121 | + |
| 122 | +def test_atlos_connection(base_url: str, token: str) -> tuple[bool, str]: |
| 123 | + if not token: |
| 124 | + return False, "API token is empty." |
| 125 | + base_url = normalize_base_url(base_url) |
| 126 | + url = urljoin(base_url + "/", "api/v2/incidents") |
| 127 | + try: |
| 128 | + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: |
| 129 | + resp = client.get( |
| 130 | + url, |
| 131 | + headers={"Authorization": f"Bearer {token}"}, |
| 132 | + params={"limit": 1}, |
| 133 | + ) |
| 134 | + if resp.status_code in (200, 401, 403): |
| 135 | + if resp.status_code == 200: |
| 136 | + return True, "Connected to Atlos API." |
| 137 | + return False, f"Atlos returned HTTP {resp.status_code}: check API token." |
| 138 | + return False, f"Unexpected HTTP {resp.status_code}: {resp.text[:200]}" |
| 139 | + except httpx.RequestError as exc: |
| 140 | + return False, f"Could not reach Atlos: {exc}" |
| 141 | + |
| 142 | + |
| 143 | +def export_incidents_batch( |
| 144 | + incidents: list[dict], |
| 145 | + *, |
| 146 | + base_url: str, |
| 147 | + api_token: str, |
| 148 | + sensitive: list[str] | None = None, |
| 149 | + skip_exported: bool = True, |
| 150 | + delay_sec: float = 0.3, |
| 151 | +) -> dict[str, Any]: |
| 152 | + base_url = normalize_base_url(base_url) |
| 153 | + if not api_token: |
| 154 | + return { |
| 155 | + "created": 0, |
| 156 | + "skipped": len(incidents), |
| 157 | + "failed": 0, |
| 158 | + "errors": ["ATLOS_API_TOKEN is not set."], |
| 159 | + } |
| 160 | + from db.dal import GraphManager |
| 161 | + |
| 162 | + created = 0 |
| 163 | + skipped = 0 |
| 164 | + failed = 0 |
| 165 | + errors: list[str] = [] |
| 166 | + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: |
| 167 | + for inc in incidents: |
| 168 | + if skip_exported and inc.get("atlos_slug"): |
| 169 | + skipped += 1 |
| 170 | + continue |
| 171 | + try: |
| 172 | + payload = incident_to_atlos_payload(inc, sensitive=sensitive) |
| 173 | + slug = create_atlos_incident(client, base_url, api_token, payload) |
| 174 | + GraphManager.set_incident_atlos_export(str(inc["id"]), slug) |
| 175 | + created += 1 |
| 176 | + if delay_sec > 0: |
| 177 | + time.sleep(delay_sec) |
| 178 | + except Exception as exc: |
| 179 | + failed += 1 |
| 180 | + msg = f"{inc.get('id')}: {exc}" |
| 181 | + errors.append(msg) |
| 182 | + logger.warning("Atlos export failed: %s", msg) |
| 183 | + return { |
| 184 | + "created": created, |
| 185 | + "skipped": skipped, |
| 186 | + "failed": failed, |
| 187 | + "errors": errors, |
| 188 | + } |
0 commit comments