Skip to content

Cirius Group SOC API Reference

Base URL (browser / human): https://soc.bedrockcybersecurity.org — fronted by Cloudflare Access (OneLogin SAML) Base URL (machine / agents): https://ca-soc-prod.orangehill-31f5dc15.westus.azurecontainerapps.io — direct Container App URL, bypasses the Cloudflare Access WAF for M2M traffic Stack: FastAPI (Python 3.12) + SQLAlchemy 2.x async ORM · Azure Container App ca-soc-prod (resource group rg-logging-logs, image soc-web from ciriusagentsprod.azurecr.io) · PostgreSQL Flexible Server psql-soc-prod (asyncpg; SQLite in dev/tests) · Alembic migrations Interactive docs: /api/docs (Swagger), /api/redoc, /api/openapi.json

Rewritten 2026-06-10 from bedrock-soc source (closes the May 2026 platform-split staleness). Every schema, limit, and error code below is taken from the code, not carried over from the old SecOps doc.

Environment convention used in examples:

bash
export SOC_API_URL="https://ca-soc-prod.orangehill-31f5dc15.westus.azurecontainerapps.io"
export SOC_API_KEY="<value of ingest-api-key in cirius-openai-kv-prod>"

Table of Contents


Authentication

Two auth mechanisms, verified from app/auth.py and app/routers/_common.py:

1. Machine auth — X-API-Key

  • Header: X-API-Key: <INGEST_API_KEY>
  • Compared against the INGEST_API_KEY env var with secrets.compare_digest (constant-time).
  • A valid key receives wildcard tenant scope (tenant_ids: ["*"]) — it may act on any tenant named in the payload/query tenant_id.
  • Used by the orchestrator, all security agents, and GitHub Actions. Agents call the direct ACA URL (SOC_API_URL) because Cloudflare Access sits in front of soc.bedrockcybersecurity.org.
  • Missing/invalid key → 401 {"detail": "Invalid or missing API key"}.

2. Browser auth — Cloudflare Access JWT

  • Cloudflare Access (OneLogin SAML IdP) authenticates every browser request and injects a Cf-Access-Jwt-Assertion header. The app validates this JWT; there are no login routes, no session cookies, no MSAL.
  • Validation (app/auth.py): RS256 against the JWKS at https://{CF_TEAM_DOMAIN}/cdn-cgi/access/certs (cached 5 minutes, lock-protected refresh), audience = CF_AUD. The app fails to start in production if CF_AUD is unset. In local dev (no CF_AUD), audience verification is skipped.
  • Tenant scope comes from soc-tenant-{id} role claims, read from any of the JWT fields groups, roles, or custom_claims. Fallback: any @ciriusgroup.com email automatically receives the cirius tenant.
  • CF service tokens are recognized (type=app claim, no email) and flagged is_service_token: true.
  • Failures: missing header → 401 Missing Cf-Access-Jwt-Assertion header; expired → 401 Token expired; otherwise → 401 Invalid token.

Dependency variants (per endpoint, from app/routers/_common.py)

DependencyAccepts
require_api_keyX-API-Key only
require_userCF Access JWT only
require_user_or_api_keyX-API-Key if the header is present, otherwise CF JWT
require_admin_or_api_keyX-API-Key, or CF JWT whose email is in SECOPS_ADMIN_EMAILS (else 403)
read-or-API-key (agent-decisions GET)X-API-Key, or X-Read-Token matching REVIEW_READ_TOKEN, or CF JWT
require_admin (tenants router only)CF JWT with soc-admin role claim or an @ciriusgroup.com email

Admin (general): is_admin() in app/models.py checks the caller's email against the comma-separated SECOPS_ADMIN_EMAILS env var. Used by events stats, saved searches, and correlation-rule writes.

Tenant scoping

get_tenant_scope(user, tenant_id) is enforced on every tenant-scoped endpoint:

  • empty tenant_id400 tenant_id is required
  • tenant not in the caller's tenant_ids (and no * wildcard) → 403 Access denied for this tenant (the attempt is logged as a SECURITY warning)

Common Headers

HeaderDirectionPurpose
X-API-KeyrequestMachine auth (INGEST_API_KEY)
Cf-Access-Jwt-AssertionrequestCloudflare Access JWT (injected by CF for browsers/service tokens)
X-Read-TokenrequestRead-only token for GET /api/agent-decisions (REVIEW_READ_TOKEN)
Content-Type: application/jsonrequestAll JSON bodies
X-SOC-Signatureoutbound webhooksha256=<HMAC-SHA256 hex> of the delivery body, keyed with the webhook secret
Retry-AfterresponseSent by the slowapi 429 handler

Security response headers are applied globally (nosniff, DENY framing, CSP, HSTS in production).


Error Codes

CodeMeaning in this API
400Missing/empty tenant_id; invalid tenant_id format; break-glass pattern rejected; invalid UUID on links/webhooks; >500 findings per ingest batch; unknown webhook events; bad min_severity; invalid playbook step index; tenant_id slug format violation
401Missing/invalid API key; missing/expired/invalid CF JWT
403Cross-tenant access attempt; admin role required
404Resource not found in the caller's tenant (also returned for malformed UUIDs on most ID lookups, to avoid existence leaks)
409Incident already resolved; tenant already exists
422Pydantic validation failures; invalid enum values (pattern_type, status, severity, change_type, environment, group_by_field); invalid ISO-8601 datetimes; analyst decision status not OPEN/CLOSED
429Rate limit exceeded (slowapi)
503/health when the database is unreachable

Health & Ops

GET /health

Liveness + DB connectivity check (SELECT 1). No auth.

Response: {"status": "ok"} or 503 {"status": "error", "db": "unavailable"}.

bash
curl -s "$SOC_API_URL/health"

Source: app/main.py

GET /robots.txt

Returns Disallow: / for all crawlers. No auth. Source: app/main.py


Incidents API

All endpoints below are under /api/incidents. Source: app/routers/incidents.py

POST /api/incidents/

Create an incident (SecurityAgent ingest path). Performs ioc_key dedup/upsert: if an incident with the same tenant_id + ioc_key exists with status NEW or OPEN (and same environment when provided), it is updated in place — occurrence_count incremented, last_seen_at/title/summary/severity refreshed, OPEN reset to NEW — instead of creating a new row. New incidents get a per-tenant sequential reference_id (INC-0001, …) via an auto-created Postgres sequence, and outbound webhooks fire (incident.created, fail-open).

Auth: X-API-Key only. Rate limit: 300/minute. Status: 201.

Request body (IncidentCreate):

FieldTypeRequiredDescription
tenant_idstring (≤64)yesTenant slug; must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$ (else 400)
titlestring (≤512)yesIncident title
severityenumyesCRITICAL | HIGH | MEDIUM | LOW
incident_typestring (≤256)noFree-form type label
summarystringnoStored as security_agent_summary
descriptionstringnoLong-form description
ioc_keystring (≤256)noDedup key (see IOC Key Specification)
affected_resourcestring (≤512)noResource name
actorstring (≤512)noAdversary/actor identity
targetstring (≤512)noTarget of the action
actionstring (≤256)noAction observed
source_agentslist[string]no (default [])Agents that produced the signal
correlated_iocslist[string]no (default [])Related IOCs
recommended_actionslist[string]no (default [])Suggested response steps
raw_payloaddictno (default {})Raw evidence blob
detected_atdatetime (ISO)noDefaults to now
environmentstringnoPROD | DDE | AWS | HOME | OTHER
source_systemstring (≤256)noOriginating log system
mitre_techniquestring (≤16)noe.g. T1078
mitre_tacticstring (≤64)noe.g. Initial Access
mitre_techniqueslist[string]no (default [])Multiple techniques
tagslist[string]no (default [])Free-form tags
agent_reasoningdictnoSecurityAgent decision rationale

