Imported from jrusz/ec-cve-check (
skills/ec-cve-check/SKILL.md). Install upstream withnpx skills add jrusz/ec-cve-check --skill ec-cve-check. Copyright stays with the author.
EC CVE Check
Inspect Clair scan results from Konflux-built images and test ECP exception removability.
Quick start — get CVEs for an image
Two commands when logged into the Konflux cluster:
# 1. Set up auth (one-time per session)
bash skills/ec-cve-check/scripts/setup-auth.sh <tenant-namespace>
# 2. Scan
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh <image-ref>
The --key flag is optional when cluster access is available — the script reads the cosign public key via k8s://openshift-pipelines/public-key by default. Use --key only for offline scenarios (see Auth details below).
Filter the report by severity and source when you only need a subset:
# Comma-separated values are supported; filters can also be repeated.
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh \
<image-ref> --severity critical,high --source RHSA
For JSON output (useful for scripting or further analysis):
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh <image-ref> --json
# Filters apply before JSON is emitted, so jq receives only matching findings.
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh \
<image-ref> --severity high --source GHSA,PYSEC --json \
| jq -r '.vulnerabilities[] | [.severity, .source, .cve, .package] | @tsv'
Pass your Konflux tenant namespace. Find it from your application's namespace in the Konflux cluster.
Auth details
The setup-auth.sh script extracts the components-namespace-pull secret, which covers all image repos under quay.io/redhat-user-workloads/<tenant>/*. It works for any component in the tenant — no need to find per-app secrets.
When working without cluster access, export the cosign key for offline use:
bash skills/ec-cve-check/scripts/setup-auth.sh <tenant-namespace> --key
# Then use: --key /tmp/clair-auth/tekton-public.pem
Or use the image proxy (one login covers all tenants):
podman login image-rbac-proxy.apps.<your-konflux-cluster-domain>
Script interfaces
setup-auth.sh <tenant-namespace> [--key]
Extracts components-namespace-pull secret to /tmp/clair-auth/config.json. With --key, also exports the cosign public key to /tmp/clair-auth/tekton-public.pem. Exits non-zero if the secret is missing or cluster access fails.
inspect-clair-cves.sh <image-ref> [options]
Accepts any full image reference — tag (quay.io/repo:tag) or digest (quay.io/repo@sha256:...). Resolves tags to digests automatically. Fetches the cosign attestation, extracts the REPORTS OCI blob, and parses all CVE data.
- Default output: human-readable table sorted by severity, including the source category
--severity <list>: comma-separated or repeated filter usingcritical,high,medium,low, orunknown--source <list>: comma-separated or repeated filter usingGHSA,PYSEC, orRHSA; severity and source filters are ANDed, while multiple values within one filter are ORed--json: structured JSON withimage, filteredtotal_vulnerabilities,summary,source_summary,filters, andvulnerabilitiesarray- Exit 0: success (even if 0 CVEs found)
- Exit 1: auth failure, missing attestation, missing REPORTS blob, or missing prerequisites (
cosign,skopeo,python3) - Exit 2: invalid or empty filter value (validated before registry/prerequisite checks)
- Env:
DOCKER_CONFIG(directory withconfig.json),TEKTON_PUBLIC_KEY(alternative to--key)
test-exception-removal.sh <namespace> <policy-file> <snapshot-name> [exception] [service-account]
Submits an EC PipelineRun with a modified policy (exception removed). Waits up to 10 minutes.
namespace: tenant namespace (e.g.,my-tenant)policy-file: local path to ECP YAML from your local clone ofkonflux-release-data(e.g.,<your-local-clone>/config/<cluster>/product/EnterpriseContractPolicy/<your-ecp-policy>.yaml)snapshot-name: Konflux snapshot name (e.g.,my-app-20260101-000000-000)exception: exception rule to remove (default:cve.cve_blockers)service-account: SA for PipelineRuns (default:konflux-integration-runner)- Exit 0: exception can be removed. Exit 1: exception still needed (violations printed).
Example:
bash skills/ec-cve-check/scripts/test-exception-removal.sh \
<tenant-namespace> \
<your-local-clone>/config/<cluster>/product/EnterpriseContractPolicy/<your-ecp-policy>.yaml \
<snapshot-name>
Understanding the output
CVE source categories
The Source column and JSON source field normalize each finding so it can be filtered:
| Output source | How it is identified | What it means |
|---|---|---|
| RHSA | Red Hat VEX updater or an RHSA-* link |
Red Hat RPM or container-level CSAF/VEX assessment. The vulnerability ID is usually a CVE-* identifier. |
| GHSA | Vulnerability ID starts with GHSA- |
GitHub Security Advisory data from the OSV/PyPI feed. |
| PYSEC | Vulnerability ID starts with PYSEC- |
Python Security Advisory data from the OSV/PyPI feed. |
For example, --source RHSA selects the Red Hat findings, while --source GHSA,PYSEC selects Python ecosystem findings. The same CVE can appear in multiple package rows; use JSON and deduplicate by .cve when counting unique IDs.
Package names still help explain where a finding occurs:
- A product package such as
<product-prefix>/<image-name>is a container-level CSAF/VEX assessment. - An RPM package represents an operating-system package finding.
- A Python package such as
setuptoolsorrequestsrepresents an OSV/PyPI finding and may be a false positive when a downstream patch has a local version suffix.
PEP 440 false positives
Downstream-patched Python packages often carry local version identifiers (e.g., package==1.2.0+downstream.1). Clair strips the local suffix, so 1.2.0 matches upstream vulnerability ranges even when the backport contains the fix. Cross-reference Python CVEs against your downstream patching/requirements files to identify false positives.
Decompose CVEs by image layer
When a product image (e.g., RHAIIS vLLM) is built on top of a base image (e.g., base-image-cuda), you can diff the two scans to see which CVEs are inherited vs introduced. This helps prioritize fixes — base image CVEs need a base image rebuild, while product-layer CVEs need changes in the product build.
Workflow
- Find the base image reference from the product image's labels:
skopeo inspect 'docker://<product-image>' | python3 -c "
import json, sys; labels = json.load(sys.stdin).get('Labels', {})
# Adjust label names to match your product's conventions
for k in ['base_image_label', 'image_label', 'git.commit']:
print(f'{k}: {labels.get(k, "N/A")}')
"
Key labels:
- The stage-released base image label — e.g.,
quay.io/<your-org>/base-images/<image>:<version> - The root base image label — e.g.,
registry.redhat.io/rhel9/rhel:9.x git.commit— source commit SHA
- Find the Konflux source image for the base image via its git commit and Snapshot:
# Get the git commit from the base image labels
skopeo inspect 'docker://<base-image-from-label>' | python3 -c "
import json, sys; print(json.load(sys.stdin).get('Labels', {}).get('git.commit', 'N/A'))"
# Find the Snapshot by commit SHA
oc get snapshots -n <tenant> \
-l pac.test.appstudio.openshift.io/sha=<commit-sha> \
-o custom-columns='NAME:.metadata.name,COMPONENT:.metadata.labels.appstudio\.openshift\.io/component,CREATED:.metadata.creationTimestamp'
# Extract the user-workloads image from the Snapshot
oc get snapshot <snapshot-name> -n <tenant> \
-o jsonpath='{range .spec.components[*]}{.name}{"\t"}{.containerImage}{"\n"}{end}'
- Scan both images and diff with
--json:
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh <base-image> --json > /tmp/base-cves.json
DOCKER_CONFIG=/tmp/clair-auth bash skills/ec-cve-check/scripts/inspect-clair-cves.sh <product-image> --json > /tmp/product-cves.json
- Compare unique CVE IDs between the two JSON outputs. Group by:
- Shared — inherited from the base image
- Product-only — introduced by the product layer (new RPMs, Python packages, or VEX assessments)
- Base-only — present in base but missing in product (see caveat below)
Caveats
- Base-only CVEs: A CVE present in the base scan but absent from the product scan does not necessarily mean a regression. The product build may have updated the affected RPM package to a newer version, resolving the CVE. Verify by checking SBOMs or comparing package versions between the two images.
- VEX feed differences: The base image and product image have separate VEX feed entries. Product Security may assess the same underlying CVE differently per product — a CVE could be marked as affecting the base image product but not the product built on top of it, even if the package is still present. This is an intentional product-level assessment, not an error.
- Deduplication: Remember that the same CVE appears multiple times across sub-packages (e.g., ffmpeg has 9 sub-packages) and across data sources (RPM scan + VEX feed). Always deduplicate by CVE ID when counting.
- Tracker types: Clair reports vulnerabilities under multiple tracker ID formats —
CVE-*(from RPM/VEX feeds),GHSA-*(GitHub Security Advisories), andPYSEC-*(Python Security DB via OSV). GHSA and PYSEC entries come from theosv/pypiupdater and only appear when the image contains Python packages. A single vulnerability may have both a GHSA and PYSEC ID.
Test ECP exception removal
Two approaches: local EC CLI (preferred) or cluster PipelineRun (slower, subject to Kueue quota).
Local EC CLI (preferred)
Run EC validation locally with the exception removed from the policy. ~30s per component, no cluster queue wait.
Prerequisites:
- EC CLI binary: download from Conforma CLI releases (e.g.,
/tmp/ec) - Auth set up:
bash skills/ec-cve-check/scripts/setup-auth.sh <tenant-namespace> - A local clone of
konflux-release-data
Steps:
- Build a modified policy JSON (remove the exception, add
builtin.image.accessibleto excludes to avoid registry auth noise):
ECP="<your-local-clone>/config/<cluster>/product/EnterpriseContractPolicy/<your-ecp-policy>.yaml"
python3 -c "
import sys, yaml, json
d = yaml.safe_load(open('$ECP'))
spec = d['spec']
for source in spec.get('sources', []):
cfg = source.setdefault('config', {})
excl = cfg.get('exclude', [])
excl = [e for e in excl if e != 'cve.cve_blockers'] # remove the exception
cfg['exclude'] = excl
if 'builtin.image.accessible' not in excl:
excl.append('builtin.image.accessible')
with open('/tmp/ec-policy.json', 'w') as f:
json.dump(spec, f)
print('Policy written to /tmp/ec-policy.json')
"
- Reduce snapshot to a single component (production releases validate per-component):
oc get snapshot <snapshot-name> -n <namespace> -o json | python3 -c "
import json, sys
snap = json.load(sys.stdin)
spec = snap['spec']
comp = [c for c in spec['components'] if c['name'] == '<component-name>'][0]
json.dump({'application': spec['application'], 'artifacts': {}, 'components': [comp]}, open('/tmp/ec-snap.json', 'w'))
"
- Run EC validate:
DOCKER_CONFIG=/tmp/clair-auth /tmp/ec validate image \
--images /tmp/ec-snap.json \
--policy /tmp/ec-policy.json \
--ignore-rekor \
--strict=false \
--output text \
--workers 5
Result interpretation:
Result: SUCCESSorResult: WARNING→ exception can be removedResult: FAILUREwith[Violation] cve.cve_blockers→ exception still needed; checkTerm:field for CVE IDs- Failures on other rules (e.g.,
required_tasks,cve_results_found) → unrelated to CVE exception
Important: Only push pipeline snapshots (on-push) have CVE scan data. Pull/MR snapshots (on-pull-request) skip scanning — cve.cve_blockers silently passes with no data. Filter snapshots:
oc get snapshots -n <namespace> \
-l "pac.test.appstudio.openshift.io/event-type=push,appstudio.openshift.io/application=<app>" \
--sort-by='.metadata.creationTimestamp' -o custom-columns='NAME:.metadata.name,CREATED:.metadata.creationTimestamp' \
| tail -5
You can verify by checking the snapshot label: appstudio.openshift.io/build-pipelinerun should contain on-push, not on-pull-request.
Cluster PipelineRun (alternative)
Submits an EC PipelineRun on the cluster. Slower (~60s+ plus Kueue queue wait) and subject to konflux-ci-dev-token quota limits (~2-3 concurrent PipelineRuns per tenant).
Read references/ecp-exception-testing.md before running — it covers interpreting results and troubleshooting PipelineRun issues.
Background
The clair-scan Tekton task produces two outputs from a single scan: SCAN_OUTPUT (RHSA-filtered counts shown in the build UI, often 0 for Python) and REPORTS (full Clair report as OCI blob — what EC's cve.cve_blockers evaluates). This gap is why builds appear clean while EC catches real CVEs at release time. inspect-clair-cves.sh reads the REPORTS data.
For source repo links (clair-scan task, conftest policy, EC rule, clair-action) and the full two-path data flow, read references/cve-scanning-architecture.md.
Troubleshooting
- cosign returns empty output: verify auth covers the image registry. Run
skopeo inspect docker://<image-ref>to test access. - SCAN_OUTPUT shows 0 but EC fails: expected — the RHSA conftest filter hides Python/Go ecosystem CVEs from the build UI. The script reads the full REPORTS data (same as EC).
- REPORTS blob missing: the script falls back to the source repo (
quay.io/redhat-user-workloads/...) automatically. If both fail, verifycomponents-namespace-pullauth. - "UNAUTHORIZED" errors: check
DOCKER_CONFIGpoints to a directory containingconfig.json, not a file directly.