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.

What runs for customers today. Everything here runs for ArmorCode QA tenants. For production (customer) tenants, Zero's customer-safe mode turns off the per-account half: Zero keeps no software index and sends no threat notices. Only the shared public threat catalog runs for everyone, and it holds public data only. The agreed direction is that ArmorCode reads that catalog (or builds its own) and does the matching and alerting itself, so the per-account half below is the model to copy. All versions in the PHP2Shell examples are illustrative.

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.

PUBLIC DATA - ONE SHARED COPY, NO CUSTOMER DATA PER ACCOUNT - EACH TENANT'S OWN DATA 1. Sources CVE List1 min GitHub Advisories2 min OSV (8 ecosystems)1 min Vendor feeds5 min CISA KEV5 min FIRST EPSShourly Debian, Ubuntu, Alpine CISA, CERTs, news5 min Says what's affected Adds signals only (exploited, likelihood, distro fixes, attention) Each source keeps its own cursor; unchanged records cost nothing. 2. Watch Store every record Unchanged? No work Candidate when: • critical (CVSS 9+), or listed in KEV • published < 30 days ago • names a CVE Everything else is ignored (a few an hour) candidates 3. Understand Group CVEs into one event (PHP2Shell is 2 CVEs) Copied from sources: • ranges: GitHub, OSV • distro fixed versions • severity A model reads the advisory: • name, plain summary • runtimes and products • RCE, no auth, exploit Check: valid schema, and every version range parses Tier (rules, not the model): 1 = major event, everyone 2 = matching accounts only threat 4. Match Look up the index by name and its aliases Compare each version by its ecosystem's rules Answer per account: Exposed Not exposed Not found so far Software index One row per software, per place: kind, ecosystem, name, version, location, internet-facing, confidence ac.account_software one lookup ArmorCode data Findings - every pass SBOM - hourly Assets - hourly Repo files - no endpoint yet Copied ahead of time, never at alert time 5. Deliver Outbox: one message per person, threat and level • Open apps: at once • Email: admins by default • Slack: DM or channel • Text: opt-in Within each person's hours Person is told about 5 minutes after publishing

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.

FieldUsed forZero todayHow ArmorCode should source it
cvesGrouping, linking findingsCode CVE List, GitHub and OSV aliasesCode Fetch the CVE record by id. LLM may propose that CVEs are one event; code accepts only when one advisory names them together
nameDisplay, tierLLM reads advisories and headlinesLLM Search the CVE ids in news and CERT alerts. Use a name only if the vendor, a CERT or two outlets use it
summary, descriptionDisplayLLM from the documentsLLM Plain words from the vendor advisory and CVE record
severity, cvssIs it a candidate, tierCode the CNA's record and the vendor's own wordCode Never the model. Highest current word across sources; a vendor's later "High" replaces its "Critical"
affected packagesMatchingCode GitHub and OSV ranges, exactCode GitHub Advisory API and OSV by CVE id. These beat anything the model reads for the same package
affected runtimes and productsMatchingLLM reads the vendor advisory and the CVE record's affected blockLLM 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 packagesMatching (backports)Code Debian, Ubuntu and Alpine trackersCode Tracker APIs per CVE (Red Hat VEX too). Fixed version per release, so a patched old version isn't flagged
affected imagesMatching containersLLM names the official imageLLM List the official image's tags (Docker Hub); the first tag at the fixed version is the fix
aliasesMatching (other spellings)LLM plus a maintained alias tableLLM 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, remoteCodeExecutionTierLLM reads the advisoryCode first from the CVSS vector (AV:N/PR:N) and CWE; LLM confirms from the advisory text
exploitPublicTier (urgency)LLM reads advisories and newsLLM Search for proof-of-concept code and exploit write-ups for the CVE ids; cite the page
kev, exploitedInTheWildTier (urgency)Code CISA KEV every 5 minutesCode KEV feed. LLM adds a vendor's "exploited" statement, cited
vendorEmergencyReleaseTier (urgency)LLM reads the vendor advisoryLLM An out-of-band release or "upgrade immediately" wording in the vendor's own advisory
attention, namedTierCode configured CISA, CERT and news feedsLLM Search CISA, national CERTs and news for the CVE ids or name; count distinct sources
widelyUsedTierCode a maintained listCode A maintained list of runtimes, frameworks and products, plus ecosystem popularity. Not the model
agreeingSourcesTier 1 gateCode counts sources naming the same softwareCode Two independent sources must name the same affected software before everyone is alerted
epssDisplay, priorityCode FIRST dailyCode FIRST EPSS API
tierWho is toldCode rules over the signalsCode Rules in configuration, tuned by replaying past CVEs. The LLM never picks the tier
Zero today versus the proposal. Zero's model does no research of its own: the watcher collects the documents (CVE records, GitHub, OSV, vendor feeds, KEV, headlines) and the model only reads them. That's safe but misses runtimes and products a vendor announces on a page we don't poll. An LLM that researches can find those, as long as everything it returns is cited and checked by code, as below.

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.