Response: {"id": "<uuid>", "status": "NEW", "action": "created"} or {"id": ..., "status": ..., "action": "updated"} on dedup.

Errors: 400 (invalid tenant_id format / scope), 401, 403, 422, 429.

bash
curl -s -X POST "$SOC_API_URL/api/incidents/" \
  -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  -d '{"tenant_id":"cirius","title":"Brute force against VPN portal","severity":"HIGH",
       "ioc_key":"identity-PROD-203.0.113.7","environment":"PROD",
       "source_agents":["identity"],"mitre_technique":"T1110","mitre_tactic":"Credential Access"}'

GET /api/incidents/summary

Counts by severity, status, and SLA state. Designed for M2M monitoring/alerting.

Auth: X-API-Key only.

Query params: tenant_id (optional — omit or pass * to aggregate across all tenants the key can see).

Response: {"total": n, "open": n, "by_severity": {...}, "by_status": {...}, "by_sla_state": {...}, "tenant_filter": "..."}. Errors: 401, 403.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/summary?tenant_id=cirius"

GET /api/incidents/open

List OPEN incidents, newest first. Auth: X-API-Key only.

Query params: tenant_id (required), environment (optional enum). Response: array of incident summary objects:

json
{"id":"…","tenant_id":"cirius","title":"…","severity":"HIGH","status":"OPEN",
 "incident_type":"…","reference_id":"INC-0042","ioc_key":"…","source_agents":["…"],
 "affected_resource":"…","actor":"…","environment":"PROD","occurrence_count":3,
 "phi_exposure":null,"assigned_to":null,"sla_state":"ON_TRACK","sla_deadline":"…",
 "detected_at":"…","last_seen_at":"…","updated_at":"…"}
bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/open?tenant_id=cirius&environment=PROD"

GET /api/incidents/pending-analyst

List NEW incidents (oldest first) awaiting analyst triage, with target, action, description, correlated_iocs, and raw_payload added to each summary. Auth: X-API-Key only. Query: tenant_id (required).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/pending-analyst?tenant_id=cirius"

GET /api/incidents/newly-opened

List OPEN incidents whose opened_at >= after. Auth: X-API-Key only.

Query: tenant_id (required), after (required, ISO 8601 — 422 if invalid), environment (optional). Response: incident summaries + raw_payload.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/incidents/newly-opened?tenant_id=cirius&after=2026-06-09T00:00:00Z"

GET /api/incidents/unenriched-open

List OPEN incidents opened/detected in the window [before - lookback_hours, before) that have not yet received a [Detection Agent …] enrichment in actions_taken. Auth: X-API-Key only.

Query: tenant_id (required), before (required ISO 8601 — 422 if invalid), lookback_hours (default 24, clamped 1–168), environment (optional).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/incidents/unenriched-open?tenant_id=cirius&before=2026-06-10T12:00:00Z&lookback_hours=24"

GET /api/incidents/resolved

Last 50 RESOLVED incidents, optionally filtered by exact incident_type and/or affected_resource. Auth: X-API-Key only. Query: tenant_id (required), incident_type, affected_resource.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/resolved?tenant_id=cirius"

GET /api/incidents/closed-patterns

Recent CLOSED incidents for pattern learning. Auth: X-API-Key only. Query: tenant_id (required), limit (default 500, clamped 1–2000).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/closed-patterns?tenant_id=cirius&limit=500"

GET /api/incidents/kg-queue

OPEN incidents whose Detection Agent enrichment recommends KNOWN_GOOD (parsed from actions_taken via regex [Detection Agent — KNOWN_GOOD]). Auth: X-API-Key only. Query: tenant_id (required), environment (optional).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/kg-queue?tenant_id=cirius"

GET /api/incidents/

General incident list with filters. Auth: X-API-Key or CF JWT.

Query params (all sanitized with bleach; invalid enum values silently ignored):

ParamDefaultDescription
tenant_id— (required)Tenant slug
severityEnum member name
statusEnum member name
qSubstring match across title/description/actor/affected_resource (≤128 chars)
mitre_techniqueMatches mitre_technique or membership in mitre_techniques
incident_typeILIKE substring
environmentEnum member name
assigned_toExact match
tagTag membership
since / untilISO 8601 bounds on detected_at (silently ignored if invalid)
limit500Capped at 2000

Response: array of incident summary objects (shape above).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/incidents/?tenant_id=cirius&severity=HIGH&status=OPEN&limit=100"

GET /api/incidents/export

Export incidents as CSV (default) or JSONL for compliance/audit. Same filters as the list endpoint plus sla_state; max 50,000 rows. CSV/JSONL responses set Content-Disposition: attachment; filename="incidents-{tenant}-{YYYYMMDD}.{ext}".

Auth: X-API-Key or CF JWT.

Query: tenant_id (required), format (csv|jsonl, default csv), severity, status, incident_type, environment, assigned_to, sla_state, since, until, limit (default 10000, 1–50000).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/incidents/export?tenant_id=cirius&format=csv&since=2026-05-01T00:00:00Z" -o incidents.csv

GET /api/incidents/actor-summary

Adversary actor rollup over the last days (default 90, clamped 1–365): per-actor count, open count, SLA breaches, severity histogram, top-5 MITRE tactics/techniques. Auth: X-API-Key or CF JWT. Query: tenant_id (required), days.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/actor-summary?tenant_id=cirius&days=90"

GET /api/incidents/type-summary

Per-incident-type count, SLA breach rate, mean MTTD (hours), and top tactics over days (default 90, clamped 1–365). Auth: X-API-Key or CF JWT. Query: tenant_id (required), days.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/type-summary?tenant_id=cirius"

GET /api/incidents/detection-efficacy

Per-source-agent alert quality: volume, severity distribution, resolved count, SLA breaches, and low_ratio (LOW+INFORMATIONAL share, a false-positive proxy). Auth: X-API-Key or CF JWT. Query: tenant_id (required), days (default 30, 1–365).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/detection-efficacy?tenant_id=cirius&days=30"

GET /api/incidents/suppression-effectiveness

Per-known-good-rule suppression counts with reopen count as FP proxy (fp_rate = reopened / suppressed). Auth: X-API-Key or CF JWT. Query: tenant_id (required), days (default 30, 1–365).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/suppression-effectiveness?tenant_id=cirius"

GET /api/incidents/

Full incident detail (summary fields plus description, security_agent_summary, actions_taken, correlated_iocs, recommended_actions, raw_payload, MITRE fields, tags, close_reason, closed_by, resolved_at, opened_at). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404 (bad UUID, missing, or wrong tenant).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/$INC_ID?tenant_id=cirius"

PATCH /api/incidents/

Partial update; writes an IncidentAudit row (action: "patch"). Auth: X-API-Key or CF JWT. Query: tenant_id (required).

Request body (IncidentPatch — all optional):

FieldTypeRequiredDescription
statusenumnoNEW/OPEN/CLOSED/FIX_IN_PROGRESS/INVESTIGATING/RESOLVED
actions_takenstringnoTruncated at 4096
phi_exposureboolnoPHI flag
assigned_tostringnoTruncated at 256
ioc_keystringnoTruncated at 256

Response: {"id": "...", "status": "..."}. Errors: 401, 403, 404, 422.

bash
curl -s -X PATCH -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/$INC_ID?tenant_id=cirius" \
  -d '{"status":"INVESTIGATING","assigned_to":"analyst@ciriusgroup.com"}'

POST /api/incidents/{incident_id}/resolve

Mark an incident RESOLVED (sets resolved_at). Auth: CF JWT only (browser). Query: tenant_id (required). Errors: 404, 409 Already resolved.

bash
# Browser path — Cloudflare Access injects the JWT; shown here with a CF service token assertion
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" \
  "https://soc.bedrockcybersecurity.org/api/incidents/$INC_ID/resolve?tenant_id=cirius"

