AI Security Threat Board — API

Read-only JSON API and RSS feed behind the board. No keys, no rate plans, no tracking.

Overview

Endpoints

GET /api/v1/latest

The board view: the newest items of each column. Threats are ordered with the CVEs CISA lists as actively exploited (badge KEV) first, newest first, followed by the remaining CVEs newest first — limit and days apply to the whole list, so a busy CNA day never hides an exploited vulnerability. Research is limited to items that finished LLM triage with a relevance at or above the board threshold (minRelevance in the response; 7 by default).

ParameterTypeDefaultMeaning
limitinteger 1–50020items per column
daysinteger 0–365030only items published within the last N days; 0 disables the window
{ "generatedAt": "…", "lastUpdated": "…" | null, "minRelevance": 7,
  "columns": { "threats": [Item], "research": [Item], "incidents": [Item] } }

GET /api/v1/{column}

One column: threats, research or incidents. Unknown columns are a 404.

ParameterTypeDefaultMeaning
limitinteger 1–50050maximum items
daysinteger 0–365030as above; 0 = no window
min_scoreinteger 0–100research only. 0 returns every row in the window, including items not yet scored (relevance: null); any value above 0 keeps items that have a score at or above it — check triageStatus: an item queued for re-triage keeps its previous score while pending. Ignored for other columns (minRelevance is then null).
sinceISO-8601 date-time with zoneonly items published at or after this instant, e.g. 2026-09-01T00:00:00Z — a full date-time with a zone is required; a bare date is a 400 (combine with days=0 for an open-ended range)
includeabstractadds abstract, triageModel, promptVersion and triagedAt to every item (offline evaluation)
include_rejected1 | truealso return rows carrying the REJECTED badge
kev_first1 | truethreats only: the board's order — KEV-badged rows first (newest first), then the rest (newest first). The default is pure publication-date order.
{ "column": "research", "generatedAt": "…", "lastUpdated": "…" | null,
  "count": 12, "minRelevance": 0 | n | null, "items": [Item] }

Board versus API relevance. The board (and /latest) shows research at or above the threshold only. The column route defaults to min_score=0 so the whole scored history is available, including pending items; pass min_score=7 to reproduce the board's selection.

Offline evaluation. GET /api/v1/research?min_score=0&include=abstract&limit=500&days=90 returns every scored abstract of the last quarter with the model and prompt version that scored it; worker/scripts/export-scored.sh in the repository turns that into JSON Lines.

GET /api/v1/status

Operational status: the last run per source and row counts. runs lists the newest run of every ingestion module plus the newest triage pass (source: "triage", triaged = items scored). lastUpdated is the finish time of the newest run that stored data — an ingestion run with status ok or partial (an error run stored nothing), or a triage pass that scored at least one item; it is the same value the board footer shows. counts.pendingTriage is the number of research items the next triage pass will pick up: pending ones plus failed ones still under the retry cap. Cached for 60 seconds.

{ "generatedAt": "…", "lastUpdated": "…" | null,
  "runs": [{ "source": "arxiv", "startedAt": "…", "finishedAt": "…" | null,
             "status": "ok" | "partial" | "error", "fetched": 3, "inserted": 1, "updated": 0, "triaged": 0, "error": null }],
  "counts": { "threats": 0, "research": 0, "incidents": 0, "pendingTriage": 0 } }

GET /api/v1/digest/latest

The newest weekly digest — a short Markdown analysis (≤ 400 words) of the last seven days' threats, research and incidents, written by the configured digest model every Monday — or a 404 while none has been written yet. The digest only ever discusses items that are on the board; it is skipped (and the board unaffected) when no digest provider is configured. Cached for five minutes.

{ "generatedAt": "…",
  "digest": { "id": 1, "periodStart": "…", "periodEnd": "…", "model": "…", "promptVersion": "…", "content": "# …", "createdAt": "…" } }

GET /rss.xml

RSS 2.0, the newest 50 items across all columns together with every CVE CISA added to KEV in the last 30 days, capped at 50 and ordered by publication date (KEV items keep their real date and are kept in preference to older non-KEV items when the cap applies). Research appears only once triaged at or above the board threshold. Item titles are prefixed with the column ([Threat], [Research], [Advisory], [Incident], [News]); a threat listed in CISA KEV is prefixed [KEV] instead; <category> carries the column, every badge and, for research, the triage tags; <guid> is the stable item id (not a permalink), with :kev appended once the CVE is in KEV so the addition surfaces once as a new item; the description is the one-line summary followed by the source and, where applicable, the relevance score or CVSS score and severity. Cached for five minutes; Content-Type: application/rss+xml.

POST /api/v1/admin/run/{source}

Runs an ingestion module synchronously and returns its outcome — for populating the board after a deploy or re-running a source without waiting for its cron. source is one of kev, nvd, arxiv, hf, rss, triage, digest or all (every source, then triage). Every source run ends with a triage pass over pending research.

{ "generatedAt": "…", "requested": "arxiv",
  "results": [{ "source": "arxiv", "status": "ok" | "partial" | "error", "fetched": 3, "matched": 3, "inserted": 1, "updated": 0,
                "triaged": 0, "durationMs": 812, "error": null, "notes": [] },
              { "source": "triage", … }] }

Item shape

FieldTypeMeaning
idstringstable 32-hex id; sources that describe the same thing (e.g. CISA KEV and NVD for one CVE) share it
columnthreats | research | incidentsboard column
source, sourceLabelstringprimary source id (nvd, kev, arxiv, hf, a feed id) and its display label
sourcesstring[]every source that contributed to the row
externalIdstringCVE id, arXiv id, HF paper id or feed entry id
title, urlstringplain-text title (≤ 500 chars) and canonical http(s) link
summarystring | nullone line for the board (the LLM summary for research; otherwise the first sentence of the source text)
publishedAt, firstSeenAt, updatedAtISO-8601source date (for CVEs: the later of NVD publication and KEV addition), first stored, last changed
relevanceinteger 0–10 | nullLLM triage score (research); null until triaged
tagsstring[]triage tags from a fixed vocabulary (e.g. prompt-injection, agents, supply-chain)
severity, cvssScore, cvssVersionCRITICALNONE | null, number | null, string | nullCVSS data for threats, from NVD: the NIST (Primary) metric of the highest CVSS version when there is one, otherwise the CNA's own (Secondary) metric; null until NVD has scored the CVE (CISA KEV carries no score — every KEV run looks up a few unscored KEV rows on NVD, so a KEV-only row gains its score within a few runs). When the record's severity label is missing or not one of the five, the severity is derived from the score and extra.severityDerived is true.
badgesstring[]KEV, RANSOMWARE, ADVISORY, INCIDENT, NEWS, REJECTED
triageStatuspending | done | failed | skippedskipped = not a triage target, or given up after repeated failures
extraobjectsource-specific data: NVD vulnStatus, lastModified, cpes, references, cvssSource, cvssType, matchedKeywords, severityDerived; KEV dueDate, requiredAction, vendorProject, product, vulnerabilityName, notes, cwes, nvdLookup (the last NVD score lookup of a KEV row: { at, outcome }); arXiv categories and authors; feed kind …

With include=abstract: abstract (plain text, ≤ 5000 chars), triageModel, promptVersion and triagedAt (all nullable).

Errors

Every non-2xx response is { "error": { "code": "…", "message": "…" } } with a stable code: bad_request (an invalid query parameter — the message names the parameter, never its value), unauthorized, not_found, method_not_allowed (with an Allow header), rate_limited (too many failed admin authentications from one address) and internal_error. Error responses are not cached.