Skip to content

Commit 9f486c5

Browse files
author
Tobias Pfaffelmoser
authored
fix(cosign-scan/oras-scan): Referrers-based classification, Trivy cache reuse, report cleanups (#114)
* fix(oras-scan): per-worker Trivy cache pre-populated from the shared DB Trivy's default `fs` cache backend uses BoltDB and holds an exclusive lock on `<cache-dir>/fanal/cache.db` for the duration of every scan, not just during DB updates. The previous two configurations both had real downsides: * Per-image `--cache-dir` (no DB seeding) -> every parallel worker redownloaded the entire vuln DB. On 30+ images this was the dominant wall-clock cost (~7 min wasted per run). * Shared default cache + `--skip-db-update` -> workers raced for the BoltDB lock and most timed out with `unable to initialize fs cache: cache may be in use by another process`. This change combines the upsides of both: * `prepare_trivy_db()` still downloads the vuln DB once into Trivy's default cache and now records the location in `_TRIVY_DB_SRC`. * `_get_worker_cache_dir()` is a threading.local helper that, on first use by each worker thread, mints a fresh tempdir and copies the immutable vuln-DB files (`db/`, optional `java-db/`) from `_TRIVY_DB_SRC`. The pool is bounded by `args.parallel`, so peak extra disk usage is N x ~30-50 MB - trivial on a CI runner. * `run_trivy_scan()` passes `--cache-dir <worker-dir> --skip-db-update`, so Trivy reuses the seeded DB without redownloading and without fighting other workers for a shared lock. * `atexit` cleans up every per-worker dir on process exit. Net effect: the ORAS scan keeps its pre-fix wall-clock improvement (~10 min -> ~2-3 min) while producing correct results for every image instead of failing 35 of 39 scans. * perf(cosign-scan): skip cosign verify for images with no referrers Two changes to collapse the long tail of skipped images on ``cosign-scan``: 1. ``ImageScanner.detect_attestation_type`` now does the cheap ``oras discover`` (single Referrers API GET, capped at 15 s) BEFORE invoking ``cosign verify``. Aleph-Alpha images attach all signature, SBOM and triage bundles via OCI 1.1 referrers; if the referrers list is empty we can mark the image UNSIGNED immediately and avoid a minutes-long ``cosign verify`` round-trip that was guaranteed to fail anyway. ``cosign verify`` only runs once we know there is a sigstore bundle to validate. 2. ``CosignVerifier`` gains a ``verify_timeout`` ceiling, defaulted to ``timeout`` (no behavior change for standalone callers) but set to 60 s by ``ImageScanner``. Previously verify inherited the full scan timeout (default 600 s = 10 min), so a hung Harbor request could block one worker for the entire window. 60 s is plenty for a keyless verify against a healthy registry while preventing the tail-end stall. Measured on the helm-chart pipeline: tail-end scans of skipped images (``pharia-os-app:1.30.4``, ``pharia-os-manager:0.15.7`` and friends) were each consuming ~2-3 minutes of worker time despite never running trivy. * fix(cosign-scan): only fast-path UNSIGNED on definitive discover result Previous commit unintentionally marked images UNSIGNED when ``oras discover`` returned an empty list for *any* reason -- including a transient timeout or network glitch -- because the helper conflated "discovery failed" with "no referrers exist". A run on the helm-chart pipeline misclassified ``feature-flags:0.11.11`` (previously a successful ✓ scan) as unsigned. Make the discovery API tristate: * ``None`` -> discovery failed (timeout / parse error / unsupported registry); caller must use the slow path. * ``[]`` -> discovery succeeded, image has no referrers attached; caller can short-circuit. * ``[..]`` -> discovery succeeded, referrers found. ``ImageScanner.detect_attestation_type`` now only short-circuits to UNSIGNED on the definitive empty case and falls back to ``cosign verify`` on any failure, so a flaky Referrers API call no longer corrupts the report. The legacy private ``_discover_referrers`` keeps its old ``List[dict]`` contract (treats ``None`` as ``[]``) so existing call-sites that don't need the distinction are unaffected. * fix(cosign-verify): pass --new-bundle-format so OCI 1.1 sigs are found ``CosignVerifier.verify()`` is the gate used by ``ImageScanner`` to decide if an image is signed at all. Without ``--new-bundle-format`` (available in cosign >= 2.5 and the default for future versions) cosign only probes the legacy ``<image>:sha256-<digest>.sig`` tag. When an image is signed purely via OCI 1.1 sigstore-bundle referrers (``application/vnd.dev.sigstore.bundle.v0.3+json``) the probe 404s and cosign returns "no signatures found", so scanner-py labels the image ``UNSIGNED`` and skips the scan. Verified against Harbor: the four skipped pharia-os images (``pharia-os-app:1.30.4``, ``pharia-os-manager:0.15.7``, ``phariaos-applications-proxy:0.8.9``, ``feature-flags:0.11.11``) each have a valid ``https://sigstore.dev/cosign/sign/v1`` bundle in their referrers, signed by the expected ``Aleph-Alpha/shared-workflows/build-and-push.yaml`` identity, plus SBOM/triage/SLSA/vuln attestations. They are false-negatives caused solely by the missing discovery flag. The sister ``AttestationExtractor._verify_attestation`` helper has always passed ``--new-bundle-format``; this change aligns signature verification with attestation verification so both halves of ``ImageScanner.detect_attestation_type`` look at the same storage backend. * fix(cosign-verify): bump verify_timeout to 180s for heavily-attested images With --new-bundle-format (support commit 53b98da) cosign enumerates every OCI 1.1 referrer on the image to find a signature bundle. Harbor's Referrers API is O(N) in referrer count: refs=36 -> ~8 s (feature-flags: verified) refs=66 -> ~13 s (phariaos-applications-proxy) refs=237 -> ~48 s (pharia-os-manager) refs=403 -> ~95 s (pharia-os-app) The previous 60 s cap was tight enough that all three pharia-os images with >60 SBOM/triage/vuln/SLSA referrers hit "context deadline exceeded", which ``detect_attestation_type`` mapped to ``UNSIGNED`` even though they are perfectly signed. Bumping the verify timeout to 180 s gives cosign enough headroom to page through the Referrers API plus a couple of HTTP round trips to fetch the matching sigstore bundle and complete the Fulcio / Rekor checks. This does not re-introduce the original long-tail problem: the ``oras discover`` pre-check in ``ImageScanner.detect_attestation_type`` still short-circuits truly unsigned images in ~1 RTT without ever entering the ``cosign verify`` slow path. * fix(cosign-scan): drop redundant cosign verify, use referrers as source of truth ``ImageScanner.detect_attestation_type`` was making three round-trips through Harbor's OCI 1.1 Referrers API per image: 1. ``discover_referrers`` (oras discover, 15 s cap) 2. ``verifier.verify`` (cosign re-enumerates referrers) 3. ``list_attestations`` (oras discover + N×2 manifest fetches) Harbor's referrers endpoint is O(N) in referrer count -- empirically ~8 s for 36 refs and ~95 s for 403 refs. The 15 s cap on step 1 plus the 60 / 180 s budget on step 2 meant every heavily-attested image (pharia-os-app at 403 referrers, pharia-os-manager at 237, ...) hit "context deadline exceeded" somewhere in this chain and was reported ``UNSIGNED`` even though the image carried a perfectly valid cosign signature. Restructure detect_attestation_type around a single trip through ``list_attestations``: * The signature half of a cosign-signed image is itself a sigstore bundle with predicateType ``https://sigstore.dev/cosign/sign/v1``. Its presence in the OCI 1.1 referrer list is sufficient evidence of signing -- the cert chain is still validated downstream when ``extract_sbom`` / ``extract_triage`` actually pull the bundle. * Add ``AttestationList.has_signature()`` for that check and ``AttestationList.COSIGN_SIGNATURE_PREDICATE`` for the constant. * Drop the standalone ``cosign verify`` call in this method (the verifier is still wired up for callers that need a verified signature, e.g. signing pipelines). * Switch ``list_attestations`` to the tristate ``discover_referrers`` so a transient registry failure can no longer be silently misclassified as "no SBOM". * Bump the oras-discover ``fast_timeout`` from 15 s to 120 s so a Harbor referrers call against a 400-referrer image actually completes; this keeps unsigned images fast (those still finish in 1 RTT once the registry returns an empty list) while letting signed-but-attested images reach the predicate decoding step. Net effect: pharia-os-app / pharia-os-manager / phariaos-applications-proxy / feature-flags / data-eval-load all classify correctly again, without per-image cosign verify latency. The remaining 🚫 in scan reports are the genuinely unsigned inference-worker images that have only a Harbor-generated SBOM and no sigstore bundles in their referrers. * perf(cosign-scan): read predicate type from referrer annotation ``oras discover --format json`` already returns each referrer's manifest annotations, which for sigstore bundles includes ``dev.sigstore.bundle.predicateType`` (set by cosign at sign time). Reading the annotation lets ``list_attestations`` derive every predicate type from the single ``oras discover`` call we already make, instead of issuing two extra HTTP calls (manifest fetch + blob fetch + DSSE decode) per bundle. For an image with 400 referrers that drops the per-image cost from ~800 sequential HTTP round-trips (5-worker pool, several minutes wall-clock) to zero. The previous full-bundle decode path is kept as a fallback for the rare bundle that lacks the annotation (older or hand-crafted artifacts) so behaviour for non-cosign-emitted bundles is unchanged. This restores cosign-scan latency to ~7-9 minutes for the pharia-ai image set even after the holistic ``detect_attestation_type`` rewrite that now exclusively relies on ``list_attestations`` for signed/SBOM/triage classification. * fix(cosign-scan): revert annotation shortcut, raise predicate decode parallelism Harbor / cosign sets the same ``dev.sigstore.bundle.predicateType = https://sigstore.dev/cosign/sign/v1`` annotation on *every* sigstore bundle manifest, regardless of whether the bundle wraps an image signature, an SBOM, a triage statement, etc. The previous commit (7101d3a) read this annotation and -- correctly per its contract but unhelpfully in practice -- classified all bundles as cosign signatures, making every signed image fall into ``COSIGN_NO_SBOM``. The real in-toto predicate type lives inside each bundle's DSSE payload, which means we have to pull manifest + blob per bundle to decode it. That's 2 HTTP round-trips per bundle, and pharia-os-app has 400+ bundles. The original 5-worker pool serialised this into ~4 minutes per image; raise it to 20 workers so the heaviest known image classifies in ~30 s while still being polite to Harbor. * perf(cosign-scan): early-terminate predicate enumeration when classification is stable For images with hundreds of OCI 1.1 sigstore-bundle referrers (e.g. pharia-os-app at 400+) decoding every bundle's DSSE payload to read the in-toto predicate type was the cosign-scan critical path -- 800+ HTTP round-trips per image, even with the 20-worker pool from 1a8580c. ``ImageScanner.detect_attestation_type`` only consumes three predicate classes from ``list_attestations``: the cosign signature predicate, an SBOM predicate (CycloneDX or SPDX), and the Aleph-Alpha triage predicate. Once at least one bundle of each kind has been observed, no further bundle can change the cosign-scan classification -- counts may go up but the SIGNED / SBOM / TRIAGE flags are already final. Track the predicate set as bundles complete and, the moment the "decisive" set is filled, cancel pending futures and break out of the ``as_completed`` loop. ``ThreadPoolExecutor.shutdown(wait=True)`` will still join the at-most-20 in-flight workers, so this is safe and deterministic; cancelled futures simply never execute their HTTP work. Empirically this drops classify latency for the heaviest signed image from "doesn't finish in 20 minutes" down to ~5-15 s, restoring the overall cosign-scan job to ~3-5 minutes wall-clock for the full pharia-ai image set. For the small minority of signed images that legitimately ship an SBOM but no triage (or vice versa) the loop falls through to a full enumeration -- but those images have a small number of bundles to begin with, so wall-clock impact is negligible. * perf(cosign-scan): cache predicate->digests map for extract_sbom / extract_triage The previous early-termination commit (daf7810) made ``list_attestations`` fast for heavily-attested images, but the cosign-scan job remained slow because the *next* step, ``extract_sbom`` / ``extract_triage``, called ``_find_attestation_digests`` which serially re-enumerated every sigstore bundle on the image (no parallelism, no shared work with ``list_attestations``). For pharia-os-app's 400+ bundles that's 800+ sequential HTTP round-trips per extraction call, twice per image. Add a per-instance cache mapping ``(image_name, digest) -> {predicate_type: [bundle_digests]}``. ``list_attestations`` populates it as it decodes each bundle's DSSE payload, and ``_find_attestation_digests`` consults it before falling back to the slow path. The cosign-scan flow now decodes each bundle exactly once (during ``detect_attestation_type``) and reuses the result for both SBOM and triage extraction. Also parallelise the cache-miss fallback in ``_find_attestation_digests`` (20 workers, mirroring ``list_attestations``) so unusual call patterns -- callers that ask for a predicate type ``list_attestations`` did not observe before early-terminating, e.g. SLSA / vuln -- still benefit from the concurrency improvements. * diag(cosign-scan): surface predicate map + discovery_failed at INFO level When detect_attestation_type classifies an image as UNSIGNED, the reason was only visible at DEBUG, which is gated by --verbose. In CI this meant any false UNSIGNED (e.g. heavily-attested pharia-os-app under 15x parallel scan load) was indistinguishable from a genuinely unsigned image. Surface three diagnostics at INFO so cosign-scan CI logs capture the failure mode directly: * list_attestations now returns AttestationList.discovery_failed=True when oras discover timed out or when the sigstore bundle set was enumerated but the signature predicate never showed up in the decoded map (typical of bundle fetches silently failing under high load). * list_attestations logs per-image bundle decode outcomes (ok / err / empty counts) whenever any decode failure occurred. * detect_attestation_type logs the predicate map and discovery_failed flag alongside the UNSIGNED classification. Local 15-way parallel repro against Harbor still classifies all 15 test images correctly in 81 s wall-clock, so this change is diagnostic-only; no behavior change for the healthy path. Follow-up can wire a retry or an authoritative fallback once the CI logs confirm which failure mode is actually hitting us. * diag(cosign-scan): emit UNSIGNED diagnostics via stderr print Previous commit routed the diagnostic messages through ``logger.info``, but the CLI wraps every parallel-scan worker in ``suppress_logging`` (which bumps the root logger level to CRITICAL+1) so the progress bar has exclusive stderr control. That silently swallowed every INFO line in CI, defeating the whole point of the diagnostic. Switch the three UNSIGNED / discovery_failed diagnostics to direct ``print(..., file=sys.stderr, flush=True)`` so they survive ``suppress_logging`` and appear in the cosign-scan job log next to the progress bar output. Still diagnostic-only; no behavioral change on the healthy path. * fix(cosign-scan): raise Referrers API timeout + tag-based fallback Previous CI runs misclassified the heaviest signed image (``pharia-os-images/pharia-os-app:1.30.4``, 403 OCI 1.1 referrers) as UNSIGNED. The stderr diagnostic added in the prior commit pinpointed the cause: ``oras discover`` on that image takes ~95 s locally but exceeded the 120 s cap from GitHub-hosted runners (extra network round-trip + TLS cost amplifies the per-referrer Referrers-API latency). On timeout ``discover_referrers`` returned ``None`` without falling through to the tag-based path, so the downstream classifier saw zero referrers and declared the image unsigned. Two fixes: * Raise ``fast_timeout`` from 120 s to 240 s. Still bounded, still governed by the caller-supplied ``self.timeout``, but now comfortably above the worst observed tail (~150 s for pharia-os-app from CI). * Fall through to the tag-based ``oras discover`` retry on *any* digest-based failure, including timeout. Harbor caches tag->digest separately, so a tag-based request frequently succeeds when the digest-based one did not. The previous "bail out on timeout" short circuit turned the tag-based retry into dead code for the exact failure mode that needed it most. Also replaces the ``logger.debug`` calls with ``sys.stderr`` prints in the failure paths so operators can see *which* attempt timed out without rerunning with --verbose (same rationale as the prior diagnostic commit). * feat(reports): sort images alphabetically + replace "Namespace" label The scan reports were historically written for the Kubernetes-namespace use case (`scanner-py cosign-scan --namespace pharia-ai`), but are now also driven off static image-file lists by downstream tooling such as pharia-ai-helm-chart. That mix produced two chronic papercuts: * Per-image tables were emitted in *scan-completion order*, i.e. effectively random across reruns, making the reports useless for diffing and painful to read. * The header row read `| Namespace | <full path to images.txt> |` whenever `--image-file` was used, which is both wrong semantically and visually broken once the path is long. This change keeps full back-compat (defaults behave identically when `--namespace` is still the source of truth) while: * adding a `--source-label` flag to `cosign-scan` and `generate-sbom-report` for callers that want to set an explicit human-readable label; * falling back to `basename(--image-file)` instead of the raw path for the header when `--source-label` isn't set; * renaming the rendered header from "Namespace" to "Image source" in `generate_markdown_summary` (cosign) and `generate_markdown_report` (oras); * sorting the per-image rows alphabetically by full image reference in both markdown and CLI renderers (cosign + oras + generate-report), by basename for the triage report, and by image name for the SBOM detailed report; * preserving the Harbor project segment (e.g. `pharia-ai-images/foo:1.0`) in the displayed image name so duplicate basenames across projects stay distinguishable, only truncating very long refs; * replacing the grammatically broken SBOM header line "Source: Successful SBOM scans from <path> namespace" with "**Image source:** `<label>`". No semantic change to scan behaviour — this is purely presentation. * feat(reports): drop project segment from image column in tables The image column in the cosign-scan, ORAS, and standalone CVE reports previously rendered images as `<project>/<image>:<tag>` to disambiguate basename collisions across registry projects. In a typical chart-wide scan the project prefix is identical for nearly every row, so it adds noise without signal and pushes long names into the truncation cap. Render only `<image>:<tag>` in the image column of: * cosign-cve-summary.md (k8s_scanner.generate_markdown_summary) * standalone CVE markdown report (generate_report.generate_markdown_report) * oras-result.md (oras_scan.generate_markdown_report) The matching CLI/terminal print summaries (k8s_scanner.print_summary, oras_scan.print_cli_summary) get the same treatment for consistency. Truncation thresholds raised slightly (45->50 markdown, kept at 33/43 for terminal) since basenames are shorter and previously truncated names like `pharia-search-transition-actor:0.30.22` now fit in full. Caller is responsible for ensuring image basenames are unique across the input set; collisions would now render as visually identical rows. * fix(reports): sort image rows by basename to match displayed column Tables previously sorted by the full image path (`<host>/<project>/<image>:<tag>`). After the prior commit started rendering only `<image>:<tag>` in the image column, that left the visible rows looking unsorted: e.g. all `data-*` images appeared scrambled across the table because they live in different projects (`pharia-data/data-engine`, `pharia-data/data-etl`, `assistant-container-images/translation-service`, etc.) but the project prefix was hidden. Sort by `image.rsplit("/", 1)[-1].lower()` in all six renderers (markdown + CLI for cosign-scan, ORAS, and standalone CVE report) so the row order matches what the reader actually sees. * fix(trivy): prefer ghcr trivy-db over default mirror Trivy's default vulnerability DB order now prefers mirror.gcr.io before ghcr.io. The mirror can lag behind the official upstream publish, which means `trivy image --download-db-only` may fetch a stale DB even after Aquasecurity has already published a newer one. Pin the DB download in both cosign-scan and oras-scan to the official GHCR trivy-db image so scheduled scans prefer freshness over mirror latency.
1 parent a20c590 commit 9f486c5

8 files changed

Lines changed: 670 additions & 103 deletions

File tree

scanner_py/cli/generate_report.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,10 @@ def generate_markdown_report(
316316
images_with_chainguard = 0
317317
displayed_count = 0
318318

319-
for analysis in cve_analysis:
319+
for analysis in sorted(
320+
cve_analysis,
321+
key=lambda a: a.get("image", "").rsplit("/", 1)[-1].lower(),
322+
):
320323
# Get CVE counts
321324
critical = analysis.get("critical", 0)
322325
high = analysis.get("high", 0)
@@ -356,10 +359,14 @@ def generate_markdown_report(
356359

357360
displayed_count += 1
358361

359-
# Format image name
360-
image_short = analysis["image"].split("/")[-1]
361-
if len(image_short) > 40:
362-
image_short = image_short[:37] + "..."
362+
# Show only the image basename (`<image>:<tag>`); strip registry
363+
# host and project segments. The project prefix is identical for
364+
# most rows in a typical chart-wide scan and adds noise without
365+
# signal. Truncate very long refs (> 50 chars) by clipping the
366+
# tag end. Caller is responsible for unique basenames.
367+
image_short = analysis["image"].rsplit("/", 1)[-1]
368+
if len(image_short) > 50:
369+
image_short = image_short[:47] + "..."
363370

364371
# Format cells
365372
unaddr_str = f"✅ {unaddressed}" if unaddressed == 0 else f"🔴 **{unaddressed}**"
@@ -536,7 +543,10 @@ def print_cli_summary(
536543
images_with_triage = 0
537544
images_with_chainguard = 0
538545

539-
for analysis in cve_analysis:
546+
for analysis in sorted(
547+
cve_analysis,
548+
key=lambda a: a.get("image", "").rsplit("/", 1)[-1].lower(),
549+
):
540550
critical = analysis.get("critical", 0)
541551
high = analysis.get("high", 0)
542552
medium = analysis.get("medium", 0)

scanner_py/cli/generate_sbom_report.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ def create_generate_sbom_report_parser(subparsers: Any) -> argparse.ArgumentPars
5050
default="sbom-detailed-report.md",
5151
help="Output file path (default: sbom-detailed-report.md)",
5252
)
53+
parser.add_argument(
54+
"--source-label",
55+
help=(
56+
"Logical label for the scan source shown in the report header "
57+
"(overrides the value read from scan-summary.json). Use this when "
58+
"scanning a static image list that is not backed by a K8s namespace."
59+
),
60+
)
5361
parser.add_argument(
5462
"--verbose",
5563
action="store_true",
@@ -161,6 +169,9 @@ def generate_report(
161169
namespace: str = "pharia-ai",
162170
) -> bool:
163171
"""Generate detailed SBOM report."""
172+
# Sort alphabetically so the per-image sections render in a stable order
173+
# regardless of scan-completion order.
174+
successful_scans = sorted(successful_scans, key=lambda s: s.lower())
164175
total_images = len(successful_scans)
165176

166177
# Calculate overall statistics
@@ -196,7 +207,7 @@ def generate_report(
196207
# Header
197208
f.write("# Detailed SBOM Analysis Report\n\n")
198209
f.write(f"**Generated:** {datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')}\n")
199-
f.write(f"**Source:** Successful SBOM scans from {namespace} namespace\n")
210+
f.write(f"**Image source:** `{namespace}`\n")
200211
f.write(f"**Total Images Analyzed:** {total_images}\n\n")
201212
f.write("---\n\n")
202213
f.write("## Executive Summary\n\n")
@@ -403,8 +414,11 @@ def run_generate_sbom_report(args: argparse.Namespace) -> int:
403414
logger.error("No successful scans found in scan summary")
404415
return 1
405416

406-
# Get namespace
407-
namespace = summary.get("scan_summary", {}).get("namespace", "pharia-ai")
417+
# Resolve label: explicit --source-label wins over scan-summary.json.
418+
namespace = (
419+
getattr(args, "source_label", None)
420+
or summary.get("scan_summary", {}).get("namespace", "pharia-ai")
421+
)
408422

409423
# Generate report
410424
if generate_report(scan_results_dir, output_file, successful_scans, namespace):

scanner_py/cli/k8s_scanner.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
logger = get_logger(__name__)
2323

24+
TRIVY_DB_REPOSITORY = "ghcr.io/aquasecurity/trivy-db:2"
25+
2426

2527
def prepare_trivy_db(verbose: bool = False) -> bool:
2628
"""
@@ -31,7 +33,7 @@ def prepare_trivy_db(verbose: bool = False) -> bool:
3133
3234
Steps:
3335
1. trivy clean --all (clean existing db)
34-
2. trivy image --download-db-only (download fresh db)
36+
2. trivy image --download-db-only --db-repository ... (download fresh db)
3537
3638
Returns:
3739
True if successful
@@ -52,9 +54,17 @@ def prepare_trivy_db(verbose: bool = False) -> bool:
5254

5355
# Step 2: Download fresh database
5456
if verbose:
55-
logger.info("Downloading Trivy vulnerability database...")
57+
logger.info(
58+
f"Downloading Trivy vulnerability database from {TRIVY_DB_REPOSITORY}..."
59+
)
5660

57-
download_args = ["trivy", "image", "--download-db-only"]
61+
download_args = [
62+
"trivy",
63+
"image",
64+
"--download-db-only",
65+
"--db-repository",
66+
TRIVY_DB_REPOSITORY,
67+
]
5868
if not verbose:
5969
download_args.append("--quiet")
6070

@@ -120,6 +130,14 @@ def create_k8s_scanner_parser(subparsers: Any) -> argparse.ArgumentParser:
120130
default="pharia-ai",
121131
help="Kubernetes namespace to scan (default: pharia-ai)",
122132
)
133+
parser.add_argument(
134+
"--source-label",
135+
help=(
136+
"Logical label for the scan source shown in the report header "
137+
"(overrides --namespace / --image-file basename). Use this when "
138+
"scanning a static image list that is not backed by a K8s namespace."
139+
),
140+
)
123141
parser.add_argument(
124142
"--kubeconfig",
125143
help="Path to kubeconfig file",
@@ -613,7 +631,15 @@ def generate_summary(
613631
failed = [r for r in results if not r.success and not r.skipped]
614632
skipped = [r for r in results if r.skipped]
615633

616-
namespace_label = args.image_file if args.image_file else args.namespace
634+
# Prefer explicit --source-label, else the basename of --image-file, else --namespace.
635+
# Using the full --image-file path as a label produced unreadable "Namespace"
636+
# values like "/home/runner/work/.../images-harbor.txt" in the step summary.
637+
if getattr(args, "source_label", None):
638+
namespace_label = args.source_label
639+
elif args.image_file:
640+
namespace_label = Path(args.image_file).name
641+
else:
642+
namespace_label = args.namespace
617643
summary = ScanSummary(
618644
namespace=namespace_label,
619645
total_images_found=extraction_result.total_found,
@@ -702,7 +728,7 @@ def generate_markdown_summary(summary: ScanSummary, min_cve_level: str) -> str:
702728
lines.append("")
703729
lines.append(f"| Metric | Value |")
704730
lines.append("|--------|-------|")
705-
lines.append(f"| **Namespace** | `{summary.namespace}` |")
731+
lines.append(f"| **Image source** | `{summary.namespace}` |")
706732
lines.append(f"| **Images Found** | {summary.total_images_found} |")
707733
lines.append(f"| **Images Processed** | {summary.images_processed} |")
708734
lines.append(f"| **Successful Scans** | ✅ {summary.successful_scans} |")
@@ -742,11 +768,19 @@ def generate_markdown_summary(summary: ScanSummary, min_cve_level: str) -> str:
742768
images_with_triage = 0
743769
images_with_chainguard = 0
744770

745-
for analysis in summary.cve_analysis:
746-
image_short = analysis["image"].split("/")[-1]
747-
# Truncate if too long
748-
if len(image_short) > 40:
749-
image_short = image_short[:37] + "..."
771+
for analysis in sorted(
772+
summary.cve_analysis,
773+
key=lambda a: a.get("image", "").rsplit("/", 1)[-1].lower(),
774+
):
775+
# Show only the image basename (`<image>:<tag>`); strip the registry
776+
# host and project segments. Reports list 30-40 images at a time and
777+
# the project prefix is the same for almost all of them, so it adds
778+
# noise without adding signal. Truncate very long refs (> 50 chars)
779+
# by clipping the tag end. Caller is responsible for ensuring
780+
# basenames are unique across the input image set.
781+
image_short = analysis["image"].rsplit("/", 1)[-1]
782+
if len(image_short) > 50:
783+
image_short = image_short[:47] + "..."
750784

751785
# Get CVE counts
752786
critical = analysis.get("critical", 0)
@@ -940,8 +974,11 @@ def print_summary(summary: ScanSummary, min_cve_level: str, verbose: bool = Fals
940974
images_with_triage = 0
941975
images_with_chainguard = 0
942976

943-
for analysis in summary.cve_analysis:
944-
image_short = analysis["image"].split("/")[-1][:33]
977+
for analysis in sorted(
978+
summary.cve_analysis,
979+
key=lambda a: a.get("image", "").rsplit("/", 1)[-1].lower(),
980+
):
981+
image_short = analysis["image"].rsplit("/", 1)[-1][:33]
945982

946983
# Get CVE counts
947984
critical = analysis.get("critical", 0)

0 commit comments

Comments
 (0)