POST /api/incidents/bulk-reset-new

Reset OPEN incidents back to NEW (all of them, or a supplied ID list). Orchestrator re-triage tool. Auth: X-API-Key only. Query: tenant_id (required).

Body (BulkResetNewIn): ids — list[string] (UUIDs), optional; omit to reset every OPEN incident.

Response: {"reset": n}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/bulk-reset-new?tenant_id=cirius" -d '{"ids":null}'

POST /api/incidents/refresh-sla

Recompute SLA state for OPEN/INVESTIGATING incidents with a deadline; returns incidents that newly entered AT_RISK/BREACHED (and stamps sla_alerted_at so they alert once). Auth: X-API-Key only. Query: tenant_id (required).

Response: {"updated": n, "newly_at_risk": [{id,title,severity,sla_state,sla_deadline,reference_id}]}.

SLA deadlines (from _SLA_HOURS): CRITICAL 4h · HIGH 24h · MEDIUM 72h · LOW 168h. AT_RISK begins at 25% of the window remaining.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/refresh-sla?tenant_id=cirius"

POST /api/incidents/bulk-resolve

Resolve a list of incidents, setting actions_taken to resolution_note. Auth: X-API-Key only. Query: tenant_id (required).

Body (BulkResolveIn):

FieldTypeRequiredDescription
idslist[string]yesIncident UUIDs (invalid/foreign/already-resolved IDs are counted as skipped)
resolution_notestringno (default "Resolved by automated cleanup")Written to actions_taken

Response: {"resolved": n, "skipped": n}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/bulk-resolve?tenant_id=cirius" \
  -d '{"ids":["'$INC_ID'"],"resolution_note":"Confirmed benign after review"}'

POST /api/incidents/{incident_id}/analyst-decision

Record the analyst agent's triage decision: open or close. Skips (returns "action": "skipped") if the incident is already CLOSED, or already OPEN when the decision isn't CLOSED. Opening stamps opened_at, the SLA deadline, and ON_TRACK state. Closing with a known_good_rule_id increments that rule's hit counters; closing without one persists and returns a suggested_rule (pre-fill for a known-good suppression proposal). Fires incident.resolved/incident.updated webhooks (fail-open). Writes an IncidentAudit row (decision:{status}).

Auth: X-API-Key only. Query: tenant_id (required).

Body (AnalystDecision):

FieldTypeRequiredDescription
statusenumyesMust be OPEN or CLOSED (else 422)
closed_bystringnoTruncated at 256
close_reasonstringnoFree text
known_good_rule_idstringnoRule credited for the suppression
cm_ticket_idstringnoChange record that explains the activity
analyst_confidencefloatnoClamped 0.0–1.0

Response: {"id": ..., "status": ..., "action": "decided", "suggested_rule": {...}|null}. Errors: 404, 422.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/$INC_ID/analyst-decision?tenant_id=cirius" \
  -d '{"status":"CLOSED","closed_by":"analyst_agent","close_reason":"Known scheduled job","analyst_confidence":0.92}'

POST /api/incidents/{incident_id}/comments

Add a comment (bleached, ≤4096 chars; 400 if empty after sanitization). Writes an audit row. Auth: CF JWT only. Query: tenant_id (required). Status: 201.

Body: body — string (min 1), required.

Response: {"id", "author_email", "body", "created_at"}.

bash
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" -H "Content-Type: application/json" \
  "https://soc.bedrockcybersecurity.org/api/incidents/$INC_ID/comments?tenant_id=cirius" -d '{"body":"Escalating to network team"}'

GET /api/incidents/{incident_id}/comments

List comments, oldest first. Auth: CF JWT only. Query: tenant_id (required).

GET /api/incidents/{incident_id}/audit

List the incident's audit trail entries ({id, actor, action, detail, created_at}), oldest first. Auth: CF JWT only. Query: tenant_id (required).

GET /api/incidents/{incident_id}/adjacent

Previous/next incident IDs+titles by detected_at within the tenant (detail-page navigation). Auth: CF JWT only. Query: tenant_id (required). Response: {"prev": {id,title}|null, "next": {...}|null}.

POST /api/incidents/{incident_id}/detection-enrichment

Detection agent writes its enrichment verdict into actions_taken (≤4096). Auth: X-API-Key only. Query: tenant_id (required).

Body: actions_taken — string, required; conventionally prefixed [Detection Agent — VERDICT].

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/$INC_ID/detection-enrichment?tenant_id=cirius" \
  -d '{"actions_taken":"[Detection Agent — KNOWN_GOOD] Matches backup window pattern"}'

POST /api/incidents/{incident_id}/links

Link two incidents — creates symmetric edges (A→B and B→A), idempotent on existing edges. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Status: 201.

Body (LinkPayload):

FieldTypeRequiredDescription
linked_idstring (UUID)yesTarget incident (same tenant)
link_typeenumno (default RELATED)LinkType value

Errors: 400 (invalid UUID / self-link), 404 (either incident missing in tenant).

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/incidents/$INC_ID/links?tenant_id=cirius" -d '{"linked_id":"'$OTHER_ID'","link_type":"RELATED"}'

List linked incidents with link metadata. Auth: X-API-Key or CF JWT. Query: tenant_id (required).

Remove the link in both directions. Auth: CF JWT only. Query: tenant_id (required). Status: 204. Errors: 400 (invalid UUID), 404.

GET /api/incidents/{incident_id}/ioc-matches

Return active threat-intel IOCs whose ioc_value appears (case-insensitive substring) anywhere in the incident's title, description, or source-agents list. Tenant-scoped; returns [] when nothing matches. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 422 (invalid UUID), 404.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/incidents/$INC_ID/ioc-matches?tenant_id=cirius"

Bulk Findings Ingest

Source: app/routers/ingest.py

POST /api/ingest/findings

Bulk-ingest normalized findings (the secops_client.post_ingest_findings() path). Each finding is deduped on ioc_key = "{source}:{source_id}" against NEW/OPEN incidents; matches bump occurrence_count, refresh title/severity/description, and merge raw_payload. New incidents get incident_type: "finding" and CMDB PHI enrichment: the affected resource's hostname is looked up in the asset registry and phi_exposure=true is stamped if the asset is phi_adjacent.

Auth: X-API-Key only. Rate limit: 300/minute. Max batch: 500 findings (400 above that). Empty list returns {"created":0,"updated":0,"total":0}.

Request body: JSON array of FindingIn:

FieldTypeRequiredDescription
sourcestring (≤128)yesSource feed/tool name (first half of ioc_key)
source_idstring (≤256)yesStable ID within the source (second half of ioc_key)
titlestring (≤512)yesFinding title
severitystringno (default MEDIUM)Mapped case-insensitively to IncidentSeverity; unknown values fall back to MEDIUM
descriptionstringnoTruncated at 2048
affected_resourcestring (≤512)noResource/hostname
source_systemstring (≤32)noe.g. PROD/DDE/AWS
resource_display_namestring (≤512)noStored in raw_payload
agent_namestring (≤128)noDefaults to source for source_agents
mitre_techniquestring (≤16)noATT&CK technique
mitre_tacticstring (≤64)noATT&CK tactic
tenant_idstring (≤64)no (default cirius)Per-finding tenant; scope-checked against the caller

Response: {"created": n, "updated": n, "total": n}. Errors: 400 (batch >500), 401, 403, 422, 429.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/ingest/findings" \
  -d '[{"source":"ghas","source_id":"repo1-alert-77","title":"Hardcoded secret in repo1",
        "severity":"HIGH","affected_resource":"repo1","tenant_id":"cirius"}]'

Known-Good Suppression Rules

All API endpoints under /api/known-good. Every endpoint is tenant-scoped. Source: app/routers/known_good.py

GET /api/known-good

List suppression rules (agents call this on startup). Auth: X-API-Key or CF JWT.

