Appearance
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 Appca-soc-prod(resource grouprg-logging-logs, imagesoc-webfromciriusagentsprod.azurecr.io) · PostgreSQL Flexible Serverpsql-soc-prod(asyncpg; SQLite in dev/tests) · Alembic migrations Interactive docs:/api/docs(Swagger),/api/redoc,/api/openapi.jsonRewritten 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
- Common Headers
- Error Codes
- Health & Ops
- Incidents API (
/api/incidents) - Bulk Findings Ingest (
/api/ingest) - Known-Good Suppression Rules (
/api/known-good) - Change Management (
/api/changes) - Maintenance Windows (
/api/maintenance) - Playbooks (
/api/playbooks) - Threat Intel (
/api/threat-intel) - Detections (
/api/detections,/api/detection/queries) - Metrics (
/api/metrics) - Webhooks (
/api/webhooks) - Agent Health (
/api/agents) - Agent Decision Log (
/api/agent-decisions) - Tenants (Admin) (
/api/tenants) - Bedrock Attack Campaigns (
/api/attack-team) - CMDB Assets (
/api/assets) - Events & Search (
/api/events,/api/search) - Correlation Rules (
/api/rules) - Mini-Trigger (
/api/mini-trigger) - Browser UI Routes
- IOC Key Specification
- Break-Glass Hard Block
- Rate Limits
- Fail-Open Guidance
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_KEYenv var withsecrets.compare_digest(constant-time). - A valid key receives wildcard tenant scope (
tenant_ids: ["*"]) — it may act on any tenant named in the payload/querytenant_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 ofsoc.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-Assertionheader. The app validates this JWT; there are no login routes, no session cookies, no MSAL. - Validation (
app/auth.py): RS256 against the JWKS athttps://{CF_TEAM_DOMAIN}/cdn-cgi/access/certs(cached 5 minutes, lock-protected refresh), audience =CF_AUD. The app fails to start in production ifCF_AUDis unset. In local dev (noCF_AUD), audience verification is skipped. - Tenant scope comes from
soc-tenant-{id}role claims, read from any of the JWT fieldsgroups,roles, orcustom_claims. Fallback: any@ciriusgroup.comemail automatically receives theciriustenant. - CF service tokens are recognized (
type=appclaim, no email) and flaggedis_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)
| Dependency | Accepts |
|---|---|
require_api_key | X-API-Key only |
require_user | CF Access JWT only |
require_user_or_api_key | X-API-Key if the header is present, otherwise CF JWT |
require_admin_or_api_key | X-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_id→400 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
| Header | Direction | Purpose |
|---|---|---|
X-API-Key | request | Machine auth (INGEST_API_KEY) |
Cf-Access-Jwt-Assertion | request | Cloudflare Access JWT (injected by CF for browsers/service tokens) |
X-Read-Token | request | Read-only token for GET /api/agent-decisions (REVIEW_READ_TOKEN) |
Content-Type: application/json | request | All JSON bodies |
X-SOC-Signature | outbound webhook | sha256=<HMAC-SHA256 hex> of the delivery body, keyed with the webhook secret |
Retry-After | response | Sent by the slowapi 429 handler |
Security response headers are applied globally (nosniff, DENY framing, CSP, HSTS in production).
Error Codes
| Code | Meaning in this API |
|---|---|
| 400 | Missing/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 |
| 401 | Missing/invalid API key; missing/expired/invalid CF JWT |
| 403 | Cross-tenant access attempt; admin role required |
| 404 | Resource not found in the caller's tenant (also returned for malformed UUIDs on most ID lookups, to avoid existence leaks) |
| 409 | Incident already resolved; tenant already exists |
| 422 | Pydantic 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 |
| 429 | Rate 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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (≤64) | yes | Tenant slug; must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$ (else 400) |
title | string (≤512) | yes | Incident title |
severity | enum | yes | CRITICAL | HIGH | MEDIUM | LOW |
incident_type | string (≤256) | no | Free-form type label |
summary | string | no | Stored as security_agent_summary |
description | string | no | Long-form description |
ioc_key | string (≤256) | no | Dedup key (see IOC Key Specification) |
affected_resource | string (≤512) | no | Resource name |
actor | string (≤512) | no | Adversary/actor identity |
target | string (≤512) | no | Target of the action |
action | string (≤256) | no | Action observed |
source_agents | list[string] | no (default []) | Agents that produced the signal |
correlated_iocs | list[string] | no (default []) | Related IOCs |
recommended_actions | list[string] | no (default []) | Suggested response steps |
raw_payload | dict | no (default {}) | Raw evidence blob |
detected_at | datetime (ISO) | no | Defaults to now |
environment | string | no | PROD | DDE | AWS | HOME | OTHER |
source_system | string (≤256) | no | Originating log system |
mitre_technique | string (≤16) | no | e.g. T1078 |
mitre_tactic | string (≤64) | no | e.g. Initial Access |
mitre_techniques | list[string] | no (default []) | Multiple techniques |
tags | list[string] | no (default []) | Free-form tags |
agent_reasoning | dict | no | SecurityAgent 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):
| Param | Default | Description |
|---|---|---|
tenant_id | — (required) | Tenant slug |
severity | — | Enum member name |
status | — | Enum member name |
q | — | Substring match across title/description/actor/affected_resource (≤128 chars) |
mitre_technique | — | Matches mitre_technique or membership in mitre_techniques |
incident_type | — | ILIKE substring |
environment | — | Enum member name |
assigned_to | — | Exact match |
tag | — | Tag membership |
since / until | — | ISO 8601 bounds on detected_at (silently ignored if invalid) |
limit | 500 | Capped 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.csvGET /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):
| Field | Type | Required | Description |
|---|---|---|---|
status | enum | no | NEW/OPEN/CLOSED/FIX_IN_PROGRESS/INVESTIGATING/RESOLVED |
actions_taken | string | no | Truncated at 4096 |
phi_exposure | bool | no | PHI flag |
assigned_to | string | no | Truncated at 256 |
ioc_key | string | no | Truncated 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):
| Field | Type | Required | Description |
|---|---|---|---|
ids | list[string] | yes | Incident UUIDs (invalid/foreign/already-resolved IDs are counted as skipped) |
resolution_note | string | no (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):
| Field | Type | Required | Description |
|---|---|---|---|
status | enum | yes | Must be OPEN or CLOSED (else 422) |
closed_by | string | no | Truncated at 256 |
close_reason | string | no | Free text |
known_good_rule_id | string | no | Rule credited for the suppression |
cm_ticket_id | string | no | Change record that explains the activity |
analyst_confidence | float | no | Clamped 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):
| Field | Type | Required | Description |
|---|---|---|---|
linked_id | string (UUID) | yes | Target incident (same tenant) |
link_type | enum | no (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"}'GET /api/incidents/{incident_id}/links
List linked incidents with link metadata. Auth: X-API-Key or CF JWT. Query: tenant_id (required).
DELETE /api/incidents/{incident_id}/links/
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:
| Field | Type | Required | Description |
|---|---|---|---|
source | string (≤128) | yes | Source feed/tool name (first half of ioc_key) |
source_id | string (≤256) | yes | Stable ID within the source (second half of ioc_key) |
title | string (≤512) | yes | Finding title |
severity | string | no (default MEDIUM) | Mapped case-insensitively to IncidentSeverity; unknown values fall back to MEDIUM |
description | string | no | Truncated at 2048 |
affected_resource | string (≤512) | no | Resource/hostname |
source_system | string (≤32) | no | e.g. PROD/DDE/AWS |
resource_display_name | string (≤512) | no | Stored in raw_payload |
agent_name | string (≤128) | no | Defaults to source for source_agents |
mitre_technique | string (≤16) | no | ATT&CK technique |
mitre_tactic | string (≤64) | no | ATT&CK tactic |
tenant_id | string (≤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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
name | string | yes | Truncated 256; bleached |
description | string | no | Truncated 4096 |
pattern_type | string | yes | IP | ASN | USER | RESOURCE | SERVICE | PATTERN (422 otherwise) |
pattern_value | string | yes | Truncated 512; 422 if empty; 400 if break-glass |
applies_to | list[string] | no (default ["all"]) | Agent names the rule applies to |
source_system | string | no | PROD/DDE/AWS; anything else → NULL (= ALL) |
status | string | no (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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
title | string | yes | Truncated 512 |
change_type | string | yes | TERRAFORM | SCRIPT | MANUAL | HISTORICAL (422 otherwise) |
status | string | no (default OPEN) | HISTORICAL | OPEN | APPROVED | APPLIED |
repo | string | no | Truncated 256 |
branch | string | no | Truncated 256 |
commit_sha | string | no | Truncated 64 |
commit_message | string | no | Truncated 8192 |
actor | string | no | Truncated 256 |
pr_url | string | no | Truncated 512 |
affected_systems | list | no (default []) | Sanitized strings, each ≤256 |
description | string | no | Truncated 8192 |
applied_at | string (ISO) | no | 422 if invalid; naive datetimes treated as UTC |
created_by | string | no | Truncated 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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
title | string | yes | Truncated 256; 422 if empty after sanitization |
start_at | string (ISO) | yes | 422 if invalid; naive = UTC |
end_at | string (ISO) | yes | Must be after start_at (422) |
affected_systems | list[string] | no (default []) | Sanitized |
notes | string | no | Truncated 4096 |
created_by | string | no | Defaults 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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
name | string | yes | Truncated 256 |
incident_type | string | no | Truncated 256 |
description | string | no | Truncated 2000 |
steps | list[dict] | no (default []) | Each becomes {index, title (≤256), description (≤4096)} |
trigger_conditions | dict | no | Auto-trigger criteria |
require_approval | bool | no (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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
title | string | yes | Truncated 512 |
ioc_type | enum | yes | IP | DOMAIN | URL | HASH | EMAIL | CVE | OTHER |
ioc_value | string | yes | Truncated 512 |
severity | enum | yes | CRITICAL | HIGH | MEDIUM | LOW | INFO |
confidence | float | no | Clamped 0.0–1.0 |
source | string | no | Truncated 256 |
description | string | no | Truncated 4000 |
tags | list[string] | no (default []) | Max 20, each ≤64 |
tlp | enum | no (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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (≤64) | yes | Tenant slug |
ioc_type | string (≤32) | yes | IOC type label |
ioc_value | string (≤512) | yes | The IOC |
feed_name | string (≤64) | yes | e.g. abuseipdb, virustotal, hibp, osv, epss, tor, shodan |
score | int | no | Feed score |
malicious | bool | no | Feed verdict |
result | dict | no | Raw feed result |
expires_at | string (ISO) | no | Invalid 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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | yes | Tenant slug |
name | string | yes | Truncated 256 |
description | string | no | Truncated 4000 |
rule_type | enum | yes | SIGMA | YARA | REGEX | BEHAVIORAL | THRESHOLD | AGENT_QUERY | OTHER |
rule_content | string | yes | Bleached, unbounded |
severity | enum | yes | CRITICAL/HIGH/MEDIUM/LOW |
incident_type | string | no | Truncated 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):
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (≤64) | yes | Tenant slug |
agent_name | string (≤128) | yes | Registering agent |
queries | list | yes | Items: 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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string (≤256) | yes | Display name |
url | string (≤1024) | yes | Destination URL |
secret | string (≤256) | no | HMAC signing key |
events | list[string] | no (default ["incident.created","incident.updated"]) | Must be valid events (400 otherwise) |
min_severity | string | no (default HIGH) | CRITICAL/HIGH/MEDIUM/LOW (400 otherwise) |
enabled | bool | no (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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (≤64) | yes | Tenant slug |
agent_name | string (≤128) | yes | Agent identifier |
environment | string (≤32) | no | PROD/DDE/AWS/HOME |
run_id | string (≤128) | no | Correlates with orchestrator run |
status | string | no (default ok) | ok | error | skipped (anything else coerced to ok) |
findings_count | int | no (default 0) | Clamped ≥0 |
error_count | int | no (default 0) | Clamped ≥0 |
duration_ms | int | no | Run duration |
last_error | string | no | Truncated 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.
| Field | Type | Required | Description |
|---|---|---|---|
llm_call_id | string | yes | Unique call ID |
environment | string | yes | PROD/DDE/AWS/HOME |
agent_name | string | yes | Calling agent |
model | string | yes | Model identifier |
action_taken | string | yes | e.g. OPEN/CLOSE/SUPPRESS |
scheduler_run_id | string | no | Orchestrator run correlation |
incident_id | string | no | Related incident |
finding_id | string | no | Related finding |
prompt_tokens / completion_tokens | int | no | Token usage |
latency_ms | int | no | Call latency |
confidence | float | no | Model confidence |
mitre_technique / mitre_tactic | string | no | ATT&CK mapping |
blob_path | string | no | Azure 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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (3–64) | yes | Lowercase slug |
display_name | string (≤256) | yes | Human name |
config | dict | no (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-teamand is unchanged — it is distinct from the Bedrock Attack producer API (bedrock-attack), which serves/api/bedrock-attackwith/api/attack-teamas 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.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string | yes | Upsert key |
completed_at | string (ISO) | yes | 422 if invalid |
risk_score / risk_score_delta | float | no | Campaign risk |
total_findings, urgent, high, medium, low, info | int | no (default 0) | Finding counts |
new_stories, updated_stories, suppressed, attack_paths, phi_findings, internet_findings | int | no (default 0) | Campaign stats |
mttr_avg_days, mttr_p90_days | float | no | Remediation 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.
| Field | Type | Required | Description |
|---|---|---|---|
data | dict | yes | Source-specific payload (may include score) |
last_activity | string | yes | ISO date of last observed activity |
public_ip | string | no | Appended 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.
Events & Search
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):
| Field | Type | Required | Description |
|---|---|---|---|
log_source | string | no | Exact match |
source_system | string | no | Exact match |
event_type | string | no | Exact match |
actor_upn | string | no | Case-insensitive exact |
actor_ip | string | no | Exact match |
outcome | string | no | Uppercased exact |
action_contains | string | no | ILIKE substring |
target_contains | string | no | ILIKE substring |
hours_back | int | no (default 24) | 1–168 |
limit | int | no (default 100) | 1–500 |
offset | int | no (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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Truncated 256 |
description | string | no | Truncated 4096 |
mitre_technique | string | no | Truncated 32 |
severity | string | yes | CRITICAL/HIGH/MEDIUM/LOW (422 otherwise) |
rule_type | string | yes | THRESHOLD/SEQUENCE/STATISTICAL (422 otherwise) |
event_filter | dict | no | Event match criteria |
threshold_count / threshold_window_minutes | int | no | THRESHOLD config |
group_by_field | string | no | Must be actor_upn, actor_ip, or source_system (422 otherwise) |
sequence_steps | list | no | SEQUENCE config |
sequence_window_minutes | int | no | SEQUENCE window |
sequence_same_actor | bool | no (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:
| Route | Page | Source |
|---|---|---|
/, /incidents/… | Incident queue and detail | app/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 windows | app/routers/maintenance.py |
/playbooks/, /playbooks/partial, /playbooks/{id} | Playbooks | app/routers/playbooks.py |
/threat-intel/ | IOC library + feed status panel | app/routers/threat_intel.py |
/detections/ | Detection coverage (MITRE + STRIDE rollups) | app/routers/detections.py |
/metrics/ | Metrics dashboard | app/routers/metrics.py |
/webhooks/ | Webhook management | app/routers/webhooks.py |
/agents/health | Agent health dashboard | app/routers/agent_health.py |
/search | Event search page | app/routers/search.py |
/rules | Correlation rules page | app/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:
Ingest findings (
app/routers/ingest.py):ioc_key = "{source}:{source_id}"(bleached; source ≤128, source_id ≤256).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_systemor"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.
- Behavioral agents:
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:
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-Keyheader 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/minutePOST /api/ingest/findings— 300/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-webhookfailure_count/last_statustrack delivery health. - Maintenance suppression (
GET /api/maintenance/activedocstring): 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 F821as a hard gate because fail-open agent code can swallowNameErrorat 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.