Threat alerts: from a feed to a notification
How Zero works out, within about five minutes of a critical vulnerability being published, whether each account is affected, and how it tells people. Written as background for ArmorCode building the same feature (ENG-146243): what the threat feed has to hold, how to source each part of it with an LLM doing its own research, and exactly which queries run against SBOM, assets and findings.
1. The whole pipeline
One worker runs a loop every 60 seconds. The left side works only with public data and keeps one shared copy. The right side is per account.
The key design choice: nothing is queried in a customer's data when a threat arrives. Each account's software is copied into a normalized index ahead of time, so a new threat is one lookup across every account plus a version comparison in code.
2. What one feed record holds
The feed is one record per threat (an event, which can be several CVEs). Everything an account match needs is in affected: who the software is, which versions are hit and which fix it. Everything that decides who is told is in signals. This is the shape Zero's public catalog serves at GET /public/v1/threats?since=, trimmed.
{
"id": "php2shell",
"name": "PHP2Shell",
"cves": ["CVE-2026-109618", "CVE-2026-109619"],
"summary": "Two critical PHP flaws let anyone run code on a PHP web server",
"severity": "critical",
"tier": 1,
"publishedAt": "2026-10-14T16:02:00Z",
"affectedKnown": true,
"affected": [
{ // the runtime itself, by upstream version
"identity": { "kind": "runtime", "ecosystem": "runtime", "name": "php" },
"aliases": ["php-fpm", "php-cli", "php8.3", "php8.3-fpm", "libapache2-mod-php8.3", "php83"],
"scheme": "semver",
"ranges": [ { "introduced": "8.1.0", "fixed": "8.1.34" },
{ "introduced": "8.2.0", "fixed": "8.2.30" },
{ "introduced": "8.3.0", "fixed": "8.3.29" } ],
"fixedVersions": ["8.1.34", "8.2.30", "8.3.29"] },
{ // a distribution's own package: its fixed version includes backported patches
"identity": { "kind": "os-package", "ecosystem": "deb", "name": "php8.2" },
"distro": "debian:12", "scheme": "deb",
"ranges": [ { "fixed": "8.2.30-1~deb12u1" } ], "fixedVersions": ["8.2.30-1~deb12u1"] },
{ "identity": { "kind": "os-package", "ecosystem": "apk", "name": "php83" },
"distro": "alpine:3.20", "scheme": "apk",
"ranges": [ { "fixed": "8.3.29-r0" } ], "fixedVersions": ["8.3.29-r0"] },
{ // the official container image, by tag
"identity": { "kind": "runtime", "ecosystem": "image", "name": "php" },
"scheme": "semver", "ranges": [ { "introduced": "8.1.0", "fixed": "8.3.29" } ], "fixedVersions": ["8.3.29"] }
],
"signals": {
"cvss": 9.8, "epss": null, "kev": false,
"unauthenticated": true, "remoteCodeExecution": true,
"exploitPublic": false, "exploitedInTheWild": false, "vendorEmergencyRelease": true,
"named": true, "widelyUsed": true,
"attention": ["attention:cisa", "attention:news-a", "attention:news-b"],
"agreeingSources": ["cve-list", "vendor:php"]
},
"references": [ { "label": "PHP 8.3.29 release announcement", "url": "https://www.php.net/..." } ],
"revision": 3
}
The ranges above are simplified to show the shape. Kinds are package (a library in a package manager: npm, Maven, PyPI, Composer…), runtime (php, node, openssl, and container images by tag), os-package (deb, rpm, apk, tied to a distro release) and product (vendor/product, for appliances and hosted software).
3. Sourcing each part of the record
Each field has a best source. The rule that keeps the feed safe: structured sources win, the LLM fills what only text says, and code decides anything that changes who is alerted. The last column is how ArmorCode's LLM research step should get it.
| Field | Used for | Zero today | How ArmorCode should source it |
|---|---|---|---|
cves | Grouping, linking findings | Code CVE List, GitHub and OSV aliases | Code Fetch the CVE record by id. LLM may propose that CVEs are one event; code accepts only when one advisory names them together |
name | Display, tier | LLM reads advisories and headlines | LLM Search the CVE ids in news and CERT alerts. Use a name only if the vendor, a CERT or two outlets use it |
summary, description | Display | LLM from the documents | LLM Plain words from the vendor advisory and CVE record |
severity, cvss | Is it a candidate, tier | Code the CNA's record and the vendor's own word | Code Never the model. Highest current word across sources; a vendor's later "High" replaces its "Critical" |
affected packages | Matching | Code GitHub and OSV ranges, exact | Code GitHub Advisory API and OSV by CVE id. These beat anything the model reads for the same package |
affected runtimes and products | Matching | LLM reads the vendor advisory and the CVE record's affected block | LLM Find the vendor's own advisory or release notes; read affected and fixed releases per branch. Map the product to a vendor/product name ArmorCode's asset data uses |
affected distro packages | Matching (backports) | Code Debian, Ubuntu and Alpine trackers | Code Tracker APIs per CVE (Red Hat VEX too). Fixed version per release, so a patched old version isn't flagged |
affected images | Matching containers | LLM names the official image | LLM List the official image's tags (Docker Hub); the first tag at the fixed version is the fix |
aliases | Matching (other spellings) | LLM plus a maintained alias table | LLM Read the binary package names a tracker lists for the source package (php8.3-fpm, libapache2-mod-php8.3), the Alpine name (php83) and common spellings. Merge into a reviewed alias table |
unauthenticated, remoteCodeExecution | Tier | LLM reads the advisory | Code first from the CVSS vector (AV:N/PR:N) and CWE; LLM confirms from the advisory text |
exploitPublic | Tier (urgency) | LLM reads advisories and news | LLM Search for proof-of-concept code and exploit write-ups for the CVE ids; cite the page |
kev, exploitedInTheWild | Tier (urgency) | Code CISA KEV every 5 minutes | Code KEV feed. LLM adds a vendor's "exploited" statement, cited |
vendorEmergencyRelease | Tier (urgency) | LLM reads the vendor advisory | LLM An out-of-band release or "upgrade immediately" wording in the vendor's own advisory |
attention, named | Tier | Code configured CISA, CERT and news feeds | LLM Search CISA, national CERTs and news for the CVE ids or name; count distinct sources |
widelyUsed | Tier | Code a maintained list | Code A maintained list of runtimes, frameworks and products, plus ecosystem popularity. Not the model |
agreeingSources | Tier 1 gate | Code counts sources naming the same software | Code Two independent sources must name the same affected software before everyone is alerted |
epss | Display, priority | Code FIRST daily | Code FIRST EPSS API |
tier | Who is told | Code rules over the signals | Code Rules in configuration, tuned by replaying past CVEs. The LLM never picks the tier |
4. The LLM research step, broken down
One research run per candidate threat, in six stages. Blue stages are plain code; purple stages are the LLM.
The research agent's tools
| Tool | Calls | Gives |
|---|---|---|
cve_record(id) | https://cveawg.mitre.org/api/cve/{id} | The CNA's description, CVSS and vector, CWE, affected vendor/product/versions, references |
github_advisories(cve) | https://api.github.com/advisories?cve_id={id} | Package ecosystems, names and exact vulnerable ranges |
osv(cve) | https://api.osv.dev/v1/vulns/{id} and its aliases | Package and distro ranges in one format |
kev(cve), epss(cve) | CISA's KEV JSON feed; https://api.first.org/data/v1/epss?cve={id} | Exploited in the wild; likelihood of exploitation |
distro_tracker(cve) | Debian security tracker, Ubuntu CVE API, Alpine secdb, Red Hat VEX | Fixed package version per release, and the binary package names |
image_tags(image) | Docker Hub tags for official images | Which tags carry the fixed version |
web_search(query) | A search API | Vendor advisories, release notes, CERT alerts, news, proof-of-concept repos |
fetch(url) | Any public page, saved with a hash | The text the quotes are checked against |
Guardrails for the run: the LLM reads public pages only and never sees customer data; page text is untrusted (instructions in a page are ignored); a cap on searches and fetches per run; and output that fails verification goes out as "We're still working out which versions are affected", never as "not affected".
5. Example: researching PHP2Shell
What one run looks like, step by step. PHP is a runtime, not a library, so the package databases have nothing on it; that's exactly where research earns its keep. Versions and pages are illustrative.
AV:N/AC:L/PR:N/UI:N, CWE-94 (code injection). Affected: vendor php, product php, versions 8.1.0 to less than 8.1.34, 8.2.0 to less than 8.2.30, 8.3.0 to less than 8.3.29.image:php, fixed 8.3.29"fixed": "8.3.29" cites the php.net page, quote "PHP 8.3.29 … fixes CVE-2026-109618".Later revisions come from the same loop: KEV listing it sets exploitedInTheWild, a Red Hat fix adds an rpm entry, and each revision re-runs matching for every account.
6. PHP2Shell, minute by minute
Each stage has a one-minute budget. The clock starts when the first watched source shows the vulnerability; NVD isn't on the path, because it often takes days.
7. Is an account affected?
Never a bare yes or no. A wrong "you aren't affected" on a Log4Shell-sized day is the worst thing we could say, so Not exposed needs complete coverage.
Exposed
At least one confirmed or likely match.
"You're exposed to PHP2Shell. 14 services in 6 repos run an affected PHP. 3 face the internet."
Not exposed
Nothing found, and every repo and asset was checked.
"You aren't exposed to PHP2Shell. We checked all 412 repos and 1,180 assets."
Not found so far
Nothing found in what we could check, and some of it we couldn't. Names the gap and the action that closes it.
"Not found so far. 38 repos have no SBOM yet, so we couldn't check them."
How one match is decided
Each affected entry in the threat meets each row of an account's index. This is the whole "is this customer affected?" test.
Rolled up per account: any Confirmed or Likely makes the answer Exposed. None, with everything checked, is Not exposed. None, with gaps or unknown versions, is Not found so far.
8. The queries, source by source
Nothing is queried in a customer's data when a threat arrives. Instead, each account's SBOM, assets and findings are read ahead of time, turned into one normalized row per piece of software per place, and kept in an index. At alert time there's one lookup against that index. Below: for each source, the request, one row that comes back, and the index row it becomes.
SBOM: confirmed packages, per repo
Read hourly as the account's reader, a page at a time, then each component's repos. A complete crawl marks each repo it listed as having an SBOM.
-- every component version, paged POST /api/components/list { "page": 0, "size": 100 } -- then, per component version, -- the repos that use it GET /api/components/{compositeId} /sub-products?page=0&size=100
{ "compositeId": "c-77120",
"name": "php:8.3.12-fpm",
"version": "8.3.12-fpm",
"purl": null,
"packageManager": null,
"componentType": "container" }
sub-products:
[{ "subProductId": 8812,
"name": "checkout-api",
"productName": "Payments" }]{ "kind": "runtime",
"ecosystem": "image",
"name": "php",
"version": "8.3.12-fpm",
"location": { "kind": "repo",
"id": "8812", "name": "checkout-api",
"application": "Payments" },
"source": "sbom",
"confidence": "confirmed" }How identity is worked out: the purl's type first (pkg:composer/… → composer, pkg:deb/debian/php8.2@…?distro=debian-12 → deb on debian:12), else the package manager, else a container's name → image. Operating-system components are skipped.
Assets: images, runtimes and internet exposure
Read hourly, a page at a time, with each asset's details for its properties. Gives where things run and whether that place faces the internet.
-- every asset, paged POST /api/v2/assets { "page": 0, "size": 100 } -- properties (images, runtime, -- engine) come with the details GET /api/v2/assets/{id}
{ "id": "55120",
"name": "web-04",
"kind": "HOST",
"active": true,
"os": "Debian GNU/Linux 12",
"internetExposure": "TRUE",
"properties": {
"imageNames": ["php:8.2.18-apache"]
} }{ "kind": "runtime",
"ecosystem": "image",
"name": "php",
"version": "8.2.18-apache",
"distro": "debian:12",
"location": { "kind": "host",
"id": "55120", "name": "web-04",
"internetFacing": true },
"source": "asset",
"confidence": "likely" }What's read from an asset: an image asset's own tag; imageNames a host or cluster runs; a cloud function's runtime (python3.12 → runtime python 3.12); a database's engine and engineVersion; kubernetesVersion; the OS gives the distro. The gap: the asset list carries no installed-package list, so a host's OS packages can't be ruled out from assets alone. That host stays "not fully checked", which keeps an account at Not found so far rather than Not exposed.
Findings: packages scanners have already seen
Every account has these. Read from the finding mirror after each ingest round (changes only), rebuilt daily. Only active, unresolved findings that name a component.
componentName, componentVersion,
filePath, cves, status,
subProduct (repo), product (app),
lastSeen
-- from the raw row, where kept:
purl, packageManager,
asset { id, name, armorCodeType,
internetExposure }// a host scanner { "componentName": "php8.2", "componentVersion": "8.2.30-1~deb12u1", "asset": { "id": 55120, "armorCodeType": "HOST" } } // a container scanner { "componentName": "php", "componentVersion": "8.2.18", "filePath": "php:8.2.18-apache (debian 12.5)", "subProduct": "billing", "lastSeen": "2026-10-03" }
// version suffix ~deb12u1 → deb { "ecosystem": "deb", "name": "php8.2", "version": "8.2.30-1~deb12u1", "distro": "debian:12", "location": "host web-04", "confidence": "likely" } // scan target → image + runtime { "ecosystem": "runtime", "name": "php", "version": "8.2.18", "location": "image php:8.2.18-apache", "evidence": "Seen in a scan on Oct 3", "confidence": "likely" }
How the ecosystem is worked out, in order: purl type → package manager → a distro suffix in the version (~deb12u1, .el9, -r0) → the file the finding names (composer.lock → composer, package-lock.json → npm) → otherwise a runtime by its name (nodejs → node). Every finding row is likely, dated by its last scan.
Confirmation: does a scanner already report this CVE?
A finding for the threat's own CVE turns that place from likely to confirmed. Where Zero mirrors findings it checks the mirror; for accounts it doesn't, it asks ArmorCode for one count per threat.
-- unresolved findings for the -- threat's CVEs, counted by repo POST /api/v2/findings/groups/count { "field": "subProduct", "filters": { "aqlQueryV2": [ "(cve IN ('CVE-2026-109618', 'CVE-2026-109619')) AND (status IN (<the account's unresolved statuses>))" ] } }
{ "8812": 3,
"9031": 1 }
-- repo id → open findingscheckout-api (8812): confirmed billing (9031): confirmed -- a repo with a finding but no -- index row is added as confirmed
At alert time: one lookup, then version rules in code
The threat's entries and aliases become (ecosystem, name) pairs. A runtime also goes by its names as deb, rpm and apk packages, so OS packages named after it are judged too.
(runtime, php) (runtime, php-fpm) (image, php) (deb, php8.2) (deb, php8.3) (deb, php8.3-fpm) (deb, libapache2-mod-php8.3) (rpm, php) (apk, php83) …
SELECT s.*, s.internet_facing OR coalesce(c.internet_facing,false) AS facing FROM ac.account_software s LEFT JOIN ac.account_software_coverage c ON c.tenant_id = s.tenant_id AND c.location_kind = s.location_kind AND c.location_id = s.location_id JOIN unnest($ecosystems, $names) AS q(ecosystem, name) ON s.ecosystem = q.ecosystem AND s.name = q.name WHERE s.tenant_id = ANY($tenants);
image php 8.3.12-fpm → leading version 8.3.12 → semver: in [8.3.0, 8.3.29) → affected, SBOM → confirmed deb php8.2 8.2.30-1~deb12u1 → debian:12 fixed 8.2.30-1~deb12u1 → deb compare: not below fix → not affected (patched) runtime php (no version) → unknown version
Indexed on (ecosystem, name), so it's one query across every account at once. Version comparison happens in code because a database can't compare Maven, PEP 440, Debian or RPM versions correctly. The same rows with no new inputs write nothing.
Coverage: turning "nothing found" into an answer
A second table says, per repo and asset, what we could check. It's what separates Not exposed from Not found so far.
ac.account_software_coverage location (repo / image / host / cloud) has_sbom -- a complete SBOM crawl listed it has_software_facts -- the asset lists its software covers -- kinds listed in full: package, -- runtime, os-package, product scanned_at, internet_facing
for each place:
covered = the place lists, in full, every
kind of software the threat affects
no matches and every place covered → Not exposed
no matches, some places not covered → Not found so far
"38 repos have no SBOM yet"
any confirmed or likely match → Exposed
Worked example (illustrative account)
| Row in the account's index | Came from | Result |
|---|---|---|
image php 8.3.12-fpm in repo checkout-api | SBOM | Confirmed in range |
image php 8.2.18-apache on host web-04, internet-facing | Asset | Likely in range |
deb php8.2 8.2.30-1~deb12u1 on host web-04 | Finding (host scanner) | Not affected Debian's fixed version |
runtime php 8.2.18 in repo billing | Finding (container scan, Oct 3) | Likely "seen in a scan on Oct 3" |
| Open finding for CVE-2026-109618 in billing | Confirmation count | Upgrades billing to Confirmed |
runtime php, no version, in repo legacy-portal | Finding | Unknown version |
This account's answer: Exposed, 3 places, 1 internet-facing, and legacy-portal listed as couldn't check.
9. Doing this inside ArmorCode
ArmorCode owns the SBOM, asset and finding data, so it doesn't need Zero's API crawl. It has two ways to answer "is this tenant affected?"
A. A normalized software index (recommended)
- One table per tenant (or one shared, tenant-keyed), one row per software per place: kind, ecosystem, name, normalized version, distro, location, internet-facing, source, confidence
- Kept up to date as SBOMs, assets and findings are ingested, not rebuilt per threat
- Indexed on (ecosystem, name): a new threat is one lookup across every tenant, then version rules in code
- Coverage kept beside it, so "not exposed" is only said when it's true
- Works the same for threats from Zero's catalog or ArmorCode's own feed
B. Query live, per tenant, per threat
- For each affected entry and alias: search SBOM components by name, findings by component name or CVE, assets by image and runtime
- Simple to start, fine for one tenant or a drill
- Slow across every tenant in the first minutes of a major event, when every tenant asks at once
- Filters can't compare versions per ecosystem, so results still need the same code-side version rules
- Aliases multiply the queries
Whichever is used, the live-query version of the three reads looks like this (pseudocode against ArmorCode's own tables; field names are illustrative):
-- SBOM: components named like the threat's entries or aliases SELECT component, version, purl, sub_product, product FROM sbom_components WHERE tenant_id = :tenant AND (purl_type, lower(name)) IN (('docker','php'), ('deb','php8.2'), ('apk','php83'), …) OR lower(name) IN ('php', 'php-fpm', 'php8.3-fpm', 'libapache2-mod-php8.3', …); -- Findings: the CVE itself (confirms), or the component (likely) SELECT sub_product, asset_id, component_name, component_version, last_seen FROM findings WHERE tenant_id = :tenant AND status IN (:unresolved) AND (cve && ARRAY['CVE-2026-109618','CVE-2026-109619'] OR lower(component_name) IN ('php', 'php8.2', 'php8.3', 'php83', …)); -- Assets: images, runtimes and engines that name the software SELECT asset_id, kind, os, internet_exposure, image_names, runtime FROM assets WHERE tenant_id = :tenant AND active AND (EXISTS (SELECT 1 FROM unnest(image_names) i WHERE i ~ '(^|/)php:') OR runtime ILIKE 'php%'); -- Then, in code, for every row: compare the version with the entry's ranges using the -- ecosystem's comparator (semver, maven, pep440, composer, deb, rpm, apk); a distro row -- against that distro's fixed version; an unreadable version is "unknown", never "not affected".
10. Eng's questions, answered
How should the threat feed be enriched?
Each threat needs its affected software as machine-readable entries in the same identity scheme as the customer's inventory: kind, ecosystem, name, aliases, version scheme, affected ranges, fixed versions, and the distro release for a distro package. It also needs the signals that decide who is told. Section 3 lists the source for each field; section 4 breaks down the LLM research that fills the parts no structured source has (runtimes, products, images, aliases, exploit and attention).
How do we know a customer is affected?
Join the threat's entries to the customer's software on normalized identity (ecosystem and name, plus aliases), then compare versions with that ecosystem's rules. Confirm a place when a scanner already reports the CVE there. Never say "not affected" when a version or comparator is unknown, and only say Not exposed when every repo and asset was checked.
What queries do we run on their data?
None at alert time in Zero: SBOM components and their repos, assets with their properties, and findings with their component fields are read ahead of time into the index (section 8). At alert time it's one indexed lookup across every account, plus one finding count by repo for the threat's CVEs where findings aren't mirrored. Inside ArmorCode the same three reads can run directly (section 9).
Gaps to plan for
- No endpoint for repo files yet, so Dockerfile base images and deployment manifests (which image runs where) aren't read
- Assets carry no installed-package list, so hosts' OS packages are known only through scanner findings
- Red Hat isn't a source yet, so RHEL backports can show as Unknown version
- Appliances and hosted products match only when an asset names the vendor and product consistently; a product name table is needed