Query: tenant_id (required), active_only (default true).

Response: array of rule objects: {id, tenant_id, name, description, pattern_type, pattern_value, applies_to, active, status, hit_count, hit_count_7d, hit_count_30d, last_hit_at, stale, days_since_last_hit, source_system, suggested_by, suggestion_note, reviewed_by, reviewed_at, created_at, created_by, updated_at, updated_by}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/known-good?tenant_id=cirius"

POST /api/known-good

Create a suppression rule. Rejects break-glass identifiers with a hard 400 (see Break-Glass Hard Block). Auth: X-API-Key or CF JWT. Status: 201.

Body (RuleIn):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
namestringyesTruncated 256; bleached
descriptionstringnoTruncated 4096
pattern_typestringyesIP | ASN | USER | RESOURCE | SERVICE | PATTERN (422 otherwise)
pattern_valuestringyesTruncated 512; 422 if empty; 400 if break-glass
applies_tolist[string]no (default ["all"])Agent names the rule applies to
source_systemstringnoPROD/DDE/AWS; anything else → NULL (= ALL)
statusstringno (default ACTIVE)ACTIVE or PENDING (engineer proposals); 422 otherwise

Response: the full rule object. Errors: 400 (break-glass), 401, 403, 422.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/known-good" \
  -d '{"tenant_id":"cirius","name":"Nightly backup service account","pattern_type":"USER",
       "pattern_value":"svc-backup@ciriusgroup.com","applies_to":["identity"],"source_system":"PROD"}'

PATCH /api/known-good/

Partial update; same validation as create, including the break-glass 400 on pattern_value. Auth: X-API-Key or CF JWT. Query: tenant_id (required).

Body (RulePatch, all optional): name, description, pattern_type, pattern_value, applies_to, source_system, active (bool), status.

Errors: 400 (break-glass), 404, 422.

bash
curl -s -X PATCH -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/known-good/$RULE_ID?tenant_id=cirius" -d '{"active":false}'

POST /api/known-good/{rule_id}/hit

Fire-and-forget hit counter increment (agents call on every rule match). Note: never 404s — returns {"ok": false, "detail": ...} on bad/unknown IDs. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Response: {"ok": true}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/known-good/$RULE_ID/hit?tenant_id=cirius"

POST /api/known-good/reset-weekly-counters

Zero hit_count_7d for all rules in the tenant (weekly orchestrator job). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Response: {"ok": true, "reset": n}.

GET /api/known-good/stale

Active rules that haven't matched in ≥ days days (default 90, 1–3650). Rules younger than the threshold are never returned. Auth: X-API-Key or CF JWT. Query: tenant_id (required), days. Response: {tenant_id, threshold_days, stale_count, rules: [...]}.

POST /api/known-good/prune-stale

Deactivate (active=false, updated_by="system:prune-stale") all stale rules. Weekly orchestrator maintenance call. Auth: X-API-Key or CF JWT. Query: tenant_id (required), days (default 90, 1–3650). Response: {"ok": true, tenant_id, threshold_days, pruned, rules: [{id,name,days_since_last_hit}]}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/known-good/prune-stale?tenant_id=cirius&days=90"

GET /api/known-good/stats

Effectiveness summary: totals, enabled/disabled, counts by pattern type, total hits, never-triggered count, top-10 rules by hit_count. Auth: X-API-Key or CF JWT. Query: tenant_id (required).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/known-good/stats?tenant_id=cirius"

Browser-only rule workflow routes (/known-good/new, /suggest, /{id}/toggle, /{id}/approve, /{id}/reject, /{id}/delete) are HTML-form endpoints — see Browser UI Routes.


Change Management

All under /api/changes. Source: app/routers/change_management.py

POST /api/changes/

Create a change record (GitHub Actions posts one on every merged PR via ingest-changes.yml). Auth: X-API-Key or CF JWT. Status: 201.

Body (ChangeCreate):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
titlestringyesTruncated 512
change_typestringyesTERRAFORM | SCRIPT | MANUAL | HISTORICAL (422 otherwise)
statusstringno (default OPEN)HISTORICAL | OPEN | APPROVED | APPLIED
repostringnoTruncated 256
branchstringnoTruncated 256
commit_shastringnoTruncated 64
commit_messagestringnoTruncated 8192
actorstringnoTruncated 256
pr_urlstringnoTruncated 512
affected_systemslistno (default [])Sanitized strings, each ≤256
descriptionstringnoTruncated 8192
applied_atstring (ISO)no422 if invalid; naive datetimes treated as UTC
created_bystringnoTruncated 256

Response: the full change object ({id, tenant_id, title, change_type, status, repo, branch, commit_sha, commit_message, actor, pr_url, affected_systems, description, applied_at, created_at, created_by}).

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/changes/" \
  -d '{"tenant_id":"cirius","title":"azure-infra PR #312 — NSG rule update","change_type":"TERRAFORM",
       "status":"APPLIED","repo":"azure-infra","commit_sha":"abc1234","actor":"rgarshol",
       "affected_systems":["CIRIUS-SVCS-FW"],"applied_at":"2026-06-10T14:00:00Z"}'

GET /api/changes/

List change records, newest first. Auth: X-API-Key or CF JWT.

Query: tenant_id (required), status, type, actor (exact), repo (exact), after/before (ISO 8601 — filter on applied_at, falling back to created_at when applied_at is NULL; 422 if invalid).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/changes/?tenant_id=cirius&status=APPLIED&repo=azure-infra"

GET /api/changes/recent

Change records touching a given system (bidirectional case-insensitive substring match against affected_systems) within the last minutes. Excludes HISTORICAL. Used by the analyst agent for CM-ticket suppression. Auth: X-API-Key or CF JWT.

Query: system (required), tenant_id (required), minutes (default 480, 1–43200).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/changes/recent?tenant_id=cirius&system=CIRIUS-SVCS-FW&minutes=480"

GET /api/changes/

Single change record. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

PATCH /api/changes/

Partial update (ChangeUpdate — same optional fields as create minus tenant_id/created_by). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404, 422.

bash
curl -s -X PATCH -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/changes/$CHANGE_ID?tenant_id=cirius" -d '{"status":"APPLIED"}'

Maintenance Windows

All under /api/maintenance. Source: app/routers/maintenance.py

GET /api/maintenance/active

Windows currently in progress (start_at <= now <= end_at) for the tenant. The analyst agent calls this every cycle to suppress alerts; callers must fail open (treat any HTTP error as [] — no suppression). Auth: X-API-Key or CF JWT. Query: tenant_id (required).

Response: array of {id, tenant_id, title, start_at, end_at, affected_systems, notes, created_by, created_at}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/maintenance/active?tenant_id=cirius"

POST /api/maintenance/

Create a window. Auth: X-API-Key or CF JWT. Status: 201.

Body (WindowCreate):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
titlestringyesTruncated 256; 422 if empty after sanitization
start_atstring (ISO)yes422 if invalid; naive = UTC
end_atstring (ISO)yesMust be after start_at (422)
affected_systemslist[string]no (default [])Sanitized
notesstringnoTruncated 4096
created_bystringnoDefaults to caller email/sub
bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/maintenance/" \
  -d '{"tenant_id":"cirius","title":"Patch Tuesday reboots","start_at":"2026-06-11T02:00:00Z",
       "end_at":"2026-06-11T06:00:00Z","affected_systems":["CIRIUS-DC1","CIRIUS-DC2"]}'

GET /api/maintenance/

List all windows for the tenant, newest start first. Auth: X-API-Key or CF JWT. Query: tenant_id (required).

PATCH /api/maintenance/

Partial update (WindowUpdate: title, start_at, end_at, affected_systems, notes — all optional; end-after-start re-validated). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404, 422.

DELETE /api/maintenance/

Delete a window. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Status: 204. Errors: 404.

