Auto-scanned CVE reports for every recent Raspberry Pi OS image release, plus the tooling that produces them.
Live index: https://raspberrypi.github.io/cve-list/
A scheduled GitHub Actions workflow walks the SBOMs published on downloads.raspberrypi.com, runs each through grype, and republishes the results as interactive HTML reports.
| Path | Purpose |
|---|---|
scan-image.py |
Scan your own image and produce a report — the whole pipeline on an SBOM or rootfs you supply, no downloads. Start here. |
scan-images.py |
Discover Pi OS SBOMs on downloads.raspberrypi.com, scan each, generate the dashboard. (Powers the hosted site.) |
filter-sbom.py |
Drop packages from an SBOM (by name glob) before scanning — e.g. a kernel that's in dpkg but isn't booted. |
inject-kernel.py |
Force a Linux kernel version into an SBOM so grype assesses it — for a custom / BSP kernel that isn't a dpkg package and is therefore invisible to syft. |
rpt-crosscheck.py |
Recover CVEs grype hides on Pi +rpt packages via an artificial epoch. |
kernel-osv-crosscheck.py |
Replace Debian's branch-based verdicts on the Pi kernel with Raspberry Pi's per-kernel OSV feed: each CVE assessed for each released kernel package by commit ancestry and the files compiled into it. |
kernel-nvd-crosscheck.py |
Add kernel CVEs from NVD's per-branch ranges for CVEs the kernel CNA hasn't assessed (older ones from other CNAs). |
apt_sources.py |
Which archives each image type installs from (Debian + Pi archive for arm64, Raspbian + Pi archive for armhf); shared by simulate-upgrade.py and debian-ahead.py. |
debian-ahead.py |
Per-CVE fix availability: installable via apt (Debian / Raspbian, each with the Pi archive) or "Debian, not Raspbian" (armhf rebuild backlog). Epoch-safe for +rpt rebuilds; branch-aware for kernel/NVD fixes. |
simulate-upgrade.py |
Build a synthetic post-apt full-upgrade SBOM against the archives the image really uses (apt_sources.py). |
exceptions.json |
Reviewed CVE exceptions: CVEs that don't affect a binary package as built from specific source versions. See Reviewed exceptions. |
apply-exceptions.py |
Apply exceptions.json to a scan: covered findings move out of the counts into the report's "excepted" section; ones the exception no longer covers are flagged for re-review. |
make-report.py |
Render a grype scan JSON into the interactive HTML report. |
history/ |
One <variant>.jsonl per image: a record per weekly scan, both states. Appended and committed by CI; see history/README.md. |
backfill-history.py |
One-off: recover past weeks' totals from old CI run logs into history/ (needs gh logged in). |
tests/ |
Unit tests (stdlib unittest): python3 -m unittest discover tests. |
.github/workflows/scan-and-publish.yml |
CI workflow: scans, commits history/, deploys to GitHub Pages weekly. |
scan-image.py runs the full Raspberry Pi-aware analysis on an image you
already have — the same steps as the hosted dashboard, without any of the
downloading/discovery. Each Pi-specific step self-skips when it doesn't apply,
so it's also fine on a plain Debian image.
# From a mounted image rootfs (generates the SBOM with syft):
./scan-image.py --dir /mnt/myimage-root --out report.html
# -> report.html (as scanned) and report-upgraded.html (after a simulated apt full-upgrade)
# From an SBOM you already produced (syft SPDX JSON, optionally .xz/.gz):
./scan-image.py --sbom myimage.spdx.json --out report.html
# The running system:
sudo ./scan-image.py --dir / --out report.htmlAnything you can mount as a directory works. --dir just needs a rootfs tree.
To scan a .img file, loop-mount it first, then point --dir at the rootfs:
LOOP=$(sudo losetup -fP --show myimage.img)
sudo mount -o ro "${LOOP}p2" /mnt/root # p2 = rootfs on Pi OS images
./scan-image.py --dir /mnt/root --out report.html
sudo umount /mnt/root && sudo losetup -d "$LOOP"To scan a squashfs rootfs (e.g. an rpi-image-gen build, a live/installer
filesystem.squashfs, or an appliance image), mount it read-only and point
--dir at the mount:
sudo mkdir -p /mnt/root
sudo mount -t squashfs -o loop,ro myrootfs.squashfs /mnt/root
./scan-image.py --dir /mnt/root --out report.html
sudo umount /mnt/rootIf the squashfs lives on a partition inside a larger .img, combine the two:
losetup -fP the image, then mount -t squashfs -o loop,ro "${LOOP}pN" /mnt/root.
No root or squashfs kernel module? Extract instead and scan the tree — the
result is identical:
unsquashfs -d ./rootfs myrootfs.squashfs # writes ./rootfs
./scan-image.py --dir ./rootfs --out report.htmlThe package database (/var/lib/dpkg/status) and the binaries syft inspects
all live in the rootfs, so a mounted or extracted squashfs gives exactly the
same SBOM — and therefore the same report — as scanning the running system.
Prerequisites: python3, grype (+ its
DB, grype db update), dpkg; plus syft
if you use --dir. Step 3 downloads the kernel OSV feed (cached for 6 hours)
and steps 5–6 (fix availability and the upgrade simulation) fetch apt Package
indexes over the network — pass --offline to use the cached feed and skip 5–6.
Distro (trixie/bookworm) and arch (arm64/armhf) are auto-detected from the SBOM;
override with --distro/--arch if needed.
The helper scripts scan-image.py orchestrates (filter-sbom.py,
rpt-crosscheck.py, kernel-osv-crosscheck.py, kernel-nvd-crosscheck.py, debian-ahead.py,
simulate-upgrade.py, make-report.py) are the reusable, image-agnostic core
— scan-images.py uses the same ones.
Sometimes a package is registered in dpkg but isn't really part of the
running system — e.g. an appliance can carry a kernel (linux-image-6.6.31+rpt-*)
that isn't the one it boots, so every CVE for it is noise. Filter such packages
out of the SBOM before it reaches grype, so the references never exist in
the scan, the cross-checks, or the report:
# drop a non-booted kernel by version (glob on package name; repeatable)
./scan-image.py --sbom myimage.spdx.json --out report.html \
--exclude '*6.6.31*'
# or keep the patterns in a file (one glob per line, '#' comments allowed)
./scan-image.py --dir /mnt/root --out report.html --exclude-file exclude.txtPatterns are fnmatch globs against the package name (*6.6.31*,
linux-*-6.6.31+rpt*, exact names, …). filter-sbom.py can also be run on its
own to produce a filtered SBOM. This is the right place to suppress
false-positive packages: unlike hiding rows in the report, a filtered package
contributes nothing to any count, cross-check, or the upgrade simulation.
A custom, Buildroot, or vendor-BSP kernel is not a dpkg package, so syft
never records it and grype cannot assess it at all. The only kernels in the SBOM
are whatever linux-image-* packages sit in the dpkg database — and on an
appliance those are frequently not the kernel that actually boots. A common
pattern: dpkg still lists the stock Pi kernels (e.g. 6.12.93+rpt, 6.6.31+rpt)
pulled in with the base image, but the device actually boots a custom kernel with
a vendor suffix (e.g. 6.12.94-myvendor) that has no dpkg record and no
linux-image package — only its /lib/modules/6.12.94-myvendor tree gives it away.
--kernel-version injects a Debian-shaped linux-image-<version> package at the
version you name, so the pipeline assesses the real kernel: grype's Debian
linux advisory matcher fires (branch-tracked CVEs) and kernel-nvd-crosscheck
adds NVD's per-branch ranges (the Raspberry Pi OSV feed covers released Pi
kernel packages only, so it leaves an injected kernel alone). Pair it with --exclude of the non-booted dpkg
kernels so they don't double-count:
./scan-image.py --sbom myimage.sbom.json --out report.html \
--exclude '*6.6.31*' --exclude '*6.12.93*' \
--kernel-version 6.12.94-myvendorThe version string is used verbatim (so the report is truthful) and its upstream
X.Y.Z is what the CVE ranges compare against. This is an upstream-baseline
assessment: a custom kernel may carry backports the baseline lacks, or omit some,
so treat the result as the closest automatable picture rather than proof — the
branch hint (below) tells you which findings are fixable within your branch.
inject-kernel.py can also be run standalone to produce the augmented SBOM.
Expand a CVE row to see its affected packages. Two details help explain the hit:
- Found in — for non-
debpackages (go-modules, embedded binaries, …), the file the package was detected in, so you can see which binary pulls in a vulnerable module (e.g. astdlibCVE traced tousr/bin/ncwstunnel). This comes from the SBOM, so pass it:make-report.py … --sbom <sbom>(scan-image.py/scan-images.pydo this automatically). - Branch hint — for kernel-style
X.Y.Zpackages, the fix cell notes whether a fix exists on the installed version's own branch (“→ fixed in your 6.12 branch at 6.12.95”, an in-place patch upgrade) or only on newer branches (“⚠ no 6.12 fix — only newer branches”). NVD often lists per-branch fix points, e.g.6.1.177, 6.6.144, 6.12.95, 6.18.38— the hint picks out the one that applies to your kernel.
Sometimes grype is right about the data and wrong about the package. Debian's
security tracker records a CVE against a source package, and grype applies
it to every binary package built from that source, even one that doesn't
contain the vulnerable code. The motivating case is libwmflite-0.2-7: libwmf
embeds a private copy of libgd, so Debian tracks several old libgd CVEs against
source libwmf. But that copy is linked only into libwmf-0.2-7, not into
libwmflite, which is the only one the images install. Debian's entry is
correct for the source, so the fix isn't upstream; it's a binary-level fact
this pipeline records in exceptions.json.
An exception is deliberately narrow, so it can't quietly cover something nobody reviewed:
| Field | Meaning |
|---|---|
id |
short name, shown in reports and warnings |
source, sourceVersions |
the Debian source package and the exact source versions reviewed. Rebuilds of the same source (+b1, Raspbian rebuilds) stay covered; a new upstream release or a security update (+deb13u1) is a new source version and is not. |
packages |
binary packages the exception covers |
affectedPackages |
optional: other binaries from the same source that were reviewed and are affected, so they are reported normally without asking for review |
vulnerabilities |
the CVE / GHSA IDs covered, listed explicitly. A new CVE against the same package is never covered. |
status, justification |
not_affected plus an OpenVEX justification, e.g. vulnerable_code_not_present |
reason, evidence |
why, in prose, and how to check it again (files, commands, tracker links) |
reviewedBy, reviewed, expires |
who and when; expires is required and at most two years after reviewed |
apply-exceptions.py runs on every scan, after the cross-checks and before
fix availability, in both scan-images.py and scan-image.py. For each finding
an exception was written for, the result is one of:
- excepted: listed package, reviewed source and version, not expired. The finding
moves from
matchestoexceptedMatchesin the scan JSON (so the published raw JSON still has it, tagged with the exception) and leaves every count. Because one exception applies across every image, the dashboard describes each exception once, in a Reviewed exceptions section on the overview page, collapsed by default to a one-line summary (which also flags any exception needing re-review): its reason, evidence, reviewed source versions, reviewer and expiry, and a grid of its CVEs against every image state (excepted, needs review, or not present). Each report's header gives the excepted count and links there. A standalone report (scan-image.py, ormake-report.pywithout--exceptions-url) lists its excepted CVEs under the table instead. - needs review: the exception has expired, the package is built from a source version nobody reviewed, the package is now built from a different source, or it's a binary from the reviewed source that the exception doesn't list. The finding stays in the table and the counts, tagged exception: review with the reason, and there's a filter chip for these. The overview grid marks it, and the CI log carries a GitHub Actions warning.
- untouched: a binary listed in
affectedPackages, or a CVE the exception doesn't name.
So exceptions never need revisiting on a schedule of their own: they ask for a
re-review only when the package actually changes, or at expiry. The file is
validated strictly before any scan (unknown fields, malformed IDs, the same
package and CVE in two exceptions, a lifetime over two years), and an invalid
file stops the run rather than being half-applied. --no-exceptions reports
every grype finding; --exceptions FILE uses another file (e.g. for your own image).
python3 apply-exceptions.py --validate # check the file; warns 30 days before an expiry
python3 apply-exceptions.py scan.json # apply to a grype JSON in place (re-runnable)
python3 -m unittest discover tests # the matching rulesTo add one: review the CVEs against the binary as built (the source's build
rules and, ideally, the shipped binary), record the evidence, and set expires.
To renew one after a new source version: re-check the evidence against that
version and add it to sourceVersions (and drop versions no image uses any more).
Turn a grype JSON scan into a single self-contained, interactive HTML report you can open in any browser.
A report describes one state of a system: one row per distinct CVE found in
the SBOM it was given, risk-sorted. Filter by severity chip, KEV, fix status,
package type, or free text; click a row for the description, every affected
package with its installed version, the fix version, and (for embedded
packages) the file it was found in. To compare states, for example an image as
released against the same image after apt full-upgrade, render one report per
state and link them from an overview page, which is what scan-images.py does.
There is deliberately no switching between states inside a report.
The page is built to stay responsive on large scans (10k+ CVEs) and on slow machines: only the rows matching the current filters are in the DOM, 500 at a time with “show more” (appended a couple of hundred rows per frame), and a CVE's detail block is created when you expand it and thrown away when you collapse it.
No server, no JS dependencies, no internet at view time — everything is inlined into one HTML file.
The examples/ directory has two pre-built reports, also served live via GitHub Pages:
- Raspberry Pi OS desktop image (
2026-04-21-raspios-trixie-arm64, 3826 findings): https://raspberrypi.github.io/cve-list/examples/raspios-2026-04-21-desktop-arm64.html - Live system scan (running Pi with a dev workspace, 14 666 findings): https://raspberrypi.github.io/cve-list/examples/live-system-example.html
# Scan a directory (e.g. a mounted Pi OS image)
sudo grype dir:/mnt/raspios-root \
--exclude './proc/**' --exclude './sys/**' \
--exclude './dev/**' --exclude './run/**' \
-o json --file scan.json
# …or a container image
grype <image-ref> -o json --file scan.json
# …or the live host filesystem
sudo grype dir:/ \
--exclude './proc/**' --exclude './sys/**' \
--exclude './dev/**' --exclude './run/**' \
--exclude './mnt/**' --exclude './media/**' \
-o json --file scan.jsonpython3 make-report.py scan.json report.html --title "My scan"Optional flags:
--title "..."— page title (defaults toGrype report: <basename>).--source-label "..."— text shown under the title (defaults to the input path; override when sharing publicly to avoid leaking local paths).--sbom <sbom>— the SBOM the scan came from; adds the “Found in” file paths for embedded (non-deb) packages.--debian-ahead <json>— output ofdebian-ahead.py; adds the “Fix available from” column.--link LABEL=URL— navigation link shown under the title (repeatable), e.g.--link Overview=../index.html.
Grype doesn't read disk-image partition tables natively, but you can loop-mount the rootfs:
xz -dk raspios.img.xz # if compressed
LOOP=$(sudo losetup -fP --show raspios.img)
sudo mkdir -p /mnt/raspios-root
sudo mount -o ro "${LOOP}p2" /mnt/raspios-root
sudo grype dir:/mnt/raspios-root \
--exclude './proc/**' --exclude './sys/**' \
--exclude './dev/**' --exclude './run/**' \
-o json --file scan.json
sudo umount /mnt/raspios-root
sudo losetup -d "$LOOP"scan-images.py is the entry point used by CI. It picks the most recent
release of each of the four full Raspberry Pi OS images — full is a
superset of the lite and desktop variants, so scanning it covers them — across
two arches and two channels:
| Variant | Arch | Channel |
|---|---|---|
raspios_full_arm64 |
64-bit | current (trixie) |
raspios_full_armhf |
32-bit | current (trixie) |
raspios_oldstable_full_arm64 |
64-bit | oldstable (bookworm) |
raspios_oldstable_full_armhf |
32-bit | oldstable (bookworm) |
For each, it runs the full grype + cross-check pipeline on the image as
released and on a simulated apt full-upgrade, and renders one report per
state: <date>.html for the image as released and <date>-upgraded.html for
the post-upgrade state. The index page shows the same summary columns for both
states side by side, each linking to its report.
# Default — the four full images, into ./site
python3 scan-images.py
# Test on a single variant
python3 scan-images.py --variant raspios_full_arm64
# Just print what would be scanned, no work
python3 scan-images.py --dry-run
# Useful flags
# --variant X repeatable; override the default four-variant set
# --no-simulate skip the apt-upgrade simulation (baseline report only)
# --force ignore cache, re-download / re-scan everything
# --out DIR output directory (default: site/)
# --cache DIR cache directory (default: cache/)
# --no-exceptions apply no reviewed exceptions (report every grype finding)
# --history DIR append this run's numbers to DIR/<variant>.jsonl (CI passes `history`;
# leave it off for local experiments so the committed history stays clean)The generated site/ directory is the Pages root:
site/
├── index.html # 4-row overview: image as released | after apt full-upgrade, each with its report
├── summary.json # per-image rollups for this run (machine-readable)
├── history/ # copy of the committed history/<variant>.jsonl files
├── raspios_full_arm64/
│ ├── 2026-06-19.html # report: image as released
│ ├── 2026-06-19.json # raw grype output (as released)
│ ├── 2026-06-19-upgraded.html # report: after simulated apt full-upgrade
│ └── 2026-06-19-upgraded.json # raw grype output (simulated upgrade)
└── ...
The CI workflow runs weekly (Monday 06:00 UTC) and on manual dispatch, caches SBOM downloads (each release's SBOM is immutable), commits the week's numbers to history/, and deploys directly to GitHub Pages. The Pages deployment is replaced each week; history/ in the repo is the durable record and the input for trend graphs.
- Python 3 (standard library only — no third-party packages).
grypeto produce the input JSON.