feat(riposte-sextant): push agent readings into the SOC steering file
Sextant counts nothing itself: it receives. Until now the opposite was true — it reached into this SOAR to run a command, which meant SOAR had to host a whitelist table, a screen to fill it, a dedicated permission and a catalogue route. Configuration belonging to another product, living here. As an integration, none of that is needed: an operator creates an instance with a URL and a token like for any other tool, and a scheduled playbook counts on the vendor console, maps, then calls push_agent_stats. The mapping lives in that playbook, which is where it belongs — next to the connector that produced the numbers, and per client rather than per product. WHAT THE SCRIPT REFUSES TO DO, and why it matters more than what it does: a counter left empty is OMITTED, never sent as 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 reassures wrongly — the whole steering file exists not to say it. A counter that is present but not a number is refused instead, naming the field: that is a broken mapping in the calling playbook, and dropping it silently would look exactly like "not measured". Verified against a running Sextant rather than assumed: the deposit files under the paired client with the empty counter absent, a broken mapping is refused by name, and an unpaired client comes back saying which identifier was not recognised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user