bash
curl -s -X DELETE -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/maintenance/$WIN_ID?tenant_id=cirius"

Playbooks

API prefix /api. Source: app/routers/playbooks.py

GET /api/playbooks

List playbooks. Auth: X-API-Key or CF JWT. Query: tenant_id (required), incident_type (lowercased exact match), include_inactive (default false).

Response objects: {id, tenant_id, name, incident_type, description, steps, active, trigger_conditions, require_approval, created_at, updated_at, step_count}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/playbooks?tenant_id=cirius"

GET /api/playbooks/runs

List playbook runs, newest first. Auth: X-API-Key or CF JWT. Query: tenant_id (required), incident_id (UUID), status (lowercased), limit (default 50, 1–200).

Response: {id, tenant_id, playbook_id, incident_id, status, triggered_by, approved_by, approved_at, started_at, completed_at, error}.

GET /api/playbooks/runs/

Run detail including per-step results (step_index, action_type, status, result, executed_at, actor). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

POST /api/playbooks/runs/{run_id}/approve

Approve a pending_approval run and execute it (delegates to agents.playbook_executor.PlaybookExecutor). Auth: CF JWT only. Query: tenant_id (required). Response: {"run_id": ..., "status": "running"}. Errors: 404 (not found or not pending approval).

GET /api/playbooks/

Playbook detail. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

POST /api/playbooks

Create a playbook. Auth: X-API-Key or CF JWT. Status: 201.

Body (PlaybookCreate):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
namestringyesTruncated 256
incident_typestringnoTruncated 256
descriptionstringnoTruncated 2000
stepslist[dict]no (default [])Each becomes {index, title (≤256), description (≤4096)}
trigger_conditionsdictnoAuto-trigger criteria
require_approvalboolno (default false)Gate runs behind human approval
bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/playbooks" \
  -d '{"tenant_id":"cirius","name":"Compromised account response","incident_type":"identity",
       "steps":[{"title":"Disable account"},{"title":"Revoke sessions"},{"title":"Review sign-in logs"}]}'

POST /api/playbooks/{playbook_id}/toggle

Flip the active flag. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Response: {"id": ..., "active": bool}. Errors: 404.

POST /api/incidents/{incident_id}/playbook/{playbook_id}/step/

Toggle per-incident step completion (verifies both incident and playbook belong to the tenant). Auth: X-API-Key or CF JWT. Query: tenant_id (required). Response: {"step_index": n, "completed": bool}. Errors: 400 (invalid step index), 404.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/incidents/$INC_ID/playbook/$PB_ID/step/0?tenant_id=cirius"

Threat Intel

API prefix /api/threat-intel. Two subsystems: the curated IOC library (ThreatIntel) and the feed enrichment cache (ThreatIntelCache, written by the threat_intel agent, one row per tenant+ioc_value+feed). Source: app/routers/threat_intel.py

GET /api/threat-intel

List curated IOCs. Auth: X-API-Key or CF JWT. Query: tenant_id (required), ioc_type, severity, include_inactive (default false).

Response objects: {id, tenant_id, title, ioc_type, ioc_value, severity, confidence, source, description, tags, tlp, active, expires_at, created_at, updated_at, created_by}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/threat-intel?tenant_id=cirius&ioc_type=IP"

GET /api/threat-intel/

Single IOC. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

POST /api/threat-intel

Create an IOC. Auth: X-API-Key or CF JWT. Status: 201.

Body (ThreatIntelCreate):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
titlestringyesTruncated 512
ioc_typeenumyesIP | DOMAIN | URL | HASH | EMAIL | CVE | OTHER
ioc_valuestringyesTruncated 512
severityenumyesCRITICAL | HIGH | MEDIUM | LOW | INFO
confidencefloatnoClamped 0.0–1.0
sourcestringnoTruncated 256
descriptionstringnoTruncated 4000
tagslist[string]no (default [])Max 20, each ≤64
tlpenumno (default GREEN)WHITE | GREEN | AMBER | RED
bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/threat-intel" \
  -d '{"tenant_id":"cirius","title":"Known C2 IP","ioc_type":"IP","ioc_value":"198.51.100.23",
       "severity":"HIGH","source":"CISA KEV","tlp":"AMBER"}'

POST /api/threat-intel/{intel_id}/toggle

Flip active. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Response: {"id": ..., "active": bool}. Errors: 404.

POST /api/threat-intel/cache

Upsert a feed enrichment result (conflict key: tenant_id, ioc_value, feed_name — PostgreSQL ON CONFLICT DO UPDATE). Called by the threat_intel agent. Auth: X-API-Key only. Status: 201.

Body (CacheEntryIn):

FieldTypeRequiredDescription
tenant_idstring (≤64)yesTenant slug
ioc_typestring (≤32)yesIOC type label
ioc_valuestring (≤512)yesThe IOC
feed_namestring (≤64)yese.g. abuseipdb, virustotal, hibp, osv, epss, tor, shodan
scoreintnoFeed score
maliciousboolnoFeed verdict
resultdictnoRaw feed result
expires_atstring (ISO)noInvalid values silently ignored

Response: {"status": "ok"}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/threat-intel/cache" \
  -d '{"tenant_id":"cirius","ioc_type":"IP","ioc_value":"198.51.100.23","feed_name":"abuseipdb","score":97,"malicious":true}'

GET /api/threat-intel/cache

Query cache entries. Auth: X-API-Key or CF JWT. Query: tenant_id (required), ioc_values (comma-separated, max 50 honored), feed_name. Newest first.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" \
  "$SOC_API_URL/api/threat-intel/cache?tenant_id=cirius&ioc_values=198.51.100.23,evil.example.com"

Detections

API prefix /api. Manual rules plus agent-registered MITRE coverage rows. Source: app/routers/detections.py

GET /api/detections

List detection rules. Auth: X-API-Key or CF JWT. Query: tenant_id (required), rule_type, severity, include_disabled (default false).

Response objects: {id, tenant_id, name, description, rule_type, rule_content, severity, incident_type, enabled, mitre_technique, mitre_tactic, agent_name, hit_count, hit_count_7d, hit_count_30d, not_applicable, inapplicable_reason, last_hit_at, created_at, updated_at, created_by}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/detections?tenant_id=cirius"

GET /api/detections/

Single rule. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

POST /api/detections

Create a manual detection rule. Auth: X-API-Key or CF JWT. Status: 201.

Body (DetectionCreate):

FieldTypeRequiredDescription
tenant_idstringyesTenant slug
namestringyesTruncated 256
descriptionstringnoTruncated 4000
rule_typeenumyesSIGMA | YARA | REGEX | BEHAVIORAL | THRESHOLD | AGENT_QUERY | OTHER
rule_contentstringyesBleached, unbounded
severityenumyesCRITICAL/HIGH/MEDIUM/LOW
incident_typestringnoTruncated 256
bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/detections" \
  -d '{"tenant_id":"cirius","name":"Impossible travel","rule_type":"BEHAVIORAL",
       "rule_content":"signin distance/time > threshold","severity":"HIGH"}'

POST /api/detections/{detection_id}/toggle

Flip enabled. Auth: X-API-Key or CF JWT. Query: tenant_id (required). Errors: 404.

POST /api/detection/queries

Upsert agent MITRE-mapped queries into the coverage table (conflict key: tenant_id, agent_name, mitre_technique; hit counters incremented by findings_this_run). Called by agents at startup via secops_client.register_queries(). Entries without mitre_technique are skipped. Auth: X-API-Key only.

Body (AgentQueriesIn):

FieldTypeRequiredDescription
tenant_idstring (≤64)yesTenant slug
agent_namestring (≤128)yesRegistering agent
querieslistyesItems: description (string ≤512, required), mitre_technique (≤32), mitre_tactic (≤64), source_system (≤32), severity (default HIGH; unknown → HIGH), findings_this_run (int ≥0, default 0)