CODE 1. Trigger A new CVE record, or a vendor's critical advisory Only when critical: CVSS 9+, the vendor says critical, or it's in KEV A few runs an hour CODE 2. Gather Fixed lookups by CVE id: • CVE record • GitHub, OSV • KEV, EPSS • distro trackers Exact data, no model. Becomes the LLM's starting brief LLM 3. Research An agent with search and fetch tools finds: • the vendor advisory • fixed releases • package and image names (aliases) • exploits, attention Every page it reads is saved LLM 4. Extract Fills the feed record, schema-constrained Every fact cites a saved page and a quote Leaves a field empty rather than guess CODE 5. Verify • quotes are on the page • every version is in a quote • ranges parse • exact ranges win • 2 sources agree • tier by rules Fails? Mark versions unknown, still publish CODE 6. Publish A new revision of the record in the feed Runs again when a source changes (a fix release, KEV, a revised range): matching re-runs on each revision

The research agent's tools

ToolCallsGives
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 aliasesPackage 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 VEXFixed package version per release, and the binary package names
image_tags(image)Docker Hub tags for official imagesWhich tags carry the fixed version
web_search(query)A search APIVendor advisories, release notes, CERT alerts, news, proof-of-concept repos
fetch(url)Any public page, saved with a hashThe 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.

1
Code - trigger
CVE List delta: CVE-2026-109618 published by the PHP Group, CVSS 9.8
Critical and new, so it's a candidate. A second record, CVE-2026-109619, arrives two minutes later from the same CNA.
2
Code - gather
cve_record("CVE-2026-109618")
Vector 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.
Fills severity, cvss; hints unauthenticated, remoteCodeExecution; a first runtime range to confirm
3
Code - gather
github_advisories("CVE-2026-109618"), osv("CVE-2026-109618"), kev(...), epss(...)
No GitHub or OSV advisory (not a library). Not in KEV. No EPSS score yet.
Fills kev = false; tells the agent the runtime and its packages must come from research
4
LLM - research
web_search("CVE-2026-109618 site:php.net") → fetch("https://www.php.net/...")
The PHP 8.3.29 release announcement: "a security release fixing CVE-2026-109618 and CVE-2026-109619 … all users are strongly encouraged to upgrade". Same-day releases 8.1.34 and 8.2.30.
Fills fixedVersions per branch, vendorEmergencyRelease; one vendor advisory naming both CVEs, so code groups them
5
LLM - research
distro_tracker("CVE-2026-109618")
Debian 12: source package php8.2, fixed in 8.2.30-1~deb12u1. Ubuntu 24.04: php8.3, fixed in 8.3.29-0ubuntu0.24.04.1. Alpine 3.20: php83, fixed in 8.3.29-r0. Binary packages: php8.2-fpm, php8.2-cli, libapache2-mod-php8.2, php8.3-fpm, …
Fills the os-package entries with each distro's fixed version, and aliases
6
LLM - research
image_tags("library/php")
Tags 8.3.29-fpm, 8.3.29-apache and 8.3.29-cli appeared the same day; earlier 8.3 tags are affected.
Fills the image entry: image:php, fixed 8.3.29
7
LLM - research
web_search("\"CVE-2026-109618\" OR PHP2Shell") ; web_search("CVE-2026-109618 proof of concept")
A CISA alert and two security news sites call it "PHP2Shell". No public exploit code found yet.
Fills name, named, attention (3 sources), exploitPublic = false
8
LLM - extract
Return the feed record as JSON; each fact with {url, quote}
The record in section 2, plus an evidence list: e.g. "fixed": "8.3.29" cites the php.net page, quote "PHP 8.3.29 … fixes CVE-2026-109618".
9
Code - verify and publish
verify(record, savedPages)
Every quote is on its saved page; every version appears in a quote; ranges parse as semver, deb and apk; the CVE record and php.net agree on what's affected (2 sources); PHP is on the widely-used list. Rules: critical + unauthenticated RCE + widely used + urgent (emergency release, 3 attention sources) + named + 2 agreeing → tier 1. Published as revision 1.

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.

