Skip to content

Vulnerability Feed API Both

The Vulnerability Feed Broker is SentriKat's centralized, read-only feed of the public vulnerability landscape: the exploited-vulnerability catalogue, the primary CVE database (CVSS and applicability), exploit probability and the software identity catalogue, plus a beyond-the-primary-database exploit-intel layer. SentriKat operates it so that client installations (especially on-premises and air-gapped) pull this data from one place instead of each hammering the upstream feeds.

What it is, and isn't

The broker is a cache/mirror of authoritative public data, not a detection engine. It serves raw, upstream-faithful records. It does no matching against your inventory: confidence scoring and false-positive suppression happen later, in the SentriKat detection engine on the client side.

Coverage scope: the broker carries the primary CVE database, the exploited-vulnerability feed, exploit probability, the identity dictionary, exploit intel, distro advisories and package advisories, plus curated mappings. Vendor advisories (Red Hat CSAF and equivalents) are the one family that stays direct on the client, so an installation that relies on the broker still fetches those itself.

Base URL: https://api.sentrikat.com/api/v1/vuln-feed · Contract version: 0.3.1 (returned in the Contract-Version response header).


Authentication

Every endpoint except /health requires the same Bearer scheme as KB Sync:

Authorization: Bearer <first 64 chars of your signed license token>
X-Installation-ID: SK-INST-<32 uppercase hex characters>

The license's edition maps to a feed tier, and each endpoint requires a minimum tier:

Edition Feed tier Can call
Community / Demo community /manifest, /cpe-dictionary, /epss, /curated-mappings
Professional professional all of the above + /vulnerabilities, /cve/{id}, /exploit-intel, /distro-advisories, /osv-advisories, /bundle

Requests are rate-limited per installation (by X-Installation-ID; a request without a usable installation ID is counted per IP):

Endpoint Limit
/vulnerabilities, /cve/{id}, /exploit-intel, /epss, /distro-advisories, /osv-advisories, /curated-mappings 600/hour
/cpe-dictionary 300/hour
/manifest 120/hour
/bundle 12/hour

Page size and rate limit are one constraint, not two: at the maximum page size of 500, 600 requests/hour is 300,000 rows/hour.

They are counted per installation, not per IP, so a NAT'd fleet sharing one egress IP isn't throttled as a single client. In front of that sits a limit of 10 requests/second per IP (burst 20). A request over a limit returns 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. The same figures are published in the rate_limits block of /health.

Error envelope

All errors return a consistent body:

{
  "error": "tier_insufficient",
  "message": "this endpoint requires the professional tier or higher",
  "documentation_url": "https://docs.sentrikat.com/api/vuln-feed"
}

Common codes: auth_credentials_missing, auth_installation_id_missing, auth_invalid_signature (all 401: nothing presented, no usable installation ID, or a token that is not usable), auth_installation_unknown (403: the credential is well formed but unknown, revoked or expired, with one code for all three), auth_installation_suspended, tier_insufficient, not_found, bad_request.


Endpoints

GET /health

Public (no auth). Liveness and per-dataset freshness so a monitor or status page can alert when ingestion silently stops.

{
  "status": "ok",
  "contract_version": "0.3.1",
  "datasets": {
    "vulnerabilities": { "count": 1342, "last_modified": "2026-06-26T05:15:00", "age_hours": 1.2, "stale": false },
    "cpe_dictionary":  { "count": 89234, "last_modified": "2026-06-22T04:30:00", "age_hours": 96.0, "stale": false },
    "exploit_intel":   { "count": 412, "last_modified": "2026-06-26T05:15:00", "age_hours": 1.2, "stale": false }
  },
  "checked_at": "2026-06-26T06:25:00+00:00"
}

The real response also carries scheduler, cursor_fields_by_dataset, rate_limits and completeness; the sample shows three datasets.

status is ok, stale (a dataset is older than its budget: 48h for vulnerabilities, exploit intel, exploitation probability and package advisories, 72h for distro advisories, 240h for the identity dictionary; or the scheduler has stopped), or empty (returns HTTP 503).

GET /manifest

Tier: community. Dataset sizes + cursors so a client can plan incremental pulls, plus your tier and the coverage note.

GET /vulnerabilities

Tier: professional. Paginated CVE records: the exploited-vulnerability catalogue plus the enriched CVE records.

Query param Default Notes
page 1
page_size 100 max 500
since – ISO 8601. Filters on ingested_at, when the broker took the row in, not on when upstream last changed it
since_id – The other half of the cursor: the last id you saw at that same ingested_at

The cursor is ingested_at, not last_modified

A row's last_modified is upstream's timestamp and does not advance in the order the broker receives rows, so seeding ?since= from it skips rows with no error anywhere: the pull succeeds, returns fewer rows, and nothing reports a problem. Always page on ingested_at, and pass since_id with it so rows sharing one timestamp are not cut in half at a page boundary.

Each dataset has its own cursor field. /health lists them under cursor_fields_by_dataset; read it there rather than assuming. The older cursor_field is still returned and describes this endpoint only, so a client that reads it for another dataset seeds the cursor from a column that does not exist there.

Incremental pulls: pass ?since=<the ingested_at you saw>&since_id=<its id> or the If-Modified-Since header. If nothing changed, the broker returns 304 Not Modified. Response is a pagination envelope:

{
  "contract_version": "0.3.1",
  "page": 1, "page_size": 100, "total": 1342,
  "next_page": "/api/v1/vuln-feed/vulnerabilities?page=2&page_size=100",
  "data": [ { "id": "<row id>", "cve_id": "CVE-2024-3400", "severity": "CRITICAL", "is_actively_exploited": true, "epss_score": 0.97, "last_modified": "2026-06-26T05:15:00", "ingested_at": "2026-06-26T05:20:00" } ]
}