Response: {"registered": n, "agent": "..."}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/detection/queries" \
  -d '{"tenant_id":"cirius","agent_name":"identity","queries":[
        {"description":"Failed sign-in burst per UPN","mitre_technique":"T1110","mitre_tactic":"Credential Access","findings_this_run":2}]}'

POST /api/detection/queries/na

Mark ATT&CK techniques as not-applicable for the tenant (updates existing coverage_seed rows only; rows are not created — the seed script must run first). Auth: X-API-Key only.

Body (NaBatchIn): tenant_id (string ≤64, required), entries (list of {mitre_technique (≤32, required), reason (≤512, required)}).

Response: {"marked": n}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/detection/queries/na" \
  -d '{"tenant_id":"cirius","entries":[{"mitre_technique":"T1583","reason":"Resource Development — outside monitoring boundary"}]}'

Metrics

All under /api/metrics. All accept X-API-Key or CF JWT, require tenant_id, and clamp days to 1–365. Source: app/routers/metrics.py

GET /api/metrics/

Main dashboard payload over days (default 30): total_incidents, by_severity, by_environment, by_status, top_agents / top_techniques / top_tactics / top_incident_types (top 10 each), sla_breach_count, sla_at_risk_count, suppression_rate (% of CLOSED with a known-good rule), mttd_hours, mttr_hours, daily_volume (zero-filled per day), agent_error_rate.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/metrics/?tenant_id=cirius&days=30"

GET /api/metrics/severity-breakdown

Per-severity count/open/resolved/closed/SLA-breached plus MTTD/MTTR hours. Query: tenant_id, days (default 30).

GET /api/metrics/analyst-workload

Per-analyst (by assigned_to, "unassigned" bucket) totals, open, resolved, SLA breached, MTTR. Query: tenant_id, days (default 30).

GET /api/metrics/phi-exposure

PHI-flagged incident summary: totals, open, SLA breaches + rate, by-severity, ISO-week trend. Query: tenant_id, days (default 30).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/metrics/phi-exposure?tenant_id=cirius&days=90"

GET /api/metrics/environment-breakdown

Per-environment count/open/resolved/SLA-breached/MTTR. Query: tenant_id, days (default 30).

GET /api/metrics/weekly-trend

One entry per ISO week over the last weeks (default 12): week, week_start, total, by_severity, sla_breached, mttd_hours, mttr_hours, resolved; plus avg_mttd_hours/avg_mttr_hours rollups. Query: tenant_id, weeks.

GET /api/metrics/soc-on-call-handoff

Per-analyst shift summary over shift_hours (default 12, 1–72): open, resolved, SLA breached/at-risk, avg MTTR. Query: tenant_id, shift_hours.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/metrics/soc-on-call-handoff?tenant_id=cirius&shift_hours=12"

Webhooks

All under /api/webhooks. Auth: CF Access JWT only — the ingest API key is not accepted on webhook CRUD. Outbound deliveries are fired automatically by the incidents router. Source: app/routers/webhooks.py

Valid events: incident.created, incident.updated, incident.resolved. Severity gate: deliveries fire only when the incident severity ≥ the webhook's min_severity (rank CRITICAL 4 > HIGH 3 > MEDIUM 2 > LOW 1).

Delivery body: {"event": "...", "incident": {<incident summary>}, "fired_at": "..."}. If a secret is configured, the delivery carries X-SOC-Signature: sha256=<HMAC-SHA256(secret, body)>. Delivery is fail-open (never blocks incident writes); last_fired_at, last_status, failure_count are tracked per webhook.

POST /api/webhooks/

Create a webhook for the caller's (first) tenant. Status: 201.

FieldTypeRequiredDescription
namestring (≤256)yesDisplay name
urlstring (≤1024)yesDestination URL
secretstring (≤256)noHMAC signing key
eventslist[string]no (default ["incident.created","incident.updated"])Must be valid events (400 otherwise)
min_severitystringno (default HIGH)CRITICAL/HIGH/MEDIUM/LOW (400 otherwise)
enabledboolno (default true)Active flag
bash
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" -H "Content-Type: application/json" \
  "https://soc.bedrockcybersecurity.org/api/webhooks/" \
  -d '{"name":"Teams alerting","url":"https://example.webhook.office.com/...","min_severity":"HIGH","secret":"s3cret"}'

GET /api/webhooks/

List webhooks for the tenant (defaults to the caller's first tenant; tenant_id query optional, scope-checked).

PATCH /api/webhooks/

Partial update (name, url, secret, events, min_severity, enabled). Errors: 400 (invalid UUID/events/severity), 404.

DELETE /api/webhooks/

Delete. Status: 204. Errors: 400, 404.


Agent Health

All under /api/agents. Source: app/routers/agent_health.py

POST /api/agents/runs

Agents report a run result. Auth: X-API-Key only. Status: 201.

FieldTypeRequiredDescription
tenant_idstring (≤64)yesTenant slug
agent_namestring (≤128)yesAgent identifier
environmentstring (≤32)noPROD/DDE/AWS/HOME
run_idstring (≤128)noCorrelates with orchestrator run
statusstringno (default ok)ok | error | skipped (anything else coerced to ok)
findings_countintno (default 0)Clamped ≥0
error_countintno (default 0)Clamped ≥0
duration_msintnoRun duration
last_errorstringnoTruncated 2048

Response: {"id": n, "status": "..."}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/agents/runs" \
  -d '{"tenant_id":"cirius","agent_name":"identity","environment":"PROD","status":"ok","findings_count":3,"duration_ms":41200}'

DELETE /api/agents/runs

Wipe agent run history (admin/maintenance use). Wildcard API keys operate on the cirius tenant. Auth: X-API-Key only. Query: agent_name (optional — omit to delete all runs for the tenant). Response: {"deleted": n, "agent_name": "...|*"}.

bash
curl -s -X DELETE -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/agents/runs?agent_name=identity"

GET /api/agents/health

Latest run per agent plus 24h aggregates and a health rollup. An agent is stale if its last run is older than 6 hours; status error counts as errored. Auth: X-API-Key or CF JWT. Query: tenant_id (required).

Response: {"agents": [{agent_name, environment, status, findings_count, error_count, duration_ms, last_error, last_run_at, is_stale, runs_24h, findings_24h}], "total_agents", "healthy", "errored", "stale", "as_of"}.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/agents/health?tenant_id=cirius"

Agent Decision Log

Prefix /api/agent-decisions. One structural record per LLM call in the agent pipeline; full prompt/response content lives in Azure Blob (blob_path), not in this API. Source: app/routers/decisions.py

POST /api/agent-decisions

Write one decision record. Auth: X-API-Key only. Status: 201.

FieldTypeRequiredDescription
llm_call_idstringyesUnique call ID
environmentstringyesPROD/DDE/AWS/HOME
agent_namestringyesCalling agent
modelstringyesModel identifier
action_takenstringyese.g. OPEN/CLOSE/SUPPRESS
scheduler_run_idstringnoOrchestrator run correlation
incident_idstringnoRelated incident
finding_idstringnoRelated finding
prompt_tokens / completion_tokensintnoToken usage
latency_msintnoCall latency
confidencefloatnoModel confidence
mitre_technique / mitre_tacticstringnoATT&CK mapping
blob_pathstringnoAzure Blob content-trace path

Response: {"id": "<uuid>"}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/agent-decisions" \
  -d '{"llm_call_id":"run42-call7","environment":"PROD","agent_name":"analyst_agent",
       "model":"gpt-5.2","action_taken":"CLOSE","confidence":0.91,"latency_ms":2300}'

GET /api/agent-decisions

Query structural records (no free text). Auth: X-API-Key, or X-Read-Token matching REVIEW_READ_TOKEN, or CF JWT.