Minute 0
php.net posts
An emergency release fixes two CVEs.
By minute 1
Watched
The php.net feed and the CVE List are polled. Critical and new: a candidate.
By minute 2
Understood
Both CVEs become one threat. Affected: PHP, its distro packages and the official image. Tier 1.
By minute 3
Matched
Every account gets its answer. Open apps show "Checking 412 repos…" and fill in.
By minute 4
Told
Home notice for everyone, email to admins, Slack and texts for those who chose them.
After
Keeps updating
Scanner findings confirm places, fixes resolve them, index changes re-answer.

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.

From the threat runtime: php aliases: php-fpm, php8.3-fpm, image php fixed in 8.3.29 From the index image php:8.3.12-fpm repo checkout-api internet-facing source: SBOM Same software? The name or one ofits aliases matches yes no Not this row Version readable? A known version, and acomparator for its kind yes no Unknown version counts toward Not found so far In affected range? Distro packages use thedistro's own fixed version no Not affected backported fixes count yes Confirmed An SBOM or lockfile lists it,or a scanner already filed afinding for this CVE Likely Seen in an earlier scan (dated),or a Dockerfile base image ordeployment manifest says so

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.

Request
-- 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
One row back
{ "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" }]
Index row
{ "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.

Request
-- every asset, paged
POST /api/v2/assets
{ "page": 0, "size": 100 }

-- properties (images, runtime,
-- engine) come with the details
GET /api/v2/assets/{id}
One row back
{ "id": "55120",
  "name": "web-04",
  "kind": "HOST",
  "active": true,
  "os": "Debian GNU/Linux 12",
  "internetExposure": "TRUE",
  "properties": {
    "imageNames": ["php:8.2.18-apache"]
  } }
Index row
{ "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.

Fields read per finding
componentName, componentVersion,
filePath, cves, status,
subProduct (repo), product (app),
lastSeen

-- from the raw row, where kept:
purl, packageManager,
asset { id, name, armorCodeType,
        internetExposure }
Two rows back
// 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" }
Index rows
// 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.

Request
-- 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>))" ] } }
Answer back
{ "8812": 3,
  "9031": 1 }
-- repo id → open findings
Effect
checkout-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.

Lookup pairs for PHP2Shell
(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)
…
The query (Postgres)
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);
Then, per row, in code
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 indexCame fromResult
image php 8.3.12-fpm in repo checkout-apiSBOMConfirmed in range
image php 8.2.18-apache on host web-04, internet-facingAssetLikely in range
deb php8.2 8.2.30-1~deb12u1 on host web-04Finding (host scanner)Not affected Debian's fixed version
runtime php 8.2.18 in repo billingFinding (container scan, Oct 3)Likely "seen in a scan on Oct 3"
Open finding for CVE-2026-109618 in billingConfirmation countUpgrades billing to Confirmed
runtime php, no version, in repo legacy-portalFindingUnknown 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