# AKS Container Image Security Advisories > Community, open-source security-advisory site for the container/OCI images > Azure Kubernetes Service (AKS) runs on your cluster -- control plane, addons, > CSI drivers, service mesh and Arc agents -- keyed on your Kubernetes version. > It tracks per-CVE status sourced from public AKS CVE data. It is NOT an > official Microsoft/Azure product and NOT an authoritative source of truth for > AKS security posture. Base URL: https://chrischangcode.github.io/aks-security-advisory/images/ ## Important for agents This human-facing page under images/ is a hash-routed single-page app (SPA). The content of a route such as `images/#/1.33/CVE-2025-1234` is rendered client-side by JavaScript; fetching that URL WITHOUT a JS runtime returns only an empty HTML shell. Do NOT scrape the rendered HTML. Read the JSON feed directly (below) -- it is the machine-readable source and needs no JavaScript. EVERY endpoint named below is OPTIONAL / 404-tolerant. The feed under `data/` is built by a SEPARATE job from the one that deploys this page (see .github/workflows/pages.yml): a site-only deploy re-serves the newest EXISTING feed and does NOT rebuild it, and on `main` today the images feed is not yet published at all. So on a first deploy the WHOLE `data/` tree can 404. Treat any 404 as "this feed build does not publish it yet", never as an error and never as "clean / not affected". For exact byte sizes and endpoint inventory read `data/manifest.json` when it is present; it is generated next to the feed it describes, so the two can never disagree. The site root (one level up, `../`) is static HTML and is safe to fetch: it is a hub linking to each advisory set (the VHD node-image advisory and this container-image advisory). ## Two axes -- never blend them (the whole point of this feed) This product reports two DIFFERENT kinds of evidence and never merges them into one number or one table. Always keep track of which axis a row came from; a record's `basis` field names it: - Kubernetes snapshot axis (`basis: k8s_snapshot`) -- a FROZEN, point-in-time scan of the images a given Kubernetes version shipped. Answers "what did the scan of version X see?". Counts are only comparable WITHIN one scan cohort (`report_time`); see `comparable_with`. - AKS release-history axis (`basis: release_history`) -- derived from the AKS release chain (mitigation history + computed diffs), continuously rescanned. The ONLY axis that answers "when was this first fixed?". The two axes can legitimately DISAGREE (a frozen snapshot is older than the release train). That disagreement is signal, not a bug -- present both, labelled, never a blended total. ## JSON feed (read this, not the HTML) The feed is deployed at `images/data/` -- i.e. `data/` relative to this file (a SIBLING of the VHD `data/` tree, one directory over, NOT the same tree). All files are plain JSON. Endpoint shapes: - data/_index.json (OPTIONAL -- treat a 404 as "this feed build is not published yet"; on first deploy the whole data/ tree can be absent) Entry point. Scanned Kubernetes versions and AKS releases, plus the cohort map and cross-version chain issues: { schema_version, type, generated, source, counts{ k8s_versions, aks_releases, cohorts }, cohorts{ report_time -> [version,...] }, chain_issues[], k8s_versions[{ version, minor, report_time, scan_age_days, cohort, image_count, cve_count, comparable_with[], source_url }], aks_releases[{ version, previous_release, report_time, image_count, unmapped_image_count, source_url }] } A version's absence from k8s_versions[] means it was NOT scanned ("not assessed"), never "clean". comparable_with lists the OTHER versions in the same scan cohort; comparing counts across cohorts is not valid. - data/k8s/.json (OPTIONAL -- treat a 404 as "this version has no published snapshot in this feed build", never as "not affected") One Kubernetes version's frozen snapshot. images[] is PRE-SORTED by CVE count descending (issue #40 F7), so the worst images are first: { schema_version, type, basis:"k8s_snapshot", version, minor, report_time, scan_age_days, cohort, comparable_with[], source_url, images[{ image, repo, tag, cve_count, cves[] }] } An image listed here whose cves[] omits a CVE is "not affected AS OF this scan" (not observed), NOT proven unaffected. An image ABSENT from images[] is "not present on this version". - data/release/.json (OPTIONAL -- treat a 404 as "this release has no published document in this feed build") One AKS release on the release-history axis. This is the HOME of images that cannot be mapped to any Kubernetes version (k8s_attribution.state k8s_unmapped). IMPORTANT: images[] has ONE ENTRY PER SHIPPED IMAGE TAG. A release ships several tags of the same image (usually one per supported Kubernetes minor) on a single scanned container, so image_count (entries) is >= target_count (scanned containers). Each entry is independently true: its tag, its attribution, its CVE list. Do NOT sum active_cve_count across entries of one (pod_namespace, container_name) -- their CVE sets overlap. { schema_version, type, basis:"release_history", version, previous_release, report_time, source_url, attribution_counts{}, unmapped_image_count, image_count, target_count, images[{ pod_namespace, container_name, image, repo, tag, tags[], active_cve_count, cves[], cve_source, split_exact, unattributed_cves?[], unattributed_cve_count?, k8s_attribution{ mode, state, unmapped_reason? } }], unattributed_cves?[{ pod_namespace, container_name, images[], cves[] }], first_fixes[{ cve, pod_namespace, container_name, release, previous_release, first_fixed_source, in_computed, in_upstream, basis, report_time }] } cve_source tells you HOW that entry's CVE list was derived: "per_tag" -- the count is about that tag alone. For a container shipping one reference the aggregate already IS that reference's set; for several, it was intersected with each tag's own scan report. (This is the normal case.) "target_union" -- a per-tag report was unavailable for at least one tag on the container, so the union across ALL its tags is reported for EVERY tag. Treat the count as an UPPER BOUND for that tag, not as a fact about it. split_exact is false when the split is not exact, for EITHER reason: the report was unavailable (cve_source "target_union"), or some CVE matched no tag's report (then unattributed_cves is present). False alone does not imply unattributed_cves exists -- read cve_source too. unattributed_cves are CVEs the container carries by the release report's own account that could not be pinned to one tag. They are listed on EVERY tag of that container, and they DO appear in cve/ and image/ documents labelled cve_source "target_unattributed". They are an upper bound for any single tag, but they are never withheld -- withholding them would make the container look unaffected. NOTE on first_fixes: these are per scanned container (pod_namespace, container_name), NOT per tag. That pair is not unique -- two unrelated images can share it -- so do not join first_fixes to images[] on it and assume one match; a join can fan out across tags and across repositories. - data/cve/.json (OPTIONAL -- treat a 404 as "this CVE is not observed in this feed build", never as "not affected") One CVE across BOTH axes, kept separate: { id, affected_minors[], release_only, k8s[{ k8s_version, minor, image, repo, tag, report_time, scan_age_days, basis:"k8s_snapshot", source_url }], releases[{ release, pod_namespace, container_name, image, repo, tag, k8s_attribution{ mode, state }, cve_source, basis:"release_history", source_url }] } Each release occurrence names the exact image TAG that carries the CVE, not just the repository. Read cve_source before treating it as that tag's own finding: "per_tag" is a fact about that tag; "target_union" and "target_unattributed" are upper bounds, meaning the CVE is on that scanned container but could not be pinned to this specific tag. - data/cve-index.json (OPTIONAL -- may be absent on older feeds; treat a 404 as "this feed build does not publish the slim triage index") Slim triage index. The Kubernetes minors are interned into a top-level minors[] table addressed by integer index in each entry's m[]: { schema_version, type, generated, minors[], cves[{ id, release_only, m:[int,...] }] } Use this to enumerate/triage cheaply, then fetch the per-CVE document. - data/image/index.json (OPTIONAL -- the per-image document family is added by a separate change and may be ABSENT entirely; treat a 404 as "per-image documents are not present in this feed build", then stop -- do not guess a filename) Authoritative repo -> filename map for the per-image documents. Image repo names contain "/", so filenames are sanitized and you MUST resolve through this index, never construct a name. The `name` key is the HOST-QUALIFIED repo exactly as it appears in k8s/ and release/ documents (e.g. "mcr.microsoft.com/oss/kubernetes/kube-proxy") -- use any such repo verbatim, do NOT strip the registry host or normalize it: { schema_version, type, count, items[{ name:"", file:".json" }] } - data/image/.json (OPTIONAL -- resolve ONLY via data/image/index.json; treat a 404 as "this image has no per-image document in this feed build") One image repository, both axes side by side (never summed): { schema_version, type:"aks-images-image", generated, repo, tags[], k8s_version_count, release_count, cve_count, cves[], tags_by_release[{ release, tags[] }], k8s[{ k8s_version, minor, image, tag, cve_count, cves[], report_time, scan_age_days, source_url }], releases[{ release, image, tag, pod_namespace, container_name, active_cve_count, cves[], cve_source, split_exact, unattributed_cves?[], k8s_attribution{ mode, state }, report_time, source_url }] } tags_by_release answers "which tag of this image ships in AKS release X" -- the flat tags[] is a union across all releases and CANNOT answer it. As in release/ documents, releases[] has one entry per shipped tag, so a release may appear more than once here; read cve_source before trusting a count. releases[].cves is that tag's own CVE ids, so "what is on REPO:TAG" is answerable from this document alone. The top-level cves[] is the union over every tag AND every release of this repository -- never report it as one tag's exposure. - data/upgrade/index.json (OPTIONAL -- the upgrade-delta family is added by a later feed build than the one that introduced release/; treat a 404 as "this build does not publish upgrade deltas", NEVER as "upgrading changes nothing") Which AKS releases can be asked "what does upgrading fix?", and whether the answer is fully trustworthy for each: { schema_version, type, generated, basis:"release_history", to_release, count, items[{ name, file }], upgrades[{ from_release, to_release, comparability, release_gap, summary{ fixed, partially_fixed, still_present, new_in_latest? }, file }] } items[] is the authoritative name -> file map; resolve a release through it rather than constructing upgrade/.json. Only the last few releases before the newest get a document -- the newest IS the upgrade target, so it never has one. An ABSENT summary.new_in_latest means "cannot be determined for this pair", NOT zero. See comparability below. - data/upgrade/.json (OPTIONAL -- resolve through data/upgrade/index.json; treat a 404 as "no delta published for that release") What upgrading from that release to the newest one actually does. This is the ONLY endpoint that answers a customer more than one release behind: release/.json carries first_fixes[], but that is strictly single-hop (release N vs N-1) and cannot serve someone three releases back. { schema_version, type, generated, basis:"release_history", from_release, to_release, from_report_time, to_report_time, from_scan_age_days, to_scan_age_days, comparability, comparability_note, releases_crossed[], release_gap, summary{ fixed, partially_fixed, still_present, new_in_latest? }, targets{ compared, only_in_from[], only_in_to[] }, fixed[{ cve, first_fixed_release, first_fixed_source, no_longer_reported?, targets[{ pod_namespace, container_name, first_fixed_release, first_fixed_source, from_images[], from_images_exact?, to_images[] }] }], partially_fixed[{ cve, first_fixed_release, first_fixed_source, no_longer_reported?, fixed_on[...], still_on[...] }], still_present[{ cve, targets[...] }], new_in_latest?[{ cve, targets[...] }], source_url, from_source_url } READ THIS BEFORE USING THE NUMBERS: fixed, partially_fixed and still_present are MUTUALLY EXCLUSIVE at the CVE level -- an id appears in exactly ONE of them, so you may sum them and you must not look up an id in only one list. fixed[] -- cleared from EVERY container that carried it. Upgrading resolves it. This is the only bucket you may report to a customer as "fixed by upgrading". partially_fixed[] -- cleared on SOME containers, still active on others. Upgrading REDUCES exposure but does NOT clear the CVE. fixed_on[] is where it goes away, still_on[] is where it remains. Reporting these as fixed is the single easiest way to overstate an upgrade. still_present[] -- active on both sides, cleared nowhere. Bucketing per (container, CVE) instead of per CVE would put the same id in both fixed and still_present: measured on one live pair, 150 of the 171 ids that are fixed somewhere are still active elsewhere, leaving only 21 truly cleared. If you are reconciling counts against a naive per-container diff, that is the difference. comparability is "same_scan" or "cross_scan" and it decides what you may assert. AKS release reports are scanned ONCE and never re-scanned, so two releases scanned on different dates were measured against DIFFERENT vulnerability databases. "same_scan" -- both reports came from one scan run. Appearance AND disappearance of a CVE are both meaningful. new_in_latest is published. "cross_scan" -- different scan runs. new_in_latest is OMITTED ENTIRELY (the key is ABSENT, which means "cannot be determined" -- do not read absence as an empty list or as "nothing new"). Every entry in fixed[] and partially_fixed[] additionally carries no_longer_reported: true, because across scan dates a CVE can also vanish by being withdrawn or rescored rather than fixed. Report it as "no longer reported", not "fixed". Measured on live data: same-scan pairs differ by 3 and 15 ids while cross-scan pairs differ by 288 and 380. That gap is the scanner database advancing, NOT AKS shipping regressions. Presenting it as regressions is the single biggest way to misuse this endpoint. first_fixed_release is the release the customer must actually REACH. When a CVE affects several containers it is the NEWEST of the per-container first fixes (each container's own value is in targets[].first_fixed_release); reporting the earliest would tell someone they are done while another container still carries it. It is also the release the CVE stays gone FROM, not merely the first release that happens to lack it -- a CVE can disappear and come back, and naming the first lapse would point at a release where it is active again later (365 such ids in one live document). first_fixed_source uses the same computed/upstream/disputed vocabulary as release/ first_fixes, and the CVE-level value is the LEAST confident across the listed containers. still_present is first-class and is normally MUCH larger than fixed (621 vs 21 on one live pair, with a further 150 only partly fixed). An answer that reports only what an upgrade fixes is misleading -- say what it does not fix too. new_in_latest is cluster-wide: an id already active on ANOTHER container before the upgrade is not something the upgrade introduces, so it is excluded. targets.only_in_from / only_in_to are containers present in just one of the two releases. They are EXCLUDED from every count: a container that came or went is not a CVE that was fixed or introduced. releases_crossed[] lists every release you move THROUGH, ending at to_release, which is therefore included; release_gap is its length. It is not an exclusive "between" list. from_images[] is which of the container's image references carried the CVE on the release the customer is running -- that is the reference to look for locally. When from_images_exact is false the CVE could not be pinned to one tag and every reference is listed as an UPPER BOUND. - data/glossary.json (OPTIONAL -- treat a 404 as "this feed build does not publish a glossary", then use the baked vocabulary below) The controlled vocabulary with per-value counts MEASURED from this build: { schema_version, type, generated, attribution{ labels{}, help{}, unmapped_reason_help{}, counts{}, shares{}, total }, first_fixed_source{ labels{}, help{}, counts{}, shares{}, total }, presentation_state{ labels{}, help{}, derivation_note }, cve_source{ labels{}, help{}, split_exact_help, counts{}, shares{}, total }, comparability{ labels{}, help{}, field_help{}, counts{}, shares{}, total }, basis{ labels{}, help{} } } Render state names from THIS document, not a hard-coded copy; the UI's baked fallback is pinned equal to it by a test and is only used when it 404s. - data/manifest.json (OPTIONAL -- may be absent on older feeds; treat a 404 as "this feed build does not publish an inventory") Generated inventory of every endpoint this feed publishes, with real byte sizes -- read this FIRST to plan fetches: { schema_version, type, generated, data_root, count, endpoints[] } Collection entries (k8s/, release/, cve/, image/) whose filenames are sanitized carry an `index` pointer; resolve a name through that index, never build the filename. ## Controlled vocabulary Full meanings + per-value counts measured from the current build are in `data/glossary.json` (OPTIONAL -- treat a 404 as "this feed build does not publish a glossary"). The stable names: Presentation state (per Kubernetes version x image x CVE, DERIVED by the consumer from the feed's explicit coverage): - active -- the scan reports this CVE on this image on this version (a stored fact). - not_affected -- the image runs on this version and its scan does NOT report the CVE (not observed in this scan; NOT proven unaffected). - not_present -- this image does not run on this version. - not_assessed -- this version has no scan report (it is absent from _index.json). This is an explicit signal, NEVER a missing key read as clean. - unknown_not_tracked -- the image is not in the AKS release train, so its fix history is unavailable. Not the same as "not fixed". Attribution state (how an AKS-release image maps onto the Kubernetes axis): - k8s_exact -- same repo:tag or same digest (strongest claim). - k8s_repo_tagskew -- repo matches, tag differs (release train moved ahead). - k8s_registry_variant -- repo matches only after registry-path normalization. - k8s_unmapped -- absent from every Kubernetes snapshot; listed under its AKS release only. The only claim is absent_from_all_k8s_reports. First-fixed source (provenance of a first-fix record on the release axis): - computed -- computed diff and the upstream mitigated list agree (confident). - upstream -- only the upstream list could answer (e.g. the oldest release). - disputed -- computed diff and upstream disagree; both signals are recorded. Basis (which axis a record came from; the three are never blended): k8s_snapshot, release_history, image_current. ## Hard rules for agents (do not violate) - Two axes are never blended. A k8s_snapshot count and a release_history count are different evidence; never add or compare them as one number. Keep the `basis` with every row. - Absence is "not observed", never "not affected". A CVE missing from a document means it was not seen in THAT scan, not that the image is proven clean. A Kubernetes version missing from _index.json is "not assessed", not clean. - Scan staleness is real. Every k8s version carries report_time / scan_age_days / cohort. Versions in different cohorts (different report_time) are NOT comparable; honour comparable_with before comparing counts. - No invented severity. The upstream API publishes no per-CVE severity, so this feed emits none. Report "severity not published by this source"; never guess. - Render the vocabulary from data/glossary.json (OPTIONAL -- fall back to the baked names above on a 404), never from a divergent private copy. - Unknown enum values from a newer feed are surfaced verbatim, not dropped or rendered blank. ## No crawl -- suggested fetch order You never need to walk every document. To answer "what CVEs are on Kubernetes version X, worst images first?": 1. GET data/_index.json (OPTIONAL -- treat a 404 as "feed not published yet"). Confirm X is in k8s_versions[] (absent => not assessed) and read its report_time / scan_age_days / cohort / comparable_with for provenance. 2. GET data/k8s/.json (OPTIONAL -- treat a 404 as "no snapshot for X"). Its images[] is already sorted by cve_count descending, so the worst images are first; each image's cves[] is the active set for that scan. 3. For one CVE across both axes, GET data/cve/.json (OPTIONAL). Read k8s[] and releases[] SEPARATELY; do not merge them. 4. For "when did AKS first fix this?", use data/release/.json (OPTIONAL) first_fixes[] on the release-history axis -- the k8s snapshot axis cannot answer it. 5. For a single image's whole story, resolve its repo through data/image/index.json (OPTIONAL -- if it 404s, per-image documents are not in this build; stop, do not guess a filename), then GET the mapped data/image/.json (OPTIONAL). 6. For "I am running image REPO:TAG -- what is on it?": resolve REPO through data/image/index.json, GET the per-image document, and MATCH ON tag. Use tags_by_release to see which AKS release ships that tag. Do NOT report the repo's whole cves[] (a union across every tag and release) as that tag's exposure, and do NOT assume the first tag listed for a release is yours -- a release commonly ships several tags of the same image. ## Worked example -- a spreadsheet column of CVE ids against one version You have a column of CVE ids exported from a scanner and a Kubernetes version X, and want per-image status for each: 1. GET data/k8s/.json (OPTIONAL). Build, from images[], the set of CVEs active on each image (image.cves[]). 2. For each pasted CVE id: if it appears in an image's cves[], that image is `active` for it; if the image is present but the CVE is not in its cves[], it is `not_affected` (not observed in this scan, NOT proven clean); if X is not in _index.json at all, every answer is `not_assessed`. 3. Report the state per (image, CVE) with the version's report_time / cohort attached, so a reader knows how fresh -- and how comparable -- the evidence is. The human UI does exactly this (its bulk-paste box) and exports the result as TSV/CSV for the same spreadsheet.