Query: agent_name, environment, mitre_technique, days (default 30, 1–365), limit (default 100, 1–500). Newest first.

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/agent-decisions?agent_name=analyst_agent&days=7"

Tenants (Admin)

Prefix /api/tenants. Auth: CF JWT with the soc-admin role claim or an @ciriusgroup.com email (require_admin in this router). The ingest API key is not accepted. Source: app/routers/tenants.py

POST /api/tenants/

Create a tenant. Status: 201. tenant_id must match ^[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]$ (400 otherwise); duplicates → 409.

FieldTypeRequiredDescription
tenant_idstring (3–64)yesLowercase slug
display_namestring (≤256)yesHuman name
configdictno (default {})Tenant config blob
bash
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" -H "Content-Type: application/json" \
  "https://soc.bedrockcybersecurity.org/api/tenants/" -d '{"tenant_id":"acme","display_name":"Acme Corp"}'

GET /api/tenants/

List all tenants. Response objects: {id, tenant_id, display_name, status, config, created_at, updated_at}.

GET /api/tenants/

Single tenant by slug. Errors: 404.

PATCH /api/tenants/

Update display_name, status (ACTIVE/SUSPENDED/ARCHIVED), and/or config (shallow-merged). Errors: 404.


Bedrock Attack Campaigns

Prefix /api/attack-team. Not tenant-scoped. Source: app/routers/attack_team.py

This is the SecOps (bedrock-soc) campaign-summary ingest endpoint. Its path is /api/attack-team and is unchanged — it is distinct from the Bedrock Attack producer API (bedrock-attack), which serves /api/bedrock-attack with /api/attack-team as a legacy alias.

POST /api/attack-team/campaigns

Upsert a campaign summary (conflict key campaign_id; the row is overwritten). Posted by the Bedrock Attack remediation agent at campaign end. Auth: X-API-Key only. Status: 201.

FieldTypeRequiredDescription
campaign_idstringyesUpsert key
completed_atstring (ISO)yes422 if invalid
risk_score / risk_score_deltafloatnoCampaign risk
total_findings, urgent, high, medium, low, infointno (default 0)Finding counts
new_stories, updated_stories, suppressed, attack_paths, phi_findings, internet_findingsintno (default 0)Campaign stats
mttr_avg_days, mttr_p90_daysfloatnoRemediation timing
bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/attack-team/campaigns" \
  -d '{"campaign_id":"2026-06-W2","completed_at":"2026-06-10T08:00:00Z","risk_score":42.5,"total_findings":17,"high":4}'

GET /api/attack-team/campaigns

Recent campaigns, newest first. Auth: X-API-Key only. Query: limit (default 12, hard-capped 200).


CMDB Assets

Prefix /api/assets. Org-wide registry — not tenant-scoped; X-API-Key auth on every endpoint. Used by ops-automation cmdb_client.py and the redteam_grey agent. System names are normalized to uppercase. Source: app/routers/assets.py

Asset object: {id, system, display_name, environment, system_type, criticality, phi_adjacent, status, owner, sources, public_ips, last_seen_at, last_seen_by, first_seen_at, grade_vulns, grade_intune, grade_xdr, grade_pentest}.

GET /api/assets

List all assets. Query: environment (optional enum filter).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/assets?environment=PROD"

GET /api/assets/by-ip/

Find an asset whose public_ips contains the IP. Errors: 404. (Registered before /{system} so by-ip is never consumed as a system name.)

GET /api/assets/

Single asset by (uppercased) system name. Errors: 404.

POST /api/assets/{system}/sources/

Write a per-source data bucket; creates the asset if absent (status active, phi_adjacent=false) and sends an SES "new unclassified asset" alert when SES env vars are configured. Recalculates letter grades (A≥3.5, B≥2.5, C≥1.5, D≥0.5, else F; vuln grade = min of mdvm/inspector/mde scores), risk_score, and last_seen_at/by from all source buckets. Appends public_ip if new.

FieldTypeRequiredDescription
datadictyesSource-specific payload (may include score)
last_activitystringyesISO date of last observed activity
public_ipstringnoAppended to public_ips if new

Response: {"system": "...", "source": "...", "created": bool}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/assets/CIRIUS-WEB01/sources/mdvm" \
  -d '{"data":{"score":3.2,"exposed_cves":4},"last_activity":"2026-06-09","public_ip":"40.83.192.10"}'

PATCH /api/assets/{system}/classify

Set environment only if currently NULL; otherwise returns {"applied": false} without changing anything. Body: {"environment": "PROD"} (422 on invalid enum). Errors: 404, 422.

PATCH /api/assets/{system}/reclassify

Overwrite environment only if the current value is in from_systems. Body: {"environment": "DDE", "from_systems": ["PROD", null]}. Response includes applied and the current value when skipped. Errors: 404, 422.


Source: app/routers/events.py, app/routers/search.py

GET /api/events/stats

Aggregate stats for the TimescaleDB events hypertable: total events, unique actors, oldest/newest event, counts by log_source and source_system. Auth: X-API-Key, or CF JWT whose email is in SECOPS_ADMIN_EMAILS (else 403).

bash
curl -s -H "X-API-Key: $SOC_API_KEY" "$SOC_API_URL/api/events/stats"

POST /api/search/events

Paginated analyst query against the events table. Auth: CF JWT only. Note: not tenant-scoped — gated by CF Access membership.

Body (SearchQuery, all filters optional):

FieldTypeRequiredDescription
log_sourcestringnoExact match
source_systemstringnoExact match
event_typestringnoExact match
actor_upnstringnoCase-insensitive exact
actor_ipstringnoExact match
outcomestringnoUppercased exact
action_containsstringnoILIKE substring
target_containsstringnoILIKE substring
hours_backintno (default 24)1–168
limitintno (default 100)1–500
offsetintno (default 0)≥0

Response: {"total": n, "results": [{event_time, log_source, source_system, event_type, actor_upn, actor_ip, actor_country, actor_device, action, outcome, target_resource, risk_signals}], "query_ms": n}. The raw event field is never returned.

bash
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" -H "Content-Type: application/json" \
  "https://soc.bedrockcybersecurity.org/api/search/events" -d '{"actor_upn":"user@ciriusgroup.com","hours_back":48}'

GET /api/search/facets

Distinct log_sources, source_systems, event_types, outcomes for the window. Auth: CF JWT only. Query: hours_back (default 24, 1–168).

POST /api/search/save

Save a named search. Auth: CF JWT + admin (SECOPS_ADMIN_EMAILS), else 403. Status: 201. Body: {"name": "...", "query": {<SearchQuery>}}.

GET /api/search/saved

List all saved searches. Auth: CF JWT only.


Correlation Rules

Prefix /api/rules. Auth: CF JWT for reads; CF JWT + admin (SECOPS_ADMIN_EMAILS) for writes. Not tenant-scoped. Source: app/routers/rules.py

GET /api/rules

List correlation rules with computed hit_count_24h / hit_count_7d. Query: active (optional bool).

GET /api/rules/

Rule detail with hit counts. Errors: 404.

POST /api/rules

Create a rule (admin only). Status: 201.

FieldTypeRequiredDescription
namestringyesTruncated 256
descriptionstringnoTruncated 4096
mitre_techniquestringnoTruncated 32
severitystringyesCRITICAL/HIGH/MEDIUM/LOW (422 otherwise)
rule_typestringyesTHRESHOLD/SEQUENCE/STATISTICAL (422 otherwise)
event_filterdictnoEvent match criteria
threshold_count / threshold_window_minutesintnoTHRESHOLD config
group_by_fieldstringnoMust be actor_upn, actor_ip, or source_system (422 otherwise)
sequence_stepslistnoSEQUENCE config
sequence_window_minutesintnoSEQUENCE window
sequence_same_actorboolno (default true)Require same actor across steps

Response: {"id": "...", "created": true}. Errors: 403, 422.

