From CVE alert to deployed patch, the missing pipeline between OSV.dev and your gateway fleet
You have an OTA pipe. You do not have CVE management. Five steps connect a feed entry to a deployed remediation, and most teams skip three of them. A walkthrough plus the data shapes that make the join cheap.
Most hardware teams I talk to have an OTA pipeline. Pushed binaries, A/B slot, signature verification, rollback on failed boot. They will tell me they have CVE management handled because "we can ship a patched binary in under an hour." That is not CVE management. That is the last step of CVE management, sitting alone in an empty house.
CVE management is the pipeline that connects CVE-2024-XXXX in rumqttc < 0.24.0 to "a patched binary running on every gateway that contains the vulnerable code, with an audit trail your security team can hand to a customer." Five steps stand between those things. Skip any one and what you have is OTA ceremony, not security.
The five steps
- Ingest a vulnerability feed.
- Match feed entries against your SBOMs.
- Compute the blast radius across releases, gateways, and namespaces.
- Roll out a remediation under policy, with appropriate gating.
- Emit an immutable evidence trail.
Steps 1, 2, and 5 are largely solved by existing tooling. Steps 3 and 4 are where every team I have seen has built something custom, badly, and where a coherent platform has the most leverage. Let us walk through them.
Step 1: ingest the feed
The feed landscape is fragmented but the practical answer is simple. Pull from OSV.dev for ecosystem-level CVEs (Cargo, npm, PyPI, Go, Debian, Alpine), and from the CISA KEV catalog for the actively exploited subset. Together they cover roughly 80 percent of practical decision-making for a typical IoT fleet. Pick up NVD for CPE enrichment when OSV does not have it, and Espressif's advisory feed if you ship ESP32 firmware.
The ingest job itself is worth getting right. Two properties matter: idempotent inserts and a first_seen_at timestamp (so you can answer "when did we know" later). OSV publishes per-vulnerability JSON in a deterministic format:
{
"id": "GHSA-7mxx-x9p7-7vmm",
"aliases": ["CVE-2024-39573"],
"summary": "rumqttc allows ...",
"affected": [
{
"package": { "ecosystem": "crates.io", "name": "rumqttc" },
"ranges": [
{ "type": "SEMVER", "events": [{ "introduced": "0.0.0" }, { "fixed": "0.24.0" }] }
]
}
],
"severity": [{ "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }],
"references": [{ "type": "ADVISORY", "url": "..." }]
}The thing to internalize is the affected[].package plus affected[].ranges shape. Ecosystem, package name, version range. That is the join key, and every downstream step is a query keyed off some variant of (ecosystem, package, vulnerable_range). Preserve that shape losslessly. Teams that flatten OSV into a denormalized "advisory" table end up rewriting this layer within six months.
Where this step breaks: teams treat the feed as a Slack channel instead of structured data. They wire OSV into PagerDuty, get paged on every advisory, and within two weeks the channel is muted. The feed is data, not signal. Signal comes from step 3.
Step 2: match against SBOMs
An SBOM is the inventory of every package and version in a built artifact. For Rust gateways it is the resolved Cargo.lock plus transitive crate metadata. For ESP32 firmware it is the idf_component.yml resolved tree plus any vendored C. For a Linux gateway image, all of that plus the apt or apk set in the base layer.
Generate SBOMs at build time, not after. Syft is the practical default. Build-time SBOMs beat post-hoc binary unpacking because you have the lockfiles and build environment in front of you. Trivy can unpack a finished binary, useful for third-party images, but do not reverse-engineer your own builds.
Matching is where OSV-Scanner and Grype live. Both take an SBOM and a vuln database and emit "these packages in these versions are affected." Pick one, run in CI on every build, store the output alongside the binary.
Now you have two tables in Postgres that matter:
-- Every release we have ever cut, with its SBOM and scan output.
CREATE TABLE releases (
release_id UUID PRIMARY KEY,
artifact TEXT NOT NULL, -- 'gateway-linux', 'gateway-esp', etc.
version TEXT NOT NULL,
sbom JSONB NOT NULL, -- CycloneDX
scan_findings JSONB NOT NULL, -- output of osv-scanner / grype
built_at TIMESTAMPTZ NOT NULL,
cluster_id TEXT NOT NULL DEFAULT 'default'
);
-- Flattened view for fast joins. Refreshed on each new release.
CREATE TABLE release_packages (
release_id UUID REFERENCES releases(release_id),
ecosystem TEXT NOT NULL,
package TEXT NOT NULL,
version TEXT NOT NULL,
PRIMARY KEY (release_id, ecosystem, package, version)
);
CREATE INDEX ON release_packages (ecosystem, package);The flattened release_packages table is what makes the join in step 3 cheap. Every new advisory becomes a single indexed lookup against (ecosystem, package) plus a version-range filter. Without the flattening, you scan JSONB on every advisory ingest, which works at small scale and falls over the moment you actually care.
Where this step breaks: teams generate SBOMs for cloud services and forget the firmware. The firmware is where the real risk lives because it runs on hardware they do not control. Or they generate SBOMs only at release tagging, so hotfix branches ship without one and a year later nobody can tell which releases contained the vulnerable openssl.
Step 3: compute the blast radius
This is where most platforms offer nothing and you write it yourself. The question is operational: a CVE just dropped against rumqttc 0.23. Which of our gateways are running a release that contains rumqttc 0.23? Which projects own them? Which environments? How many sit in namespaces with a contractual uptime SLA?
The answer is a join, not a slide. Three more tables joined to the two above:
-- The fleet.
CREATE TABLE gateways (
gateway_id UUID PRIMARY KEY,
namespace_id UUID NOT NULL,
pinned_release_id UUID REFERENCES releases(release_id),
last_seen_at TIMESTAMPTZ,
cluster_id TEXT NOT NULL DEFAULT 'default'
);
CREATE TABLE namespaces (
namespace_id UUID PRIMARY KEY,
org_id UUID NOT NULL,
environment TEXT NOT NULL, -- 'prod', 'staging', 'dev'
feature_flags JSONB NOT NULL DEFAULT '{}'
);The blast radius query for a single advisory is then a five-table join:
SELECT g.gateway_id, n.namespace_id, n.environment, r.version, n.org_id
FROM gateways g
JOIN releases r ON g.pinned_release_id = r.release_id
JOIN release_packages rp ON r.release_id = rp.release_id
JOIN namespaces n ON g.namespace_id = n.namespace_id
WHERE rp.ecosystem = 'crates.io'
AND rp.package = 'rumqttc'
AND rp.version IN (SELECT v FROM affected_versions WHERE advisory_id = $1)
AND g.last_seen_at > NOW() - INTERVAL '7 days';Run this at advisory ingest time. The output is your worklist: exactly which gateways are exposed, which customers they belong to, what environment they live in. Empty output, log a no-op. Non-empty, the next step is policy.
The version comparison is the one fiddly bit. Semver works for crates and npm. Debian and Alpine use their own comparators. OSV publishes range events as introduced and fixed markers per ecosystem, and the OSV schema docs spell out the rules. You will write a comparator per ecosystem you care about. Six or seven cover most fleets.
Where this step breaks: teams stop at "we have a list of CVEs and a list of gateways" and never write the join. The result is a security team producing quarterly spreadsheets by hand, and engineers arguing about which CVEs actually apply. The data shapes above make the argument disappear because the answer is a query, not a meeting.
Step 4: roll out under policy
You have a worklist of affected gateways and a patched release. Now what? The wrong answer is "auto-deploy tonight." The right answer is policy that matches the risk. A v1 policy layer has roughly four knobs:
- Severity threshold. Below CVSS 4.0, default-suppress in dev and staging. Surface in a weekly digest. Above CVSS 7.0, surface immediately as a remediation task in the dashboard. KEV entries jump the queue regardless of CVSS, because exploitation is no longer hypothetical.
- Environment gating. Auto-stage the patched release into dev namespaces on ingest. Require explicit operator action to promote to staging. Require a second approval to promote to production. The two-person rule is borrowed from change management for a reason.
- Rollout shape. Canary one or two gateways per namespace, wait for
last_seen_atto advance and telemetry to confirm steady state, then expand. The orchestrator owns this. Same OTA pipe you already have, driven by a CVE worklist instead of a human clicking deploy. - Forcing functions. KEV has a due-date field for federal agencies. Borrow it. If a gateway is exposed to a KEV advisory and the operator has not approved within the window, escalate. The escalation ladder is policy, not code.
Honest take on full automation: it is almost never correct in v1. The cost of a bad auto-deploy on hardware in a hospital or a power plant is asymmetric. The cost of an operator clicking a button on a pre-staged release is a few minutes. Build the system so the human is the trigger for production change and the system has done the prep. Teams that automate the human out of the loop ship either something so conservative it never fires or so aggressive it eventually takes a customer down.
Where this step breaks: there is no policy layer at all. The CVE lands in a spreadsheet and a senior engineer makes a judgment call by hand every time. That works at 10 customers and breaks at 100. The policy does not have to be sophisticated. It has to exist, be encoded as data, and be queryable.
Step 5: emit the evidence
Every state change in this pipeline is a SOC 2 audit event. Advisory ingested, blast radius computed, patched release staged, operator approved, rollout completed. Each is a row in an immutable audit log with actor, timestamp, before-state, after-state, and reason. Automated events name the system as actor (system:cve-pipeline), so the trail is complete.
Immutability is a database constraint, not a convention. A trigger that rejects UPDATE and DELETE. A 13-month retention via partition drop, never row delete:
CREATE TABLE audit_log (
event_id UUID PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL, -- 'cve.ingested', 'rollout.approved', etc.
resource TEXT NOT NULL, -- 'advisory:CVE-2024-XXXX', 'gateway:abc-123'
before_state JSONB,
after_state JSONB,
reason TEXT,
cluster_id TEXT NOT NULL DEFAULT 'default'
) PARTITION BY RANGE (occurred_at);This is what you produce when a customer's compliance team asks "show me the trail for CVE-2024-XXXX." A CSV filtered by resource = 'advisory:CVE-2024-XXXX', readable without your help. That is the moment the pipeline pays for itself.
Where this step breaks: audit is bolted on after a customer asks for it, which means it lacks the events from before the question. Bake it in day one. Every mutation writes an event, no exceptions.
Where agents fit (and where they do not)
LLM-driven dependency-bump PR drafts are a reasonable use of agents here. Renovate-style automation that reads OSV and opens a PR with a version bump and a passing test run is a clear win. Cost of a wrong PR: operator clicks no. Cost of a right PR: one less chore. Good asymmetry.
Auto-merging those PRs into production firmware on medical devices is a different question. Use agents where being wrong is cheap. Use them as drafters and explainers, not approvers. The policy layer in step 4 is exactly where the human stays in the loop, and the agent prepares every input they need to make the call fast.
What you can use today, what you have to build
The OSS toolchain covers more of this pipeline than people realize. Ingest is OSV.dev plus CISA KEV, both stable JSON feeds. SBOMs are Syft for generation, OSV-Scanner or Grype for matching. The OTA layer you almost certainly already have. The audit layer is a Postgres table with a trigger.
What you have to build is the middle. The flattened release_packages table and the blast-radius join. The policy layer that turns a CVE into a gated worklist. The dashboard that shows an operator "here are the 14 gateways exposed, here is the staged patched release, click to promote." Audit emission woven through every state change.
That middle is what we are building into SCADABLE. OTA and audit were already there because the platform needed them. Wiring OSV ingestion into the same release and gateway tables the orchestrator owns turned out to be a small amount of glue and a lot of careful schema work. The result: a CVE drops, the dashboard shows affected gateways within minutes, the patched release is one operator approval away.
If you are staring at the gap between your OTA pipe and a real CVE program, this post is the rough scope. Four to eight weeks for a senior engineer, more if you have not done the SBOM hygiene yet. The data shapes above are the part you should not get wrong, because everything else hangs off them.
Talk to a founder
If you have an OTA pipeline today and you're now staring at the gap between "we ship updates" and "we have a CVE program," let's talk. I'd rather see your stack than guess at it.