GET /cve/{cve_id}

Tier: professional. A single CVE with its CPE applicability (cpe_data).

A cpe_data entry that only applies on some operating systems carries platforms, for example ["microsoft:windows"]: the software is vulnerable only when it runs there. Entries without the key apply on every operating system. The key appears since contract 0.3.1 and only on the entries that need it.

GET /cpe-dictionary

Tier: community. Paginated CPE dictionary (?since= / 304 supported).

GET /exploit-intel

Tier: professional. Paginated exploit signal per CVE, beyond what the primary database carries.

GET /epss

Tier: community. Paginated exploitation-probability scores per CVE.

Cursor: (epss_updated_at, id), and every row carries epss_updated_at. It is not ingested_at: a score changes without the broker taking the row in again, so a client paging on ingested_at saw each score once and never saw it move, on a dataset whose whole purpose is to move.

GET /distro-advisories

Tier: professional. Paginated Linux distribution security advisories (Ubuntu USN, Debian, Oracle ELSA, Amazon ALAS and the RHEL family), including the backport-aware fixed-version data that stops a patched package being reported as vulnerable.

GET /osv-advisories

Tier: professional. Paginated package advisories across the supported language ecosystems, matched on (ecosystem, name, version) rather than on a CPE guess.

GET /curated-mappings

Tier: community. Paginated vendor/product to CPE identities curated centrally from the whole fleet's contributions, so an identity resolved once is resolved for everyone.

Cursor: (curated_at, id), and both travel in the row. The timestamp is served as curated_at, which is the name to build the cursor from.

GET /bundle

Tier: professional. Downloads the latest signed offline bundle (.tar.gz) for air-gapped installs. See below. Limited to 12 requests/hour. Returns 404 (not_found) until a bundle has been generated.


Offline bundle (air-gapped)

A self-contained, signed snapshot for installations with no internet access.

  • Archive: .tar.gz (gzip).
  • Datasets: NDJSON (one JSON object per line), field names identical to the REST output, so the client uses one parser for both API and bundle. The .jsonl members are plain text, not individually compressed: the archive's own gzip is the only compression layer.
  • Integrity: each dataset has a sha256 recorded in manifest.json.
  • Authenticity: a detached manifest.json.sig signs the canonical manifest.json with the same RSA keypair as licenses (RSA-PKCS1v15-SHA256, base64).
  • Freshness: the signed manifest carries a strictly increasing sequence and an expires_at, so an importer can refuse an older or an expired bundle.

Archive layout. Read the file names from manifest.json rather than assuming this list: datasets are added over time, and the dataset name is the contract.

manifest.json
manifest.json.sig
vulnerabilities.jsonl
cpe_dictionary.jsonl
cpe_data.jsonl
kev_history.jsonl
exploit_intel.jsonl
distro_advisories.jsonl
osv_advisories.jsonl

manifest.json:

{
  "contract_version": "0.3.1",
  "format_version": 1,
  "sequence": 47,
  "generated_at": "2026-06-26T05:45:00Z",
  "expires_at": "2026-09-24T05:45:00Z",
  "datasets": [
    {"name": "vulnerabilities", "filename": "vulnerabilities.jsonl", "record_count": 1342, "sha256": "…"},
    {"name": "cpe_data", "filename": "cpe_data.jsonl", "record_count": 5821, "sha256": "…"}
  ],
  "signature": {"algorithm": "RSA-PKCS1v15-SHA256", "format": "detached", "file": "manifest.json.sig", "signed": "manifest.json"}
}

Verifying a bundle

Verify the signature over the raw manifest.json bytes (do not re-serialise), then the per-dataset sha256:

import tarfile, json, hashlib, base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding

with tarfile.open("sentrikat-feed-bundle.tar.gz", "r:gz") as t:
    manifest_bytes = t.extractfile("manifest.json").read()          # raw bytes
    sig = base64.b64decode(t.extractfile("manifest.json.sig").read())
    licensing_public_key.verify(sig, manifest_bytes,                # same key as licenses
                                padding.PKCS1v15(), hashes.SHA256())
    manifest = json.loads(manifest_bytes)
    for d in manifest["datasets"]:
        data = t.extractfile(d["filename"]).read()
        assert hashlib.sha256(data).hexdigest() == d["sha256"]
        rows = [json.loads(line) for line in data.splitlines()]     # NDJSON

Then check freshness, which the signature alone cannot give you:

  • reject a bundle whose sequence is lower than or equal to the last one you imported (an old bundle served again),
  • reject a bundle whose expires_at is in the past (a bundle frozen in place).

sequence only moves when the data behind the bundle changes. A rebuild that finds nothing new keeps serving the bundle you already have, with the same number.


Data freshness & reliability

The feed is refreshed on a schedule from authoritative public sources. You don't manage any of that. What you can check as a client:

  • Check freshness any time via GET /health (per-dataset last_modified, age_hours, stale).
  • Upstream outages show up as staleness: /health reports stale when a dataset is older than its budget, and empty (HTTP 503) when there is no data at all.
  • Fetch vendor advisories directly. The broker does not carry vendor advisories (Red Hat CSAF and equivalents), so your installation keeps ingesting those itself. If the broker is unreachable, fall back to direct upstreams.

Security

  • Access is limited to licensed installations (Bearer + tier).
  • The API is read-only and rate-limited per installation.
  • Offline bundles are RSA-signed and per-dataset hashed, so a client that verifies them as shown above detects a tampered bundle before import.
  • The feed data itself is public vulnerability intelligence, the guarantees here are integrity and access control, not secrecy.