bash
curl -s -X POST -H "Cf-Access-Jwt-Assertion: $CF_JWT" -H "Content-Type: application/json" \
  "https://soc.bedrockcybersecurity.org/api/rules" \
  -d '{"name":"Failed logon burst","severity":"HIGH","rule_type":"THRESHOLD",
       "event_filter":{"event_type":"signin","outcome":"FAILURE"},
       "threshold_count":20,"threshold_window_minutes":10,"group_by_field":"actor_upn"}'

PATCH /api/rules/

Partial update (admin only; same enum validation). Errors: 403, 404, 422.

POST /api/rules/{rule_id}/toggle

Flip active (admin only). Errors: 403, 404.

DELETE /api/rules/

Soft delete — sets active=false (admin only). Response: {"id": ..., "active": false}. Errors: 403, 404.

GET /api/rules/{rule_id}/hits

Paginated rule hits, newest first. Query: page (default 1), per_page (default 25, 1–100). Response: {"total", "page", "per_page", "hits": [{id, matched_at, actor_upn, actor_ip, event_count, evidence}]}.


Mini-Trigger

Prefix /api/mini-trigger. In-memory signal store for mini-orchestrator → big-orchestrator coordination. State is in-memory with a 10-minute TTL — a process restart clears it, causing at most one spurious extra big-O trigger (fail-safe direction). Source: app/routers/mini_trigger.py

POST /api/mini-trigger

Record which signals fired. Auth: X-API-Key only. Body: {"signals": ["..."], "env": "PROD"} (env defaults to PROD). Response: {"ok": true}.

bash
curl -s -X POST -H "X-API-Key: $SOC_API_KEY" -H "Content-Type: application/json" \
  "$SOC_API_URL/api/mini-trigger" -d '{"signals":["break_glass","identity"],"env":"PROD"}'

GET /api/mini-trigger/latest

Read the last trigger for cooldown comparison; returns {"signals": [], "env": "..."} if no entry or the entry is older than 10 minutes. Auth: X-API-Key only. Query: env (default PROD).


Browser UI Routes

Server-rendered HTML pages (Jinja2), authenticated by Cloudflare Access JWT. Listed for completeness — they are not part of the JSON API contract:

RoutePageSource
/, /incidents/…Incident queue and detailapp/routers/ui.py, app/routers/incidents_ui.py
/known-good/ (+ partial, new, suggest, {id}/toggle|approve|reject|delete)Suppression rule management (form posts; suggest/approve workflow for PENDING rules)app/routers/known_good.py
/changes/, /changes/{id}Change management list/detail (detail lists incidents suppressed via cm_ticket_id)app/routers/change_management.py
/maintenance/ (+ create/delete form posts)Maintenance windowsapp/routers/maintenance.py
/playbooks/, /playbooks/partial, /playbooks/{id}Playbooksapp/routers/playbooks.py
/threat-intel/IOC library + feed status panelapp/routers/threat_intel.py
/detections/Detection coverage (MITRE + STRIDE rollups)app/routers/detections.py
/metrics/Metrics dashboardapp/routers/metrics.py
/webhooks/Webhook managementapp/routers/webhooks.py
/agents/healthAgent health dashboardapp/routers/agent_health.py
/searchEvent search pageapp/routers/search.py
/rulesCorrelation rules pageapp/routers/rules.py

IOC Key Specification

ioc_key is the dedup identity for incidents: POST /api/incidents/ and POST /api/ingest/findings both upsert onto an existing NEW/OPEN incident in the same tenant with the same ioc_key (the incidents endpoint additionally matches environment when provided), bumping occurrence_count instead of creating a duplicate.

Construction, from the actual producing code:

  1. Ingest findings (app/routers/ingest.py): ioc_key = "{source}:{source_id}" (bleached; source ≤128, source_id ≤256).

  2. Agent findings (agents/secops_client.py::post_agent_findings):

    • Behavioral agents: "{agent_name}-{env_tag}-{primary_ioc}-{YYYY-MM-DD}"
    • Chronic agents: "{agent_name}-{env_tag}-{primary_ioc}" (no date)
    • env_tag = source_system or "PROD" (a warning is logged if source_system is missing). primary_ioc = first IOC, else affected_resource, else "unknown". The env tag exists because PROD/DDE/AWS share one database — it prevents cross-environment dedup collisions.
  3. Raw hits (agents/secops_client.py::post_raw_hit):

    • Chronic: "{agent_name}:{mitre_technique}" or "{agent_name}:{mitre_technique}:{ioc_key_suffix}"
    • Behavioral: same, with ":{YYYY-MM-DD}" appended.

Chronic vs behavioral (agents/agent_config.py): chronic agents represent persistent conditions (infrastructure health, identity/auth patterns, endpoint, DLP, lateral_movement, execution, etc. — see CHRONIC_AGENTS) — omitting the date keeps one long-lived incident per condition with occurrence_count communicating persistence. Behavioral agents (AWS/PROD kill-chain credential dumping, persistence, defense evasion, exfiltration) keep the date so each day's signal is a forensically distinct ticket. Per-call override: chronic=True|False on post_raw_hit() / post_agent_findings().


Break-Glass Hard Block

app/routers/known_good.py maintains a hard-coded break-glass account registry (BREAK_GLASS_ACCOUNTS / _BREAK_GLASS_NEEDLES) covering PROD and DDE break-glass mailboxes (cirius-breakglass@ciriusgroup.com, cirius-breakglass@ciriusdde.com, the break-glass-1/2@…onmicrosoft.com accounts, the DR account), all seven AWS account root users (by account ID), and the firewall-admin accounts on every Palo Alto firewall/Panorama.

Enforcement: is_break_glass() does a case-insensitive substring check of the proposed pattern_value against every needle. Any match on known-good rule create (API + UI form), patch, or suggest is rejected — API paths return 400 "Break-glass accounts cannot be added to known-good rules"; the UI suggest form redirects with error=break_glass. Break-glass activity therefore can never be suppressed by a known-good rule. (There is no equivalent block in any other router.)


Rate Limits

From app/rate_limit.py (slowapi) and the actual decorators:

  • Key function: the literal X-API-Key header value when present, otherwise the source IP. A stolen key is rate-limited as a single identity regardless of source IPs.
  • Limited endpoints (only these two):
    • POST /api/incidents/300/minute
    • POST /api/ingest/findings300/minute
  • All other endpoints have no application-level rate limit.
  • Exceeding a limit returns 429 via slowapi's standard handler (includes Retry-After).

Fail-Open Guidance

Documented fail-open behaviors in the codebase:

  • Outbound webhooks (fire_webhooks, app/routers/webhooks.py): never raises — DB or delivery failures are logged and swallowed; incident creation/decision endpoints succeed regardless. Per-webhook failure_count/last_status track delivery health.
  • Maintenance suppression (GET /api/maintenance/active docstring): agents must treat any HTTP error as [] — i.e., on failure, do not suppress alerts. Missing suppression produces noise; failed-closed suppression would hide real incidents.
  • Mini-trigger (app/routers/mini_trigger.py): in-memory state with a 10-minute TTL; a restart loses state and at worst causes one extra big-orchestrator trigger — fail-safe in the alerting direction.
  • Webhook delivery from incident writes is wrapped in try/except in app/routers/incidents.py with a non-fatal warning log ("Webhook delivery failed (non-fatal)").
  • CI guard related to fail-open code: the deploy workflow runs ruff --select F821 as a hard gate because fail-open agent code can swallow NameError at runtime (see deploy.yml comment).

Generated from the bedrock-soc repository source on 2026-06-10. Endpoint behavior, schemas, limits, and error codes are taken directly from app/main.py, app/auth.py, app/rate_limit.py, app/models.py, app/routers/*.py, and agents/secops_client.py / agents/agent_config.py.

Internal use only — Cirius Group