diff --git a/integrations/riposte-sextant/manifest.yaml b/integrations/riposte-sextant/manifest.yaml new file mode 100644 index 0000000..18fd910 --- /dev/null +++ b/integrations/riposte-sextant/manifest.yaml @@ -0,0 +1,64 @@ +id: riposte-sextant +name: Riposte Sextant +version: 1.0.0 +description: > + Riposte Sextant — SOC steering. Push what this SOAR already knows about a + client's estate into their steering file: agent counters, measured on the + vendor console by another command and mapped in the playbook that calls this + one. Sextant never connects to anything itself; it receives. Bearer token + authentication, stdlib-only, no extra Python dependencies. +changelog: "1.0.0 — Initial release: push agent counters, and a connection test." +category: reporting + +config_schema: + properties: + base_url: + type: string + description: "Sextant's public URL, e.g. https://sextant.example.corp (no trailing path)" + token: + type: string + description: "Ingestion token, created in Sextant under Settings. It writes readings and nothing else: it carries no role and reads nothing." + x-soar-sensitive: true + required: + - base_url + - token + +commands: + - id: push_agent_stats + name: sextant-push-agent-stats + description: > + Deposit one agent reading for a client. Counters left empty stay UNKNOWN in + Sextant — never zero: "we did not measure how many agents are in error" and + "no agent is in error" are opposite pieces of news, and the second one + reassures wrongly. Map only what the console actually returned. + # safe_write, and not read: this writes a reading into another product. It + # changes nothing on the customer's estate, which is what "safe" means here. + risk: safe_write + inputs_schema: + properties: + client_id: + type: string + description: "The client's id IN THIS SOAR. Sextant resolves its own client through the pairing already recorded on the client sheet — no extra identifier to copy, so none to get wrong." + expected: + type: number + description: "Agents the contract commits to. A commitment, not a measurement — no console knows it. Without it the coverage ratio stays unknown and the raw counters still stand." + deployed: { type: number, description: "Agents installed on the estate" } + connected: { type: number, description: "Agents talking to the console" } + disconnected: { type: number, description: "Agents known but silent" } + errored: { type: number, description: "Agents reporting a fault" } + outdated: { type: number, description: "Agents running an old version" } + unmanaged: { type: number, description: "Machines with no agent" } + required: [client_id] + outputs_schema: + properties: + client_id: { type: string, description: "The Sextant client the reading was filed under — the confirmation that the pairing pointed at the right one" } + sent: { type: object, description: "The counters actually deposited, so the run log shows what was left unmeasured" } + + - id: test_connection + name: sextant-test-connection + description: "Check the URL and the ingestion token, without depositing anything." + risk: read + inputs_schema: { properties: {} } + outputs_schema: + properties: + ok: { type: boolean } diff --git a/integrations/riposte-sextant/scripts/push_agent_stats.py b/integrations/riposte-sextant/scripts/push_agent_stats.py new file mode 100644 index 0000000..d89037c --- /dev/null +++ b/integrations/riposte-sextant/scripts/push_agent_stats.py @@ -0,0 +1,85 @@ +import json, os, sys, urllib.error, urllib.request + +# The six counters Sextant knows, plus the contractual expectation. The +# vocabulary is fixed on purpose: the steering portfolio compares clients to one +# another, and it can only do that if "connected" means the same thing whichever +# console produced it. +COUNTERS = ["expected", "deployed", "connected", "disconnected", "errored", "outdated", "unmanaged"] + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _post(path, body): + cfg = _cfg() + base = str(cfg.get("base_url") or "").rstrip("/") + if not base: + raise Exception("base_url is not configured") + req = urllib.request.Request( + base + path, + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer " + str(cfg.get("token") or ""), + "User-Agent": "Riposte-SOAR/sextant", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _counter(name, value): + """Return a counter, or None when it was not measured. + + An ABSENT value is dropped rather than sent as zero. That is the whole + contract: an unmapped field stays unknown in Sextant, and a zero in its + place would be a confident lie about a customer's estate. + + A value that is present but not a number is REFUSED instead, and the field + is named. That is a broken mapping in the calling playbook, and it must be + visible — silently dropping it would look exactly like "not measured". + """ + if value is None or value == "": + return None + try: + n = float(value) + except (TypeError, ValueError): + raise Exception(name + " is not a number: " + repr(value)) + if n != int(n): + raise Exception(name + " is not a whole number: " + repr(value)) + return int(n) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + client_id = str(inputs.get("client_id") or "").strip() + if not client_id: + raise Exception("client_id is required — it is the client's id in this SOAR") + + body = {"client_id": client_id} + for name in COUNTERS: + value = _counter(name, inputs.get(name)) + if value is not None: + body[name] = value + + result = _post("/api/ingest/agents", body) + sent = {k: v for k, v in body.items() if k != "client_id"} + print(json.dumps({"client_id": result.get("client_id", ""), "sent": sent})) + + +try: + main() +except urllib.error.HTTPError as e: + # Sextant names its refusals — an unpaired client says which identifier it + # did not recognise. Passing the body through is what turns "HTTP 422" into + # something an operator can act on. + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/riposte-sextant/scripts/test_connection.py b/integrations/riposte-sextant/scripts/test_connection.py new file mode 100644 index 0000000..f9b4186 --- /dev/null +++ b/integrations/riposte-sextant/scripts/test_connection.py @@ -0,0 +1,38 @@ +import json, os, sys, urllib.error, urllib.request + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def main(): + cfg = _cfg() + base = str(cfg.get("base_url") or "").rstrip("/") + if not base: + raise Exception("base_url is not configured") + req = urllib.request.Request( + base + "/api/ingest/ping", + headers={ + "Accept": "application/json", + "Authorization": "Bearer " + str(cfg.get("token") or ""), + "User-Agent": "Riposte-SOAR/sextant", + }, + method="GET", + ) + with urllib.request.urlopen(req, timeout=30) as r: + result = json.loads(r.read() or b"{}") + # The probe deposits nothing and reads nothing back but an acknowledgement: + # an ingestion token must not become a way to enumerate clients. + if not result.get("ok"): + raise Exception("unexpected response from Sextant") + print(json.dumps({"ok": True})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)