#!/usr/bin/env python3 """soc-collector — scan an estate and push ONE consolidated report to SOC Center. Each run produces a single report (all enabled sources merged), keyed by SOC_SLUG — so one server = one report. Run it per server with a distinct SOC_SLUG and you get one report each, not one-per-source-per-server. Re-running the same slug lets SOC Center version it, diff each run, and alert only on new findings. Runs once and exits (schedule with cron / a systemd timer). Configuration is entirely via env vars — see the README. Required: SOC_URL, SOC_TOKEN. A source self-skips when its inputs are absent (no SOC_TARGETS -> no nmap; no Docker socket -> no Trivy / config). """ import os import sys import soc from sources import cve, config_drift, surface, tls SEV_ORDER = {"crit": 0, "high": 1, "med": 2, "low": 3} def _list(name): return [x.strip() for x in os.environ.get(name, "").replace(";", ",").split(",") if x.strip()] def main(): url = soc._env("SOC_URL", required=True) token = soc._env("SOC_TOKEN", required=True) visibility = os.environ.get("SOC_VISIBILITY", "private") base_tags = _list("SOC_TAGS") or ["collector"] want = set(_list("SOC_SOURCES") or ["surface", "cve", "tls", "config"]) targets, domains = _list("SOC_TARGETS"), _list("SOC_DOMAINS") # One report per run; the slug names this server. Fall back to the old prefix var. slug = (os.environ.get("SOC_SLUG") or os.environ.get("SOC_SLUG_PREFIX") or "soc-scan").strip() title = os.environ.get("SOC_TITLE") or f"Security scan — {slug}" runners = [] if "surface" in want: runners.append(("surface", lambda: surface.run(targets))) if "cve" in want: runners.append(("cve", cve.run)) if "tls" in want: runners.append(("tls", lambda: tls.run(domains))) if "config" in want: runners.append(("config", config_drift.run)) print(f"[soc-collector] target={url} slug={slug} sources={sorted(want)}") findings, surface_rows, good, scope, ran = [], [], [], [], [] for key, fn in runners: print(f"[soc-collector] source: {key}") try: data = fn() except Exception as e: print(f" ! {key} errored: {e}") continue if data is None: print(f" - {key} skipped (no targets / no Docker socket)") continue ran.append(key) n = data.get("findings", []) findings += n surface_rows += data.get("surface", []) good += data.get("good", []) if data.get("scope"): scope.append(f"{key}: {data['scope']}") print(f" ✓ {key}: {len(n)} finding(s)") if not ran: print("[soc-collector] no sources ran (need SOC_TARGETS / SOC_DOMAINS / a Docker socket)") sys.exit(1) findings.sort(key=lambda f: SEV_ORDER.get(f.get("sev"), 9)) # criticals first data = {"scope": " · ".join(scope), "findings": findings, "surface": surface_rows, "good": good} status, resp = soc.post_report(url, token, slug, title, data, visibility=visibility, tags=base_tags + ran) if status in (200, 201): d = resp.get("diff", {}) print(f"[soc-collector] ✓ {slug}: {len(findings)} finding(s) from {ran}, " f"{'created' if resp.get('created') else 'updated'} " f"(+{d.get('added', 0)}/-{d.get('removed', 0)}/~{d.get('changed', 0)}) " f"alerted={resp.get('alerted')}") sys.exit(0) print(f"[soc-collector] ! {slug}: HTTP {status} {resp}") sys.exit(1) if __name__ == "__main__": main()