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). Incidents are hidden only once triage scored them below that threshold (items not scored yet are shown), and a story carried by several outlets appears once: the other reports are listed in extra.alsoReportedBy and their sources added to sources. Build It items (builder) are listed newest first with no relevance gate.

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], "builder": [Item] },
  "threatGroups": [{ "product": "…", "count": 3, "worstSeverity": "CRITICAL" | null, "worstScore": 9.8 | null, "kev": false,
                     "fixedVersions": ["1.2.3"], "unfixedCount": 1, "latestAt": "…", "items": [Item] }] }

threatGroups groups every threat of the window by the product its description names (at most 40 groups, known-exploited groups first, then by worst CVSS score, then newest; each lists up to 5 of its CVEs, known-exploited first, then most severe). A CVE whose description names no product is a group of its own, named by its CVE id. product is at most 120 characters. count is the number of CVEs in the window, fixedVersions every version a description says fixes a flaw (“fixed in”, “patched in”, “upgrade to”, else “before” / “prior to”; every release line named), in version order, unfixedCount the CVEs whose description names no fixed version. Every threat item carries the same facts in extra.product (a name, or null), extra.fix (fixed in A, fixed in A and B or fixed in A, B and C — every fixed version named —, no fix named (affected through X) — X the highest version still affected — or no version stated) and extra.flaw (the description's first one or two sentences without the product blurb, at most 220 characters, or null); all are read from the description by fixed rules, not by a model. For a CVE CISA listed before NVD described it, the fix status comes from NVD's description whenever NVD has one (kept in extra.nvdFix), never from CISA's short text. When those optional reads fail, the board is sent without threatGroups (and without the edge cache) rather than failing. Research items carry extra.stance — attack, defense or measurement — when the keyword classifier can tell.

GET /api/v1/{column}

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

ParameterTypeDefaultMeaning
limitinteger 1–50050maximum items
daysinteger 0–365030as above; 0 = no window
min_scoreinteger 0–100 (research); the board threshold (incidents)Research: 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. Incidents: hides items scored below it (unscored items stay), default the board threshold, 0 shows everything; incidents are collapsed per story as on the board. Ignored for threats and builder. minRelevance echoes it for research only (null for every other column).
sinceISO-8601 date-time with zone—only 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). The instant is rounded down to the whole UTC hour (…T10:42:17Z is read as …T10:00:00Z), so items published up to an hour earlier can appear.
includeabstract—adds abstract, triageModel, promptVersion and triagedAt to every item (offline evaluation). With it, limit is capped at 100 (a larger value is treated as 100).
include_rejected1 | true—also return rows carrying the REJECTED badge
kev_first1 | true—threats 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=100&days=90 returns the newest 100 abstracts of the last quarter with the model and prompt version that scored them; narrow days or raise min_score when a window holds more. 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 and incidents items the next triage pass will pick up: pending ones plus failed ones still under the retry cap. Cached for 60 seconds.

Freshness. freshness has one entry per ingestion source (kev, nvd, arxiv, hf, rss, in that order; treat source as a free string, more may be added). lastOkAt is, for nvd, the NVD watermark: NVD changes up to about 6 hours before this time have been read (a run that reads only part of a backlog does not move it); for every other source, the finish time of its newest ok or partial run; null when there is none. stale is true when that time (or, with none, the oldest recorded run's start) is more than staleAfterHours old — 24 for KEV and NVD, 36 for arXiv and the news feeds, 48 for Hugging Face, each at least twice the source's schedule. A source that has never run is not stale. The field is optional: an older deployment omits it.

{ "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, "builder": 0, "pendingTriage": 0 },
  "freshness": [{ "source": "nvd", "lastOkAt": "…" | null, "staleAfterHours": 24, "stale": false }] }

GET /api/v1/health

For uptime monitors. 200 with status: "ok" while ingestion is alive; 503 with status: "stale" when no ingestion run has stored data (status ok or partial) in the last 12 hours — twice the 6-hour interval of the CISA KEV / NVD runs, so one missed run is tolerated — when none ever has, or when the latest run of every source that ran in the last 7 days failed. Triage and digest runs never count. reason is a constant phrase naming the cause. A 200 is cached for 30 seconds; a 503 is sent with Cache-Control: no-store.

{ "status": "ok" | "stale", "generatedAt": "…", "lastSuccessfulIngest": "…" | null,
  "staleAfterHours": 12, "reason": null | "…" }

GET /api/v1/digest/latest

The newest weekly digest — a short Markdown briefing on the last seven days, written by the configured digest model every Monday — or a 404 while none has been written yet. The model is asked for four sections, headed ## Bottom line, ## Secure It (threats, attack research, incidents and advisories), ## Build It (defensive research, guidance, tooling and releases) and ## Do this week, for at most 320 words and at most three bullets in each section after the first. Those are targets, not guarantees: a digest that misses them is still stored (a missing digest is worse than a long one), and the Worker records the miss in the digest run's notes. Abbreviations are spelled out at their first use in the prose (glossary-backed); link labels are names and are left as written. The model cites items by reference and the Worker turns each reference into a link to that item's own URL; the digest only ever discusses items that are on the board.

What the Worker guarantees about the stored Markdown: no HTML tags; one line per paragraph; links only to the digested items' URLs — every inline link [label](url) points exactly to the URL of an item the digest was written from (any other link becomes its label, an image its alt text); no http://, https://, other scheme://, //host or www. address anywhere else in the text, labels included (such bare URLs are removed, so GFM autolinking finds none); and ]( or ]: outside those links is broken with a space, so a CommonMark renderer cannot pair leftover brackets into a link or a link reference definition. Not covered: bare domain names without a scheme or www. and e-mail addresses stay as text, so render the digest with fuzzy linkification off, as plain text, or through your own link allow-list. The digest 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 /api/v1/digests

The digest archive: a page of past digests, newest first, each the same full shape as /api/v1/digest/latest serves (Markdown included — a digest is a few kilobytes). Pages are cut by id, not by offset: nextBefore is the id of the last digest on the page — pass it as before to get the next, older page — or null when the page reaches the oldest digest. Ids are assigned in order of creation, so this is also newest-first by createdAt, and a digest written between two page reads never shifts an older page. An empty archive is a 200 with an empty list, not a 404. Cached for five minutes.

ParameterTypeDefaultMeaning
limitinteger 1–5020digests per page
beforeinteger ≥ 1—only digests with an id below this one (the previous page's nextBefore); a value past the oldest digest gives an empty page
{ "generatedAt": "…",
  "digests": [{ "id": 8, "periodStart": "…", "periodEnd": "…", "model": "…", "promptVersion": "…", "content": "## …", "createdAt": "…" }, …],
  "nextBefore": 5 | null }

GET /api/v1/digests/{id}

One archived digest by id, in the shape of /api/v1/digest/latest; a 404 when there is no such digest, and for anything that is not a plain positive integer (0, 007, abc; the id is read after URL decoding, so %37 is 7). Cached for five minutes like the other read routes — a digest its operator withdraws stops being served within that window — and the ETag changes only with the row.

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], [Build It]); 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 | incidents | builderboard column (builder = Build It)
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, cvssVersionCRITICAL…NONE | 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 — and, with the lookups left over, KEV rows NVD has not yet given a fix status). 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; Build It items: GUIDANCE, TOOLING, RELEASE, NEWS
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 lookup of a KEV row: { at, outcome, factsVersion }); threats product, fix, flaw (see threatGroups above), nvdFix and nvdFixVersion (NVD's own fix status on a row CISA listed first, and its rules version; it wins over fix) and, when they were stored at ingest, factsVersion (the rules version they were computed with); feed items feedId, feedName, kind, categories, matchedKeywords; arXiv categories and authors …

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.