diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 0125faa12..1bb3e4dee 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -245,6 +245,7 @@ jobs: contents: read packages: write id-token: write + security-events: write needs: matrix # GitHub Actions fails (not skips) a matrix job when the matrix evaluates to []. # Guard here so an empty change-aware matrix (a push with nothing to build) @@ -456,8 +457,17 @@ jobs: # name-free `repo@sha256:...` entry per target -- all of them coexist, # unlike tags. wizcli's `extract` driver reads the local store, so it can # only resolve every target once Test has warmed it. - - name: Scan - if: ${{ inputs.push && steps.filter-steps.outputs.wiz-auth == 'true' }} + # Production builds scan only the latest version of each image, now that the + # backlog has been scanned once; development builds keep scanning every push. + # Gated here rather than by passing `--latest` to the scan: this job is already + # pinned to one version, so `--latest` combined with that pin resolved to an + # empty target set on every non-latest version and exited 1, which + # continue-on-error then hid behind a red annotation on an otherwise green job. + # matrix.img.latest comes from `bakery ci matrix`, so the "is this the latest + # version" decision is made once, in one place. + - name: Wiz Scan + if: ${{ inputs.push && steps.filter-steps.outputs.wiz-auth == 'true' + && (inputs.dev-versions == 'only' || matrix.img.latest) }} continue-on-error: true env: IMAGE_NAME: ${{ matrix.img.image }} @@ -479,11 +489,6 @@ jobs: [[ -n "$WIZ_POLICY_ID" ]] && POLICY_FLAGS=(--policies "$WIZ_POLICY_ID") PROJECT_FLAGS=() [[ -n "$WIZ_PROJECT_ID" ]] && PROJECT_FLAGS=(--projects "$WIZ_PROJECT_ID") - # Production builds (dev-versions != only) scan only the --latest - # version of each image now that the backlog has been scanned once. - # Development builds keep scanning every push. - LATEST_FLAGS=() - [[ "$DEV_VERSIONS" != "only" ]] && LATEST_FLAGS=(--latest) WIZCLI_PATH=${GITHUB_WORKSPACE}/tools/wizcli \ bakery wizcli scan \ --image-name "^${IMAGE_NAME}$" \ @@ -494,9 +499,44 @@ jobs: "${DEV_SPEC_FLAGS[@]}" \ "${POLICY_FLAGS[@]}" \ "${PROJECT_FLAGS[@]}" \ - "${LATEST_FLAGS[@]}" \ --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ --context "$CONTEXT" + + - name: Setup trivy + if: ${{ inputs.push && matrix.img.latest }} + uses: "posit-dev/images-shared/setup-trivy@main" + + # After Test for the same reason the Wiz scan is: only Test's `docker run` + # makes every target addressable locally. Scanning earlier silently pulled + # and scanned the published image instead of the one just built. + - name: Trivy Scan + if: ${{ inputs.push && matrix.img.latest }} + continue-on-error: true + env: + IMAGE_NAME: ${{ matrix.img.image }} + IMAGE_VERSION: ${{ matrix.img.version }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} + CONTEXT: ${{ inputs.context }} + run: | + bakery trivy scan \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --image-platform "$NORMALIZED_PLATFORM" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ + --context "$CONTEXT" + + # No `category:` on purpose: each file carries its own in + # automationDetails.id, and one category across all of them is rejected. + - name: Upload Trivy SARIF + if: ${{ inputs.push && matrix.img.latest && !cancelled() && hashFiles('results/trivy/**/*.sarif') != '' }} + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: results/trivy + - name: Upload Metadata uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22389d3f1..3d9b20c9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Setup wizcli uses: ./setup-wizcli + - name: Setup trivy + uses: ./setup-trivy + - name: Setup ORAS CLI uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 @@ -164,6 +167,7 @@ jobs: contents: read packages: write id-token: write + security-events: write uses: "./.github/workflows/bakery-build-native.yml" secrets: @@ -181,6 +185,7 @@ jobs: permissions: contents: read packages: write + security-events: write uses: "./.github/workflows/bakery-build-pr.yml" with: version: ${{ github.head_ref || github.ref_name }} diff --git a/CLAUDE.md b/CLAUDE.md index 87696ba5e..76a2779f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ Run `bakery --help` and `bakery --help` for full command reference. Commands have aliases: `build`/`b`, `create`/`c`, `run`/`r`, `update`/`u`, `remove`/`rm`. Key commands for product repos: `bakery build`, `bakery build --plan`, `bakery dgoss run`, -`bakery update files`, `bakery create version`, `bakery ci matrix`. +`bakery trivy scan`, `bakery update files`, `bakery create version`, `bakery ci matrix`. ## Image Templating System diff --git a/posit-bakery/README.md b/posit-bakery/README.md index 4490c1365..6e4b3c1d7 100644 --- a/posit-bakery/README.md +++ b/posit-bakery/README.md @@ -39,7 +39,7 @@ Full documentation is available at **[posit-dev.github.io/images-shared](https:/ | [dgoss](https://github.com/goss-org/goss#installation) | `bakery dgoss run` | Test container images for expected content & behavior | | [hadolint](https://github.com/hadolint/hadolint#install) | `bakery hadolint run` | Lint Dockerfile/Containerfile | | [openscap](https://static.open-scap.org/) | to be implemented | Scan container images for secure configuration and vulnerabilities | -| [trivy](https://trivy.dev/docs/latest/getting-started/) | to be implemented | Scan container images for vulnerabilities | +| [trivy](https://trivy.dev/docs/latest/getting-started/) | `bakery trivy scan` | Scan container images for vulnerabilities | | [wizcli](https://www.wiz.io/lp/wiz-cli) | `bakery wizcli scan` | Scan container images for vulnerabilities | ## Installation diff --git a/posit-bakery/docs/architecture.qmd b/posit-bakery/docs/architecture.qmd index 4848c6ad2..fde02eccc 100644 --- a/posit-bakery/docs/architecture.qmd +++ b/posit-bakery/docs/architecture.qmd @@ -221,7 +221,7 @@ flowchart TD results[/"Test & Scan results"/] trivy[[trivy]] - runTrivy[[bakery trivy run]] + runTrivy[[bakery trivy scan]] runTrivy --> trivy image -.-> trivy -.-> results @@ -244,8 +244,8 @@ flowchart TD class trivy,openscap,wizcli external %% Mark what we are working on and is in flight %% - class inprogress,trivy todo - class openscap,results todo + class inprogress todo + class openscap todo ``` ### Publish diff --git a/posit-bakery/docs/configuration.qmd b/posit-bakery/docs/configuration.qmd index c02d59635..1aed7d708 100644 --- a/posit-bakery/docs/configuration.qmd +++ b/posit-bakery/docs/configuration.qmd @@ -666,6 +666,33 @@ images: - linux/arm64 ``` +#### TrivyOptions + +A TrivyOption configures [Trivy](https://trivy.dev/docs/latest/getting-started/) vulnerability scanning for an image target. + +Trivy options can be set in the `options` array of an [Image](#image) or an [ImageVariant](#imagevariant). When both are present, the variant's options are merged over the image's options, with any field not explicitly set on the variant inheriting the image-level value. Scanning runs via `bakery trivy scan`, which writes SARIF results to `results/trivy/`. CLI flags (`--severity`, `--fail-on-severity`, `--disabled-scanners`, `--timeout`) take precedence over these options when both are set. + +| Field | Description | Default Value | Example | +|-----------------------------------------|-------------------------------------------------------------------|-----------------|---------------------------| +| `tool`
*"trivy" literal string* | *(Required)* The name of the tool. | | `trivy` | +| `severity`
*string array* | Severities to report (e.g. HIGH, CRITICAL). | Trivy default | `["HIGH", "CRITICAL"]` | +| `failOnSeverity`
*string array* | Severities that fail the scan if found. Unset means never fail. | Never fails | `["CRITICAL"]` | +| `disabledScanners`
*string array* | Scanners to disable (e.g. secret, license, misconfig). | Trivy default | `["secret"]` | +| `timeout`
*string* | Timeout for the scan (e.g. 1h, 10m). | Trivy default | `"10m"` | + +##### Example TrivyOptions + +```yaml +images: + - name: workbench + options: + - tool: trivy + severity: ["HIGH", "CRITICAL"] + failOnSeverity: ["CRITICAL"] + disabledScanners: ["secret"] + timeout: "10m" +``` + ### DevBuildSpec A DevBuildSpec is a typed payload passed to the `--dev-spec` CLI option to configure development (daily) version builds. It supports either a dispatch-pinned version or a branch-targeted discovery build. At least one of `version` or `release_branch` must be set. diff --git a/posit-bakery/docs/index.qmd b/posit-bakery/docs/index.qmd index 4aec0c673..93bacabad 100644 --- a/posit-bakery/docs/index.qmd +++ b/posit-bakery/docs/index.qmd @@ -18,10 +18,11 @@ Bakery is a CLI tool that binds together various tools to manage a matrixed buil | [dgoss](https://github.com/goss-org/goss#installation) | `bakery dgoss run` | Test container images for expected content & behavior | | [hadolint](https://github.com/hadolint/hadolint#install) | `bakery hadolint run` | Lint Containerfiles for best practices | | [oras](https://oras.land/) | `bakery imagetools merge` | Merge multi-platform images into an OCI index | +| [trivy](https://trivy.dev/docs/latest/getting-started/) | `bakery trivy scan` | Scan container images for vulnerabilities | | [wizcli](https://www.wiz.io/) | `bakery wizcli scan` | Scan container images for vulnerabilities | ::: {.callout-note} -Additional tool integrations (trivy, openscap) are planned. See the [architecture diagrams](architecture.qmd) for the full roadmap. +Additional tool integrations (openscap) are planned. See the [architecture diagrams](architecture.qmd) for the full roadmap. ::: ## Installation diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index 4b086c12f..38ea495de 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -35,6 +35,7 @@ class RichHelpPanelEnum(str, Enum): class BakeryCIMatrixFieldEnum(str, Enum): VERSION = "version" DEV = "dev" + LATEST = "latest" PLATFORM = "platform" @@ -265,6 +266,7 @@ def matrix( "image": "image-name", "version": "version-name", "dev": false, + "latest": true, "platform": "linux/amd64" } ] @@ -357,6 +359,10 @@ def matrix( entry["version"] = ver.name if BakeryCIMatrixFieldEnum.DEV not in exclude: entry["dev"] = ver.isDevelopmentVersion + if BakeryCIMatrixFieldEnum.LATEST not in exclude: + # Same predicate the --latest filter uses, so a workflow gating on + # this field selects exactly what `--latest` would have. + entry["latest"] = ver.is_latest_release if BakeryCIMatrixFieldEnum.PLATFORM not in exclude: for platform in ver.supported_platforms: entry["platform"] = platform diff --git a/posit-bakery/posit_bakery/cli/common.py b/posit-bakery/posit_bakery/cli/common.py index 131a5fc34..237a8dc57 100644 --- a/posit-bakery/posit_bakery/cli/common.py +++ b/posit-bakery/posit_bakery/cli/common.py @@ -43,27 +43,43 @@ def exit_if_no_targets(config: "BakeryConfig", settings: "BakerySettings") -> No detail = f" matching {active}" if active else "" stderr_console.print( f"❌ No image targets{detail}. Check the --image-name, --image-version, " - "--image-variant, --image-os, and --image-platform filters along with the " - "--dev-versions/--matrix-versions selection.", + "--image-variant, --image-os, --image-platform, and --latest filters along " + "with the --dev-versions/--matrix-versions selection.", style="error", ) raise typer.Exit(code=1) def _describe_active_filters(settings: "BakerySettings") -> str: - """Render the set filters as a human-readable ``--flag value`` list.""" + """Render the active filters as a human-readable ``--flag value`` list. + + Only lists filters the caller actually set. ``--latest`` is included + because it is the one narrowing policy filter whose default (``False``) + does not narrow, so seeing it here always means the caller asked for it -- + and it is frequently the filter that emptied the selection while every + selector matched, which a selector-only message hid entirely. + ``--dev-versions``/``--matrix-versions`` stay out: they default to + ``exclude``, so echoing them would report flags the caller never passed. + """ f = settings.filter - parts = [ - f"--{name} {value!r}" - for name, value in ( - ("image-name", f.image_name), - ("image-version", f.image_version), - ("image-variant", f.image_variant), - ("image-os", f.image_os), - ("image-platform", f.image_platform), - ) - if value - ] + parts = [] + for name, value in ( + ("image-name", f.image_name), + ("image-version", f.image_version), + ("image-variant", f.image_variant), + ("image-os", f.image_os), + ("image-platform", f.image_platform), + ): + if not value: + continue + if isinstance(value, (list, tuple)): + # Render as the flag would be typed rather than as a Python list + # repr, which surfaced as "--image-platform ['linux/arm64']". + parts.extend(f"--{name} {v!r}" for v in value) + else: + parts.append(f"--{name} {value!r}") + if settings.latest: + parts.append("--latest") return ", ".join(parts) diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index 17cceaa03..3a33dca4a 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -1020,12 +1020,8 @@ def generate_image_targets(self, settings: BakerySettings = BakerySettings()): f"due to not matching version filter '{settings.filter.image_version}'" ) continue - if settings.latest and (not version.latest or version.isDevelopmentVersion): - reason = ( - "development version ignored by --latest" - if version.isDevelopmentVersion - else "not the latest version (excluded by --latest)" - ) + included, reason = version.matches_latest_filter(settings.latest) + if not included: if version_filter_matched: log.warning( f"Version '{version.name}' in image '{image.name}' matches --image-version filter " diff --git a/posit-bakery/posit_bakery/config/image/version.py b/posit-bakery/posit_bakery/config/image/version.py index f4f740b5e..f6eb7ac96 100644 --- a/posit-bakery/posit_bakery/config/image/version.py +++ b/posit-bakery/posit_bakery/config/image/version.py @@ -178,6 +178,40 @@ def matches_dev_filter( return False, f"dev channel '{vc_str}' does not match --dev-channel '{dev_channel.value}'" return True, None + @property + def is_latest_release(self) -> bool: + """Whether ``--latest`` selects this version. + + A development version is never the latest release, even if it carries the + flag: ``--latest`` selects the newest *release*. Every dev-version + constructor currently hardcodes ``latest=False``, so the second clause is + belt-and-braces -- but this predicate is what CI gates key on to decide + what gets security-scanned, and a wrong boolean there fails silently. + + Deliberately a plain ``property`` and not a ``computed_field``: + ImageVersion is round-tripped back into bakery.yaml via ``model_dump`` + (see ``BakeryConfig.patch_version``), and a computed field would write a + derived key into the user's config file. + """ + return self.latest and not self.isDevelopmentVersion + + def matches_latest_filter(self, latest: bool) -> tuple[bool, str | None]: + """Check whether this version should be included given the ``--latest`` filter. + + Shares :pyattr:`is_latest_release` with the ``latest`` field emitted by + ``bakery ci matrix``, so a workflow gating on that field and a caller + passing ``--latest`` always select the same versions. + + :param latest: Whether the --latest filter is active. When False, every + version is included and the filter is a no-op. + :return: A tuple of (included, reason). If excluded, reason explains why. + """ + if not latest or self.is_latest_release: + return True, None + if self.isDevelopmentVersion: + return False, "development version ignored by --latest" + return False, "not the latest version (excluded by --latest)" + @field_validator("extraRegistries", "overrideRegistries", mode="after") @classmethod def deduplicate_registries( diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py new file mode 100644 index 000000000..49d985c92 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py @@ -0,0 +1,353 @@ +import logging +from enum import Enum +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from posit_bakery.cli.common import with_verbosity_flags, exit_if_no_targets, parse_dev_spec +from posit_bakery.config.config import BakeryConfig, BakeryConfigFilter, BakerySettings +from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum +from posit_bakery.error import BakeryToolRuntimeErrorGroup +from posit_bakery.image.image_target import ImageTarget +from posit_bakery.log import stderr_console +from posit_bakery.plugins.builtin.trivy.options import TrivyOptions +from posit_bakery.plugins.builtin.trivy.report import TrivyReportCollection +from posit_bakery.plugins.builtin.trivy.suite import TrivySuite +from posit_bakery.plugins.protocol import BakeryToolPlugin, ToolCallResult +from posit_bakery.settings import SETTINGS +from posit_bakery.util import auto_path + +log = logging.getLogger(__name__) + + +class RichHelpPanelEnum(str, Enum): + FILTERS = "Filters" + TRIVY = "Trivy Options" + + +class TrivyPlugin(BakeryToolPlugin): + name: str = "trivy" + description: str = "Scan container images for vulnerabilities with Trivy" + tool_options_class = TrivyOptions + + def register_cli(self, app: typer.Typer) -> None: + trivy_app = typer.Typer(no_args_is_help=True) + plugin = self + + @trivy_app.command() + @with_verbosity_flags + def scan( + context: Annotated[ + Path, + typer.Option( + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + writable=True, + resolve_path=True, + help="The root path to use. Defaults to the current working directory where invoked.", + ), + ] = auto_path(), + image_name: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image name to isolate scanning to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + image_version: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image version to isolate scanning to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + image_variant: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image variant to isolate scanning to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + image_os: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image OS to isolate scanning to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + image_platform: Annotated[ + Optional[str], + typer.Option( + show_default=SETTINGS.get_host_architecture(), + help="Filters which image build platform to scan.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + dev_versions: Annotated[ + Optional[DevVersionInclusionEnum], + typer.Option( + help="Include or exclude development versions defined in config.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = DevVersionInclusionEnum.EXCLUDE, + dev_spec: Annotated[ + str | None, + typer.Option( + "--dev-spec", + envvar="BAKERY_DEV_SPEC", + help='JSON spec for a dispatched dev build. Ex: \'{"version": "2026.05.0-dev+185-gSHA", "channel": "daily"}\'', + rich_help_panel=RichHelpPanelEnum.FILTERS, + callback=parse_dev_spec, + ), + ] = None, + matrix_versions: Annotated[ + Optional[MatrixVersionInclusionEnum], + typer.Option( + help="Include or exclude versions defined in image matrix.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = MatrixVersionInclusionEnum.EXCLUDE, + latest: Annotated[ + Optional[bool], + typer.Option( + "--latest", + help="Scan only the latest version of each image. Development versions are ignored by this filter.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = False, + metadata_file: Annotated[ + Optional[Path], + typer.Option( + help="Path to a build metadata file. If given, attempts to scan image artifacts in the file." + ), + ] = None, + # Trivy-specific options + severity: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="Comma-separated severities to report (e.g. HIGH,CRITICAL).", + rich_help_panel=RichHelpPanelEnum.TRIVY, + ), + ] = None, + fail_on_severity: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="Comma-separated severities that fail the scan if found (e.g. CRITICAL). " + "Unset means findings never fail the scan.", + rich_help_panel=RichHelpPanelEnum.TRIVY, + ), + ] = None, + disabled_scanners: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="Comma-separated scanners to disable (e.g. secret,license,misconfig).", + rich_help_panel=RichHelpPanelEnum.TRIVY, + ), + ] = None, + timeout: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="Timeout for the scan (e.g. 1h, 10m).", + rich_help_panel=RichHelpPanelEnum.TRIVY, + ), + ] = None, + trivy_config: Annotated[ + Optional[Path], + typer.Option( + show_default=False, + help="Path to a native trivy.yaml config file. Defaults to '/trivy.yaml' if present.", + rich_help_panel=RichHelpPanelEnum.TRIVY, + ), + ] = None, + ) -> None: + """Scan container images for vulnerabilities using Trivy. + + \b + Runs `trivy image` against each image target in the project. + Results are written as SARIF files to the `results/trivy/` directory. + + \b + Images are expected to be available to the local Docker daemon, or + resolvable to a registry digest via --metadata-file. It is advised + to run `build` before running trivy scans. + + \b + Requires trivy to be installed on the system. The path to the binary can be + set with the `TRIVY_PATH` environment variable if not present in the system PATH. + """ + platform = image_platform or SETTINGS.architecture + if not platform.startswith("linux/"): + platform = f"linux/{platform}" + + settings = BakerySettings( + filter=BakeryConfigFilter( + image_name=image_name, + image_version=image_version, + image_variant=image_variant, + image_os=image_os, + image_platform=[platform], + ), + dev_versions=dev_versions, + dev_spec=dev_spec, # type: ignore[arg-type] # typer requires str annotation; parse_dev_spec callback delivers DevBuildSpec at runtime + matrix_versions=matrix_versions, + latest=latest, + ) + c = BakeryConfig.from_context(context, settings) + + exit_if_no_targets(c, settings) + + if metadata_file: + c.load_build_metadata_from_file(metadata_file) + + results = plugin.execute( + c.base_path, + c.targets, + scan_platform=platform, + severity=severity, + fail_on_severity=fail_on_severity, + disabled_scanners=disabled_scanners, + timeout=timeout, + trivy_config=trivy_config, + ) + plugin.results(results) + + app.add_typer(trivy_app, name="trivy", help="Scan container images for vulnerabilities with Trivy") + + def execute( + self, + base_path: Path, + targets: list[ImageTarget], + *, + scan_platform: str | None = None, + severity: str | None = None, + fail_on_severity: str | None = None, + disabled_scanners: str | None = None, + timeout: str | None = None, + trivy_config: Path | None = None, + **kwargs, + ) -> list[ToolCallResult]: + suite = TrivySuite( + base_path, + targets, + scan_platform=scan_platform, + severity=severity, + disabled_scanners=disabled_scanners, + timeout=timeout, + trivy_config=trivy_config, + ) + report_collection, errors = suite.run() + + # Each TrivyCommand already resolved its own per-target TrivyOptions + # (variant overrides image, per get_tool_option); reuse that resolution + # here so --fail-on-severity falls back to bakery.yaml the same way + # --severity/--disabled-scanners/--timeout already do in TrivyCommand. + tool_options_by_uid = {cmd.image_target.uid: cmd.tool_options for cmd in suite.trivy_commands} + + error_list = [] + if errors is not None: + if isinstance(errors, BakeryToolRuntimeErrorGroup): + error_list = list(errors.exceptions) + else: + error_list = [errors] + + results = [] + for target in targets: + report = None + if target.image_name in report_collection: + target_reports = report_collection[target.image_name] + if target.uid in target_reports: + _, report = target_reports[target.uid] + + target_error = None + for err in error_list: + if hasattr(err, "message") and str(target) in err.message: + target_error = err + break + + target_tool_options = tool_options_by_uid.get(target.uid) + resolved_fail_on_severity = fail_on_severity or ( + ",".join(target_tool_options.failOnSeverity) + if target_tool_options and target_tool_options.failOnSeverity + else None + ) + breach_severities = ( + [s.strip().upper() for s in resolved_fail_on_severity.split(",") if s.strip()] + if resolved_fail_on_severity + else None + ) + + severity_breach = bool(report and breach_severities and report.breaches(breach_severities)) + + exit_code = 0 + if target_error is not None: + exit_code = getattr(target_error, "exit_code", 1) + elif severity_breach: + exit_code = 1 + + artifacts = {} + if report is not None: + artifacts["report"] = report + if target_error is not None: + artifacts["execution_error"] = target_error + if severity_breach: + artifacts["severity_breach"] = True + + results.append( + ToolCallResult( + exit_code=exit_code, + tool_name="trivy", + target=target, + stdout="", + stderr="", + artifacts=artifacts if artifacts else None, + ) + ) + + return results + + def results(self, results: list[ToolCallResult]) -> None: + report_collection = TrivyReportCollection() + has_errors = False + has_severity_breach = False + errors = [] + + for result in results: + if result.artifacts and "report" in result.artifacts: + report_collection.add_report(result.target, result.artifacts["report"]) + if result.artifacts and "execution_error" in result.artifacts: + errors.append(result.artifacts["execution_error"]) + has_errors = True + if result.artifacts and result.artifacts.get("severity_breach"): + has_severity_breach = True + + if report_collection: + stderr_console.print(report_collection.table()) + + if has_severity_breach: + stderr_console.print("-" * 80) + stderr_console.print( + "Findings matching one or more of the configured --fail-on-severity severities were detected.", + style="bright_red bold", + ) + + if has_errors: + stderr_console.print("-" * 80) + for err in errors: + stderr_console.print(err, style="error") + stderr_console.print("❌ trivy scan(s) failed to execute", style="error") + + if has_errors or has_severity_breach: + raise typer.Exit(code=1) + + stderr_console.print("✅ Scans completed", style="success") diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py new file mode 100644 index 000000000..03ccad8c4 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py @@ -0,0 +1,149 @@ +import re +from pathlib import Path +from typing import Annotated, Self + +from pydantic import BaseModel, Field, computed_field, model_validator + +from posit_bakery.image.image_target import ImageTarget, ImageTargetContext +from posit_bakery.plugins.builtin.trivy.options import TrivyOptions +from posit_bakery.settings import SETTINGS +from posit_bakery.util import find_bin + +TRIVY_ALL_SCANNERS = ["vuln", "secret", "license", "misconfig"] +# Trivy's own default for `trivy image` only runs vuln,secret -- license/misconfig +# are off by default. The --disabled-scanners complement must be computed against +# this default set, not TRIVY_ALL_SCANNERS, or disabling e.g. just "secret" would +# silently turn ON license/misconfig scanning (which report real severities and +# pollute the vulnerability count with unrelated findings). +TRIVY_DEFAULT_SCANNERS = ["vuln", "secret"] + + +def find_trivy_bin(context: ImageTargetContext) -> str | None: + """Find the path to the trivy binary.""" + return find_bin(context.base_path, "trivy", "TRIVY_PATH") or "trivy" + + +def discover_trivy_config(image_target: ImageTarget) -> Path | None: + """Look for a native `trivy.yaml` at the image's root directory (e.g. `workbench/trivy.yaml`).""" + candidate = image_target.context.base_path / image_target.image_name / "trivy.yaml" + return candidate if candidate.is_file() else None + + +class TrivyCommand(BaseModel): + image_target: ImageTarget + trivy_bin: Annotated[str, Field(default_factory=lambda data: find_trivy_bin(data["image_target"].context))] + results_file: Path + + # ToolOptions fields + tool_options: Annotated[TrivyOptions | None, Field(default=None)] + + # Platform actually being scanned, as resolved by the CLI. Not derivable from + # image_target: a target is not platform-scoped (image_os.platforms is a list), + # so the host architecture is only correct when the scan happens to be native. + scan_platform: Annotated[str, Field(default_factory=lambda: f"linux/{SETTINGS.architecture}")] + + # CLI pass-through options + severity: Annotated[str | None, Field(default=None)] + disabled_scanners: Annotated[str | None, Field(default=None)] + timeout: Annotated[str | None, Field(default=None)] + trivy_config: Annotated[Path | None, Field(default=None)] + + @classmethod + def from_image_target( + cls, + image_target: ImageTarget, + results_dir: Path, + *, + tool_options: TrivyOptions | None = None, + scan_platform: str | None = None, + severity: str | None = None, + disabled_scanners: str | None = None, + timeout: str | None = None, + trivy_config: Path | None = None, + ) -> "TrivyCommand": + # Resolve tool options from variant config if not explicitly provided + if tool_options is None and image_target.image_variant: + tool_options = image_target.image_variant.get_tool_option("trivy") + + if trivy_config is None: + trivy_config = discover_trivy_config(image_target) + + image_subdir = results_dir / image_target.image_name + results_file = image_subdir / f"{image_target.uid}.sarif" + + return cls( + image_target=image_target, + results_file=results_file, + tool_options=tool_options, + **({"scan_platform": scan_platform} if scan_platform else {}), + severity=severity, + disabled_scanners=disabled_scanners, + timeout=timeout, + trivy_config=trivy_config, + ) + + @computed_field + @property + def scan_category(self) -> str: + """Version-stable category key for GitHub Code Scanning. + + Omits the image version so the same category is reused across releases, + which is what lets code scanning diff a PR against its baseline instead + of reporting every finding as new. Uses tag display names (Variant, OS) + and the scanned platform's architecture to match published image tags. + + Deliberately *not* used as the results filename: two versions of the same + image share a category by design, so filenames stay uid-keyed and the + category travels in the SARIF's automationDetails.id instead. + """ + tv = self.image_target.tag_template_values + parts = [self.image_target.image_name] + if tv["Variant"]: + parts.append(tv["Variant"]) + if tv["OS"]: + parts.append(tv["OS"]) + parts.append(self.scan_platform.removeprefix("linux/")) + return re.sub(r"[ .+/]", "-", "-".join(parts)).lower() + + @model_validator(mode="after") + def check_trivy_bin(self) -> Self: + if not self.trivy_bin: + raise ValueError( + "trivy binary path must be specified with the `TRIVY_PATH` environment variable if it cannot be " + "discovered in the system PATH." + ) + return self + + @computed_field + @property + def command(self) -> list[str]: + cmd = [self.trivy_bin, "image", self.image_target.ref()] + + cmd.extend(["--format", "sarif"]) + cmd.extend(["--output", str(self.results_file)]) + cmd.append("--quiet") + + severity = self.severity or ( + ",".join(self.tool_options.severity) if self.tool_options and self.tool_options.severity else None + ) + if severity: + cmd.extend(["--severity", severity]) + + disabled_scanners = self.disabled_scanners or ( + ",".join(self.tool_options.disabledScanners) + if self.tool_options and self.tool_options.disabledScanners + else None + ) + if disabled_scanners: + disabled_set = {s.strip() for s in disabled_scanners.split(",") if s.strip()} + enabled = [s for s in TRIVY_DEFAULT_SCANNERS if s not in disabled_set] + cmd.extend(["--scanners", ",".join(enabled)]) + + timeout = self.timeout or (self.tool_options.timeout if self.tool_options else None) + if timeout: + cmd.extend(["--timeout", timeout]) + + if self.trivy_config: + cmd.extend(["--config", str(self.trivy_config)]) + + return cmd diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/errors.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/errors.py new file mode 100644 index 000000000..c3059f2c7 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/errors.py @@ -0,0 +1,39 @@ +import textwrap +from typing import List + +from posit_bakery.error import BakeryToolRuntimeError + + +class BakeryTrivyError(BakeryToolRuntimeError): + def __init__( + self, + message: str = None, + tool_name: str = None, + cmd: List[str] = None, + stdout: str | bytes | None = None, + stderr: str | bytes | None = None, + exit_code: int = 1, + metadata: dict | None = None, + ) -> None: + super().__init__( + message=message, + tool_name=tool_name, + cmd=cmd, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + metadata=metadata, + ) + + def __str__(self) -> str: + s = f"{self.message}\n" + s += f" - Exit code: {self.exit_code}\n" + stdout_dump = self.dump_stdout() + if stdout_dump: + s += f" - Output:\n{textwrap.indent(stdout_dump, ' ')}\n" + s += f" - Command executed: {' '.join(self.cmd)}\n" + if self.metadata: + s += " - Metadata:\n" + for key, value in self.metadata.items(): + s += f" - {key}: {value}\n" + return s diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/options.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/options.py new file mode 100644 index 000000000..b3cc7f377 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/options.py @@ -0,0 +1,40 @@ +from copy import deepcopy +from typing import Annotated, Literal + +from pydantic import Field + +from posit_bakery.config.tools.base import ToolOptions + + +class TrivyOptions(ToolOptions): + """Configuration options for Trivy container image scanning.""" + + tool: Literal["trivy"] = "trivy" + severity: Annotated[ + list[str] | None, + Field(default=None, description="Severities to report (e.g. HIGH, CRITICAL)."), + ] = None + failOnSeverity: Annotated[ + list[str] | None, + Field(default=None, description="Severities that fail the scan if found. Unset means never fail."), + ] = None + disabledScanners: Annotated[ + list[str] | None, + Field(default=None, description="Scanners to disable (e.g. secret, license, misconfig)."), + ] = None + timeout: Annotated[ + str | None, + Field(default=None, description="Timeout for the scan (e.g. 1h, 10m)."), + ] = None + + def update(self, other: "TrivyOptions") -> "TrivyOptions": + """Update this instance with settings from another. + + The merge strategy uses the values of the other instance for any field not explicitly set + in the current instance. + """ + merged = deepcopy(self) + for field_name in ("severity", "failOnSeverity", "disabledScanners", "timeout"): + if field_name not in self.model_fields_set: + setattr(merged, field_name, getattr(other, field_name)) + return merged diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py new file mode 100644 index 000000000..2e5b67c2f --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py @@ -0,0 +1,184 @@ +import json +from pathlib import Path +from typing import Annotated + +from pydantic import BaseModel, Field +from rich.table import Table +from rich.text import Text + +from posit_bakery.image.image_target import ImageTarget + +_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") + + +class TrivyReport(BaseModel): + """Lightweight model for Trivy SARIF scan output. + + Captures per-severity finding counts by cross-referencing each SARIF result's + `ruleId` against its rule's `properties.tags` severity tag, without modeling + the full SARIF schema. + """ + + filepath: Annotated[Path | None, Field(default=None, exclude=True)] + critical_count: int = 0 + high_count: int = 0 + medium_count: int = 0 + low_count: int = 0 + unknown_count: int = 0 + + @property + def total_count(self) -> int: + return self.critical_count + self.high_count + self.medium_count + self.low_count + self.unknown_count + + def breaches(self, severities: list[str]) -> bool: + """Return True if any of the given severities has at least one finding.""" + counts = { + "CRITICAL": self.critical_count, + "HIGH": self.high_count, + "MEDIUM": self.medium_count, + "LOW": self.low_count, + "UNKNOWN": self.unknown_count, + } + return any(counts.get(sev.strip().upper(), 0) > 0 for sev in severities) + + @classmethod + def load(cls, filepath: Path, *, scan_category: str | None = None) -> "TrivyReport": + """Load a TrivyReport from a Trivy SARIF output file. + + Re-writes the file with indentation for human readability, since Trivy + outputs minified JSON by default. + + When ``scan_category`` is given, stamps it into each run's + ``automationDetails.id`` as ``"/"``. Everything before the last + slash is the code-scanning category, and the trailing slash leaves the run + ID empty. github/codeql-action/upload-sarif only fills automationDetails in + when it is absent, so stamping here lets one directory upload carry a + distinct category per file -- which a single ``category:`` input cannot do. + """ + raw = filepath.read_text() + data = json.loads(raw) + + if scan_category: + for run in data.get("runs", []) or []: + run["automationDetails"] = {"id": f"{scan_category}/"} + + formatted = json.dumps(data, indent=2) + "\n" + if formatted != raw: + filepath.write_text(formatted) + + counts = {sev: 0 for sev in _SEVERITIES} + + for run in data.get("runs", []) or []: + rules = run.get("tool", {}).get("driver", {}).get("rules", []) or [] + severity_by_rule_id = {} + for rule in rules: + tags = rule.get("properties", {}).get("tags", []) or [] + severity = next((t for t in tags if t in _SEVERITIES), "UNKNOWN") + severity_by_rule_id[rule["id"]] = severity + + for result in run.get("results", []) or []: + severity = severity_by_rule_id.get(result.get("ruleId"), "UNKNOWN") + counts[severity] += 1 + + return cls( + filepath=filepath, + critical_count=counts["CRITICAL"], + high_count=counts["HIGH"], + medium_count=counts["MEDIUM"], + low_count=counts["LOW"], + unknown_count=counts["UNKNOWN"], + ) + + +class TrivyReportCollection(dict): + """Collection of TrivyReports keyed by image_name -> {uid: (target, report)}.""" + + def add_report(self, image_target: ImageTarget, report: TrivyReport): + self.setdefault(image_target.image_name, dict())[image_target.uid] = (image_target, report) + + def aggregate(self) -> dict: + totals = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0} + results = {"total": totals} + + for image_name, targets in self.items(): + for uid, (target, report) in targets.items(): + variant_name = target.image_variant.name if target.image_variant else "" + os_name = target.image_os.name if target.image_os else "" + version_name = target.image_version.name + + row = { + "critical": report.critical_count, + "high": report.high_count, + "medium": report.medium_count, + "low": report.low_count, + "unknown": report.unknown_count, + } + + results.setdefault(image_name, {}) + results[image_name].setdefault(version_name, {}) + results[image_name][version_name].setdefault(os_name, {}) + results[image_name][version_name][os_name].setdefault(variant_name, {})[uid] = row + + for key in totals: + totals[key] += row[key] + + return results + + def table(self) -> Table: + aggregated = self.aggregate() + total_row = aggregated.pop("total") + + table = Table(title="Trivy Scan Results") + table.add_column("Image Name", justify="left") + table.add_column("Version", justify="left") + table.add_column("Variant", justify="left") + table.add_column("OS", justify="left") + table.add_column("Critical", justify="right", header_style="bright_red") + table.add_column("High", justify="right", header_style="red") + table.add_column("Medium", justify="right", header_style="yellow") + table.add_column("Low", justify="right", header_style="bright_blue") + table.add_column("Unknown", justify="right", header_style="bright_black") + + for image_name, versions in aggregated.items(): + p_image_name = image_name + for version, oses in versions.items(): + p_version = version + for os_name, variants in oses.items(): + p_os = os_name + for variant_name, entries in variants.items(): + for uid, row in entries.items(): + critical_style = "bright_red bold" if row["critical"] > 0 else "bright_black italic" + high_style = "red bold" if row["high"] > 0 else "bright_black italic" + medium_style = "yellow bold" if row["medium"] > 0 else "bright_black italic" + low_style = "bright_blue bold" if row["low"] > 0 else "bright_black italic" + unknown_style = "bright_black" + + table.add_row( + p_image_name, + p_version, + variant_name, + p_os, + Text(str(row["critical"]), style=critical_style), + Text(str(row["high"]), style=high_style), + Text(str(row["medium"]), style=medium_style), + Text(str(row["low"]), style=low_style), + Text(str(row["unknown"]), style=unknown_style), + ) + p_image_name = "" + p_version = "" + p_os = "" + + table.add_section() + table.add_row( + "Total", + "", + "", + "", + str(total_row["critical"]), + str(total_row["high"]), + str(total_row["medium"]), + str(total_row["low"]), + str(total_row["unknown"]), + ) + + return table diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py new file mode 100644 index 000000000..23e82b70f --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py @@ -0,0 +1,118 @@ +import logging +import os +import shutil +import subprocess +from pathlib import Path + +from posit_bakery.error import BakeryToolRuntimeError, BakeryToolRuntimeErrorGroup +from posit_bakery.image.image_target import ImageTarget +from posit_bakery.plugins.builtin.trivy.command import TrivyCommand +from posit_bakery.plugins.builtin.trivy.errors import BakeryTrivyError +from posit_bakery.plugins.builtin.trivy.report import TrivyReport, TrivyReportCollection +from posit_bakery.settings import SETTINGS + +log = logging.getLogger(__name__) + + +class TrivySuite: + def __init__( + self, + context: Path, + image_targets: list[ImageTarget], + *, + scan_platform: str | None = None, + severity: str | None = None, + disabled_scanners: str | None = None, + timeout: str | None = None, + trivy_config: Path | None = None, + ) -> None: + self.context = context + self.results_dir = context / "results" / "trivy" + + self.trivy_commands = [ + TrivyCommand.from_image_target( + target, + results_dir=self.results_dir, + scan_platform=scan_platform, + severity=severity, + disabled_scanners=disabled_scanners, + timeout=timeout, + trivy_config=trivy_config, + ) + for target in image_targets + ] + + def run(self) -> tuple[TrivyReportCollection, BakeryToolRuntimeError | BakeryToolRuntimeErrorGroup | None]: + if self.results_dir.exists(): + shutil.rmtree(self.results_dir) + self.results_dir.mkdir(parents=True) + + report_collection = TrivyReportCollection() + errors = [] + verbose = SETTINGS.log_level == logging.DEBUG + + for trivy_command in self.trivy_commands: + log.info(f"[bright_blue bold]=== Scanning '{str(trivy_command.image_target)}' with Trivy ===") + log.debug(f"[bright_black]Executing trivy command: {' '.join(trivy_command.command)}") + + trivy_command.results_file.parent.mkdir(parents=True, exist_ok=True) + + run_env = os.environ.copy() + + p = subprocess.run( + trivy_command.command, + env=run_env, + cwd=self.context, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE if verbose else subprocess.DEVNULL, + ) + + if verbose: + try: + stderr_text = p.stderr.decode("utf-8").strip() + if stderr_text: + log.debug(f"[bright_black]trivy stderr:\n{stderr_text}") + except UnicodeDecodeError: + pass + + exit_code = p.returncode + + report = None + if exit_code == 0: + if trivy_command.results_file.exists(): + try: + report = TrivyReport.load(trivy_command.results_file, scan_category=trivy_command.scan_category) + report_collection.add_report(trivy_command.image_target, report) + except Exception as e: + log.error(f"Failed to parse trivy results for '{str(trivy_command.image_target)}': {e}") + exit_code = 1 + else: + log.error(f"trivy for '{str(trivy_command.image_target)}' exited 0 but produced no results file") + exit_code = 1 + + # trivy's own --exit-code flag is never set (see TrivyCommand), so any + # non-zero exit here is a true execution failure, never "found vulnerabilities." + if exit_code != 0: + log.error(f"trivy for '{str(trivy_command.image_target)}' exited with code {exit_code}") + errors.append( + BakeryTrivyError( + f"trivy scan failed for '{str(trivy_command.image_target)}'", + "trivy", + cmd=trivy_command.command, + stdout=p.stdout, + stderr=p.stderr if verbose else None, + exit_code=exit_code, + ) + ) + else: + log.info(f"[bright_green bold]Scan completed for '{str(trivy_command.image_target)}'") + + if errors: + if len(errors) == 1: + errors = errors[0] + else: + errors = BakeryToolRuntimeErrorGroup("trivy runtime errors occurred for multiple images.", errors) + else: + errors = None + + return report_collection, errors diff --git a/posit-bakery/pyproject.toml b/posit-bakery/pyproject.toml index 515a6d9a4..321898641 100644 --- a/posit-bakery/pyproject.toml +++ b/posit-bakery/pyproject.toml @@ -60,6 +60,7 @@ dgoss = "posit_bakery.plugins.builtin.dgoss:DGossPlugin" imagetools = "posit_bakery.plugins.builtin.imagetools:ImageToolsPlugin" hadolint = "posit_bakery.plugins.builtin.hadolint:HadolintPlugin" wizcli = "posit_bakery.plugins.builtin.wizcli:WizCLIPlugin" +trivy = "posit_bakery.plugins.builtin.trivy:TrivyPlugin" [build-system] requires = ["hatchling", "uv-dynamic-versioning"] diff --git a/posit-bakery/test/cli/test_ci_matrix_dev_versions.py b/posit-bakery/test/cli/test_ci_matrix_dev_versions.py index bb15e104a..7ca51f13f 100644 --- a/posit-bakery/test/cli/test_ci_matrix_dev_versions.py +++ b/posit-bakery/test/cli/test_ci_matrix_dev_versions.py @@ -22,11 +22,16 @@ BASIC_CONTEXT = str(Path(__file__).parent.parent / "resources" / "basic") -def _make_version(name: str, *, is_dev: bool, channel: ReleaseChannelEnum | None = None): +def _make_version(name: str, *, is_dev: bool, channel: ReleaseChannelEnum | None = None, latest: bool = False): """Return a minimal MagicMock ImageVersion with working matches_dev_filter.""" ver = MagicMock() ver.name = name ver.isDevelopmentVersion = is_dev + ver.latest = latest + # Mirrors ImageVersion.is_latest_release. Set explicitly because an unset + # MagicMock attribute is a MagicMock, which is not JSON serializable and + # would fail the matrix command's json.dumps rather than the assertion. + ver.is_latest_release = latest and not is_dev ver.metadata = {"release_channel": channel} if channel else {} ver.supported_platforms = ["linux/amd64"] diff --git a/posit-bakery/test/cli/test_ci_matrix_latest.py b/posit-bakery/test/cli/test_ci_matrix_latest.py new file mode 100644 index 000000000..ce5b8bba6 --- /dev/null +++ b/posit-bakery/test/cli/test_ci_matrix_latest.py @@ -0,0 +1,119 @@ +"""Tests for the `latest` field emitted by `bakery ci matrix`. + +CI workflows gate per-version scan steps on this field rather than passing `--latest` +to a command already pinned to a single version by the build matrix. That pin plus +`--latest` resolves to an empty target set on every non-latest version, which bakery +reports as an error. + +Because consumers make the "is this the latest version" decision from this field rather +than from the filter, the two must agree. They share ImageVersion.is_latest_release so +they cannot drift; TestCiMatrixLatestMatchesFilter checks that end to end. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from posit_bakery.cli.main import app +from posit_bakery.config.config import BakeryConfig, BakerySettings +from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum +from posit_bakery.const import DevVersionInclusionEnum + +runner = CliRunner() +CHANGESET_CONTEXT = str(Path(__file__).parent.parent / "resources" / "changeset") + + +def _matrix(*args: str) -> list[dict]: + result = runner.invoke( + app, + ["ci", "matrix", "--quiet", "--context", CHANGESET_CONTEXT, *args], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + return json.loads(result.stdout.strip()) + + +class TestCiMatrixLatestField: + def test_marks_latest_version(self): + """2.0.0 carries `latest: true` in the fixture; 1.0.0 does not.""" + entries = {e["version"]: e for e in _matrix() if e["image"] == "app"} + + assert entries["2.0.0"]["latest"] is True + assert entries["1.0.0"]["latest"] is False + + def test_latest_is_json_boolean(self): + """The workflow gates on this in a GHA `if:`, where a non-boolean is truthy.""" + for entry in _matrix(): + assert isinstance(entry["latest"], bool), entry + + def test_exclude_latest_omits_field(self): + for entry in _matrix("--exclude", "latest"): + assert "latest" not in entry + # Excluding one field must not disturb the others. + assert "version" in entry + assert "dev" in entry + + +class TestCiMatrixLatestMatchesFilter: + """The matrix field and the --latest filter must select the same versions. + + The workflow gates on the field while bakery's own filter is what any manual + `--latest` invocation uses. If these drift, the scan silently covers the wrong + set instead of failing, which is the one failure mode the gating approach can + introduce. + """ + + def test_agrees_with_generate_image_targets(self): + from_matrix = {e["version"] for e in _matrix() if e["image"] == "app" and e["latest"]} + + config = BakeryConfig.from_context(CHANGESET_CONTEXT, BakerySettings(latest=True)) + from_filter = {t.image_version.name for t in config.targets if t.image_name == "app"} + + assert from_matrix == from_filter + assert from_matrix == {"2.0.0"} + + +class TestCiMatrixLatestDevVersions: + """A dev version reports `latest: false`. + + The rule itself lives in ImageVersion.is_latest_release and is covered against real + objects in test/config/image/test_version.py. This only checks that the matrix + command plumbs the predicate through to the emitted field. + """ + + @pytest.fixture + def mock_config_with_latest_dev_version(self): + dev_ver = MagicMock() + dev_ver.name = "2026.99.0-dev+1" + dev_ver.isDevelopmentVersion = True + dev_ver.latest = True + # As ImageVersion.is_latest_release would resolve it for a dev version. + dev_ver.is_latest_release = False + dev_ver.metadata = {"release_channel": ReleaseChannelEnum.DAILY} + dev_ver.supported_platforms = ["linux/amd64"] + dev_ver.matches_dev_filter = lambda dev_versions, dev_channel=None: ( + (False, "excluded by --dev-versions exclude") + if dev_versions == DevVersionInclusionEnum.EXCLUDE + else (True, None) + ) + + img = MagicMock() + img.name = "app" + img.matrix = None + img.versions = [dev_ver] + + with patch("posit_bakery.cli.ci.BakeryConfig") as mock: + instance = MagicMock() + instance.model.images = [img] + mock.from_context.return_value = instance + yield mock + + def test_dev_version_is_not_latest(self, mock_config_with_latest_dev_version): + entries = _matrix("--dev-versions", "only") + + assert len(entries) == 1 + assert entries[0]["dev"] is True + assert entries[0]["latest"] is False diff --git a/posit-bakery/test/cli/test_common.py b/posit-bakery/test/cli/test_common.py index 50c7e4a21..8b79fed12 100644 --- a/posit-bakery/test/cli/test_common.py +++ b/posit-bakery/test/cli/test_common.py @@ -6,8 +6,10 @@ __make_value_map as make_value_map, __parse_dependency_constraint as parse_dependency_constraint, __parse_dependency_versions as parse_dependency_versions, + _describe_active_filters, normalize_platform, ) +from posit_bakery.config.config import BakeryConfigFilter, BakerySettings from posit_bakery.config.dependencies import ( PythonDependencyConstraint, RDependencyConstraint, @@ -501,3 +503,39 @@ def test_defaults_to_host_architecture_when_empty_string(self, monkeypatch): """An empty string is falsy, so it also falls back to the host architecture.""" monkeypatch.setattr("posit_bakery.cli.common.SETTINGS.architecture", "amd64") assert normalize_platform("") == "linux/amd64" + + +class TestDescribeActiveFilters: + """The no-targets message must name the filter that actually emptied the selection.""" + + def test_reports_latest(self): + """--latest was omitted entirely because False is falsy, so the one filter + responsible for an empty selection was the one filter never shown.""" + described = _describe_active_filters( + BakerySettings(filter=BakeryConfigFilter(image_version="2026.05.0"), latest=True) + ) + assert described == "--image-version '2026.05.0', --latest" + + def test_omits_latest_when_not_set(self): + described = _describe_active_filters(BakerySettings(filter=BakeryConfigFilter(image_version="2026.05.0"))) + assert described == "--image-version '2026.05.0'" + + def test_renders_platform_list_as_typed(self): + """image_platform is a list; a bare repr leaked "['linux/arm64']" into the message.""" + described = _describe_active_filters(BakerySettings(filter=BakeryConfigFilter(image_platform=["linux/arm64"]))) + assert described == "--image-platform 'linux/arm64'" + + def test_renders_each_platform_separately(self): + described = _describe_active_filters( + BakerySettings(filter=BakeryConfigFilter(image_platform=["linux/amd64", "linux/arm64"])) + ) + assert described == "--image-platform 'linux/amd64', --image-platform 'linux/arm64'" + + def test_omits_default_inclusion_policies(self): + """--dev-versions/--matrix-versions default to 'exclude', so echoing them would + report flags the caller never passed.""" + described = _describe_active_filters(BakerySettings(filter=BakeryConfigFilter(image_name="^app$"))) + assert described == "--image-name '^app$'" + + def test_empty_when_no_filters_set(self): + assert _describe_active_filters(BakerySettings()) == "" diff --git a/posit-bakery/test/cli/testdata/ci/matrix/barebones/default.json b/posit-bakery/test/cli/testdata/ci/matrix/barebones/default.json index 38b2f8d43..769b4ead0 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/barebones/default.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/barebones/default.json @@ -1 +1 @@ -[{"image": "scratch", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "scratch", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/basic/default.json b/posit-bakery/test/cli/testdata/ci/matrix/basic/default.json index bdf6a413d..c9df78914 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/basic/default.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/basic/default.json @@ -1 +1 @@ -[{"image": "test-image", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "test-image", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/basic/image_version_match.json b/posit-bakery/test/cli/testdata/ci/matrix/basic/image_version_match.json index bdf6a413d..c9df78914 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/basic/image_version_match.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/basic/image_version_match.json @@ -1 +1 @@ -[{"image": "test-image", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "test-image", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json b/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json index dbb15a2e7..7825146d1 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json @@ -1 +1 @@ -[{"image": "app", "version": "2.0.0", "dev": false, "platform": "linux/amd64"}, {"image": "app", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "app", "version": "2.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}, {"image": "app", "version": "1.0.0", "dev": false, "latest": false, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json b/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json index 0de825170..094da8766 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json @@ -1 +1 @@ -[{"image": "app", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "app", "version": "1.0.0", "dev": false, "latest": false, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/merge-multi-image/image_name_filter.json b/posit-bakery/test/cli/testdata/ci/matrix/merge-multi-image/image_name_filter.json index 711bb2083..b7f0d7f79 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/merge-multi-image/image_name_filter.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/merge-multi-image/image_name_filter.json @@ -1 +1 @@ -[{"image": "test-alpha", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] +[{"image": "test-alpha", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/default.json b/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/default.json index af5fd7149..19e2ac4d3 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/default.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/default.json @@ -1 +1 @@ -[{"image": "test-multi", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}, {"image": "test-multi", "version": "1.0.0", "dev": false, "platform": "linux/arm64"}] +[{"image": "test-multi", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/amd64"}, {"image": "test-multi", "version": "1.0.0", "dev": false, "latest": true, "platform": "linux/arm64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/exclude_platform.json b/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/exclude_platform.json index 85f47fe46..fe82b8eb6 100644 --- a/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/exclude_platform.json +++ b/posit-bakery/test/cli/testdata/ci/matrix/multiplatform/exclude_platform.json @@ -1 +1 @@ -[{"image": "test-multi", "version": "1.0.0", "dev": false}] +[{"image": "test-multi", "version": "1.0.0", "dev": false, "latest": true}] diff --git a/posit-bakery/test/config/image/test_version.py b/posit-bakery/test/config/image/test_version.py index 00c7dd26f..ca59252a1 100644 --- a/posit-bakery/test/config/image/test_version.py +++ b/posit-bakery/test/config/image/test_version.py @@ -773,3 +773,62 @@ def test_parsed_version_unparseable_returns_none_with_warning(self, caplog): assert v.parsed_version is None warnings = [r for r in caplog.records if "Unparseable version string" in r.message] assert len(warnings) == 1 + + +class TestIsLatestRelease: + """The predicate shared by the --latest filter and `bakery ci matrix`'s latest field. + + Uses real ImageVersion objects rather than mocks: the whole point of sharing the + predicate is that nobody restates it, and a mocked property would restate it. + """ + + @pytest.mark.parametrize( + "latest, is_dev, expected", + [ + (True, False, True), + (False, False, False), + # A dev version is never the latest release, even carrying the flag. + (True, True, False), + (False, True, False), + ], + ) + def test_predicate(self, latest, is_dev, expected): + v = ImageVersion(name="1.0.0", latest=latest, isDevelopmentVersion=is_dev) + assert v.is_latest_release is expected + + def test_not_serialized_into_bakery_yaml(self): + """A plain property, not a computed_field: model_dump round-trips back into + bakery.yaml, and a derived key must not leak into the user's config.""" + v = ImageVersion(name="1.0.0", latest=True) + assert "is_latest_release" not in v.model_dump() + + +class TestMatchesLatestFilter: + def test_inactive_filter_includes_everything(self): + """--latest unset is a no-op, including for versions that are not latest.""" + v = ImageVersion(name="1.0.0", latest=False) + assert v.matches_latest_filter(False) == (True, None) + + def test_includes_latest_release(self): + v = ImageVersion(name="1.0.0", latest=True) + assert v.matches_latest_filter(True) == (True, None) + + def test_excludes_non_latest_with_reason(self): + v = ImageVersion(name="1.0.0", latest=False) + included, reason = v.matches_latest_filter(True) + assert included is False + assert reason == "not the latest version (excluded by --latest)" + + def test_excludes_dev_version_with_distinct_reason(self): + """Dev exclusion is reported separately so the log says which rule applied.""" + v = ImageVersion(name="1.0.0-dev+1", latest=True, isDevelopmentVersion=True) + included, reason = v.matches_latest_filter(True) + assert included is False + assert reason == "development version ignored by --latest" + + def test_agrees_with_is_latest_release(self): + """The method and the property must never disagree.""" + for latest in (True, False): + for is_dev in (True, False): + v = ImageVersion(name="1.0.0", latest=latest, isDevelopmentVersion=is_dev) + assert v.matches_latest_filter(True)[0] is v.is_latest_release diff --git a/posit-bakery/test/plugins/builtin/trivy/__init__.py b/posit-bakery/test/plugins/builtin/trivy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/posit-bakery/test/plugins/builtin/trivy/conftest.py b/posit-bakery/test/plugins/builtin/trivy/conftest.py new file mode 100644 index 000000000..0b32b009c --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/conftest.py @@ -0,0 +1,31 @@ +from unittest.mock import patch + +import pytest + +from posit_bakery.image import ImageTarget + + +@pytest.fixture(autouse=True) +def mock_find_trivy_bin(): + """Mock find_trivy_bin to return 'trivy' by default for test isolation.""" + with patch("posit_bakery.plugins.builtin.trivy.command.find_trivy_bin") as mock: + mock.return_value = "trivy" + yield mock + + +@pytest.fixture +def basic_standard_image_target(get_config_obj): + """Return a standard ImageTarget object for testing.""" + basic_config_obj = get_config_obj("basic") + + image = basic_config_obj.model.get_image("test-image") + version = image.get_version("1.0.0") + variant = image.get_variant("Standard") + os = version.os[0] + + return ImageTarget.new_image_target( + repository=basic_config_obj.model.repository, + image_version=version, + image_variant=variant, + image_os=os, + ) diff --git a/posit-bakery/test/plugins/builtin/trivy/test_command.py b/posit-bakery/test/plugins/builtin/trivy/test_command.py new file mode 100644 index 000000000..5d66da3c7 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_command.py @@ -0,0 +1,279 @@ +import re +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from posit_bakery.plugins.builtin.trivy.command import TrivyCommand +from posit_bakery.settings import SETTINGS + +pytestmark = [ + pytest.mark.unit, + pytest.mark.trivy, +] + + +class TestTrivyCommand: + def test_from_image_target_basic(self, basic_standard_image_target): + """Test basic initialization from an image target.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + assert cmd.image_target == basic_standard_image_target + command_str = " ".join(cmd.command) + assert "image" in cmd.command + assert "--format" in cmd.command + assert "sarif" in cmd.command + assert "--output" in cmd.command + assert str(cmd.results_file) in command_str + assert "--quiet" in cmd.command + + def test_command_never_sets_exit_code_flag(self, basic_standard_image_target): + """Trivy's own --exit-code flag must never be passed (see Global Constraints).""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + assert "--exit-code" not in cmd.command + + def test_command_with_cli_severity_and_timeout(self, basic_standard_image_target): + """Test that CLI severity/timeout options are passed through.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + severity="HIGH,CRITICAL", + timeout="10m", + ) + assert "--severity" in cmd.command + assert "HIGH,CRITICAL" in cmd.command + assert "--timeout" in cmd.command + assert "10m" in cmd.command + + def test_command_disabled_scanners_computes_complement(self, basic_standard_image_target): + """--disabled-scanners is translated into the enabled complement passed as --scanners.""" + from posit_bakery.plugins.builtin.trivy.command import TRIVY_DEFAULT_SCANNERS + + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + disabled_scanners="secret,license", + ) + idx = cmd.command.index("--scanners") + enabled = cmd.command[idx + 1].split(",") + assert "secret" not in enabled + assert "license" not in enabled + for scanner in TRIVY_DEFAULT_SCANNERS: + if scanner not in ("secret", "license"): + assert scanner in enabled + + def test_command_disabled_scanners_never_enables_non_default_scanners(self, basic_standard_image_target): + """Disabling a default-on scanner must never turn on license/misconfig (trivy defaults to vuln,secret only).""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + disabled_scanners="secret", + ) + idx = cmd.command.index("--scanners") + assert cmd.command[idx + 1] == "vuln" + + def test_command_with_tool_options(self, basic_standard_image_target): + """Test that ToolOptions fields are included in the command when no CLI value is given.""" + from posit_bakery.plugins.builtin.trivy.options import TrivyOptions + + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + tool_options=TrivyOptions(severity=["HIGH", "CRITICAL"], timeout="5m"), + ) + command_str = " ".join(cmd.command) + assert "--severity" in command_str + assert "HIGH,CRITICAL" in command_str + assert "--timeout" in command_str + assert "5m" in command_str + + def test_cli_severity_wins_over_tool_options(self, basic_standard_image_target): + """An explicit CLI value takes precedence over the bakery.yaml TrivyOptions value.""" + from posit_bakery.plugins.builtin.trivy.options import TrivyOptions + + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + severity="LOW", + tool_options=TrivyOptions(severity=["HIGH", "CRITICAL"]), + ) + idx = cmd.command.index("--severity") + assert cmd.command[idx + 1] == "LOW" + + def test_command_with_tool_options_disabled_scanners(self, basic_standard_image_target): + """Test that disabledScanners from ToolOptions are included in the command.""" + from posit_bakery.plugins.builtin.trivy.options import TrivyOptions + from posit_bakery.plugins.builtin.trivy.command import TRIVY_DEFAULT_SCANNERS + + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + tool_options=TrivyOptions(disabledScanners=["secret", "license"]), + ) + idx = cmd.command.index("--scanners") + enabled = cmd.command[idx + 1].split(",") + assert "secret" not in enabled + assert "license" not in enabled + for scanner in TRIVY_DEFAULT_SCANNERS: + if scanner not in ("secret", "license"): + assert scanner in enabled + + def test_cli_disabled_scanners_wins_over_tool_options(self, basic_standard_image_target): + """CLI disabled_scanners takes precedence over tool_options value.""" + from posit_bakery.plugins.builtin.trivy.options import TrivyOptions + from posit_bakery.plugins.builtin.trivy.command import TRIVY_DEFAULT_SCANNERS + + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + disabled_scanners="secret", + tool_options=TrivyOptions(disabledScanners=["vuln"]), + ) + idx = cmd.command.index("--scanners") + enabled = cmd.command[idx + 1].split(",") + assert "secret" not in enabled + # tool_options also asked to disable "vuln", but the CLI value fully + # replaces (not merges with) tool_options, so vuln must stay enabled. + assert "vuln" in enabled + for scanner in TRIVY_DEFAULT_SCANNERS: + if scanner != "secret": + assert scanner in enabled + + def test_command_with_native_config(self, basic_standard_image_target, tmp_path): + """Test that an explicit --trivy-config path is passed through via --config.""" + config_path = tmp_path / "trivy.yaml" + config_path.write_text("severity:\n - CRITICAL\n") + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + trivy_config=config_path, + ) + assert "--config" in cmd.command + assert str(config_path) in cmd.command + + def test_discover_trivy_config_finds_conventional_path(self, basic_standard_image_target, tmp_path): + """discover_trivy_config finds //trivy.yaml when present.""" + from posit_bakery.plugins.builtin.trivy.command import discover_trivy_config + + image_dir = basic_standard_image_target.context.base_path / basic_standard_image_target.image_name + image_dir.mkdir(parents=True, exist_ok=True) + config_path = image_dir / "trivy.yaml" + config_path.write_text("severity:\n - CRITICAL\n") + + found = discover_trivy_config(basic_standard_image_target) + assert found == config_path + + config_path.unlink() + + def test_discover_trivy_config_returns_none_when_absent(self, basic_standard_image_target): + from posit_bakery.plugins.builtin.trivy.command import discover_trivy_config + + image_dir = basic_standard_image_target.context.base_path / basic_standard_image_target.image_name + conventional = image_dir / "trivy.yaml" + if conventional.exists(): + pytest.skip("fixture project unexpectedly has a trivy.yaml already") + + assert discover_trivy_config(basic_standard_image_target) is None + + def test_results_file_is_uid_scoped(self, basic_standard_image_target): + """results_file stem must be the target uid so no two targets share a file.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + assert cmd.results_file.stem == basic_standard_image_target.uid + assert cmd.results_file.suffix == ".sarif" + + def test_scan_category_is_version_stable(self, basic_standard_image_target): + """scan_category must omit the image version so it stays stable across releases.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + assert basic_standard_image_target.image_version.name not in cmd.scan_category + + def test_scan_category_includes_image_variant_os(self, basic_standard_image_target): + """scan_category includes image name plus the Variant and OS tag display names.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + tv = basic_standard_image_target.tag_template_values + sanitized = lambda s: re.sub(r"[ .+/]", "-", s).lower() # noqa: E731 + assert sanitized(basic_standard_image_target.image_name) in cmd.scan_category + if tv["Variant"]: + assert sanitized(tv["Variant"]) in cmd.scan_category + if tv["OS"]: + assert sanitized(tv["OS"]) in cmd.scan_category + + def test_scan_category_uses_scanned_platform_not_host_arch(self, basic_standard_image_target): + """The category's arch must come from the scanned platform, not the host. + + A cross-arch scan (e.g. --image-platform linux/arm64 on an amd64 host) would + otherwise label arm64 results 'amd64' and collide both arches into one + code-scanning category, silently overwriting each other. + """ + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + scan_platform="linux/arm64", + ) + assert cmd.scan_category.endswith("-arm64") + assert "amd64" not in cmd.scan_category + + def test_scan_category_defaults_to_host_arch(self, basic_standard_image_target): + """With no explicit platform, the category falls back to the host architecture.""" + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + cmd = TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) + assert cmd.scan_category.endswith(f"-{SETTINGS.architecture}") + + def test_category_is_shared_across_versions_but_files_are_not(self, get_config_obj): + """The 'changeset' fixture holds two versions of one image/variant/OS. + + Those two targets must share a single code-scanning category (that shared + key is what makes PR-vs-baseline diffing work) while still writing to + separate SARIF files. Deriving the filename from the category collapses + them onto one path, so the later scan silently overwrites the earlier. + """ + config_obj = get_config_obj("changeset") + results_dir = config_obj.base_path / "results" / "trivy" + cmds = [ + TrivyCommand.from_image_target(image_target=target, results_dir=results_dir) + for target in config_obj.targets + ] + assert len(cmds) > 1 + assert len(set(c.scan_category for c in cmds)) == 1 + assert len(set(c.results_file for c in cmds)) == len(cmds) + + def test_validate_no_trivy_bin(self, basic_standard_image_target): + """Test that validation fails if trivy binary cannot be found.""" + with patch("posit_bakery.plugins.builtin.trivy.command.find_trivy_bin") as mock: + mock.return_value = None + with pytest.raises(ValidationError, match="trivy binary path must be specified"): + results_dir = basic_standard_image_target.context.base_path / "results" / "trivy" + TrivyCommand.from_image_target( + image_target=basic_standard_image_target, + results_dir=results_dir, + ) diff --git a/posit-bakery/test/plugins/builtin/trivy/test_init.py b/posit-bakery/test/plugins/builtin/trivy/test_init.py new file mode 100644 index 000000000..4f363bfb5 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_init.py @@ -0,0 +1,352 @@ +"""Unit tests for the `bakery trivy scan` CLI command. + +Guards the `--latest` filter pass-through and the zero-target guard. Mocks +BakeryConfig and the plugin's execute/results so the CLI can run end-to-end +without trivy installed or any built images. +""" + +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from typer.testing import CliRunner + +from posit_bakery.cli.main import app +from posit_bakery.config.image.dev_version.spec import DevBuildSpec +from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum +from posit_bakery.plugins.builtin.trivy import TrivyPlugin +from posit_bakery.plugins.builtin.trivy.errors import BakeryTrivyError +from posit_bakery.plugins.builtin.trivy.options import TrivyOptions +from posit_bakery.plugins.builtin.trivy.report import TrivyReport, TrivyReportCollection +from posit_bakery.plugins.protocol import ToolCallResult + +pytestmark = [ + pytest.mark.unit, + pytest.mark.trivy, +] + +runner = CliRunner() + +BASIC_CONTEXT = str(Path(__file__).parent.parent.parent.parent / "resources" / "basic") + + +@pytest.fixture +def mocked_trivy_scan(): + """Mock BakeryConfig and TrivyPlugin.execute/results so the CLI can run + end-to-end without needing trivy or built images.""" + with patch("posit_bakery.plugins.builtin.trivy.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [MagicMock()] + mock_config.from_context.return_value = instance + with ( + patch("posit_bakery.plugins.builtin.trivy.TrivyPlugin.execute") as mock_execute, + patch("posit_bakery.plugins.builtin.trivy.TrivyPlugin.results"), + ): + mock_execute.return_value = [] + yield mock_config, mock_execute + + +class TestTrivyScanZeroMatchGuard: + """A filter that matches no targets must fail loudly, not silently pass.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.plugins.builtin.trivy.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [] + mock_config.from_context.return_value = instance + with patch("posit_bakery.plugins.builtin.trivy.TrivyPlugin.execute") as mock_execute: + result = runner.invoke( + app, + ["trivy", "scan", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + mock_execute.assert_not_called() + + +class TestTrivyScanLatestFlag: + """The --latest flag is passed through to settings.""" + + def test_latest_passed_to_settings(self, mocked_trivy_scan): + mock_config, _ = mocked_trivy_scan + result = runner.invoke( + app, + ["trivy", "scan", "--latest", "--context", BASIC_CONTEXT], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert settings.latest is True + + def test_latest_default_false(self, mocked_trivy_scan): + mock_config, _ = mocked_trivy_scan + result = runner.invoke( + app, + ["trivy", "scan", "--context", BASIC_CONTEXT], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert settings.latest is False + + +class TestTrivyScanImagePlatformFlag: + """Regression coverage: `--image-platform linux/amd64` must not become + `linux/linux/amd64`. + + The shared GitHub Actions workflows pass platform values straight through + from `bakery ci matrix` output (e.g. `linux/amd64`), and a double-prefixed + value would match zero image targets. Mirrors dgoss's equivalent guard + (test/plugins/builtin/dgoss/test_init.py::TestDgossRunPlatformNormalization). + """ + + @pytest.mark.parametrize( + "given,expected", + [ + ("amd64", "linux/amd64"), + ("arm64", "linux/arm64"), + ("linux/amd64", "linux/amd64"), + ("linux/arm64", "linux/arm64"), + ], + ) + def test_normalizes_platform(self, mocked_trivy_scan, given, expected): + mock_config, _ = mocked_trivy_scan + result = runner.invoke( + app, + ["trivy", "scan", "--context", BASIC_CONTEXT, "--image-platform", given], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert settings.filter.image_platform == [expected] + + +class TestTrivyScanDevSpecFlag: + """The --dev-spec flag is parsed by parse_dev_spec and passed through to settings. + + Kept for parity with `build` and `dgoss run`, so a dev-spec-pinned target can be + resolved when scanning by hand. No shared workflow passes it: the native Scan step + only runs for `latest` versions, which dev versions never are. + """ + + def test_dev_spec_passed_to_settings(self, mocked_trivy_scan): + mock_config, _ = mocked_trivy_scan + result = runner.invoke( + app, + [ + "trivy", + "scan", + "--context", + BASIC_CONTEXT, + "--dev-spec", + '{"version": "2026.05.0-dev+185-gSHA", "channel": "daily"}', + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert isinstance(settings.dev_spec, DevBuildSpec) + assert settings.dev_spec.version == "2026.05.0-dev+185-gSHA" + assert settings.dev_spec.channel == ReleaseChannelEnum.DAILY + + def test_dev_spec_default_none(self, mocked_trivy_scan): + mock_config, _ = mocked_trivy_scan + result = runner.invoke( + app, + ["trivy", "scan", "--context", BASIC_CONTEXT], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert settings.dev_spec is None + + def test_invalid_dev_spec_json_errors(self): + result = runner.invoke( + app, + ["trivy", "scan", "--context", BASIC_CONTEXT, "--dev-spec", "not-json"], + ) + assert result.exit_code != 0 + + +class TestTrivyScanFlagPassthrough: + def test_severity_and_fail_on_severity_passed_to_execute(self, mocked_trivy_scan): + _, mock_execute = mocked_trivy_scan + result = runner.invoke( + app, + [ + "trivy", + "scan", + "--context", + BASIC_CONTEXT, + "--severity", + "HIGH,CRITICAL", + "--fail-on-severity", + "CRITICAL", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + _, kwargs = mock_execute.call_args + assert kwargs["severity"] == "HIGH,CRITICAL" + assert kwargs["fail_on_severity"] == "CRITICAL" + + def test_no_authentication_panel(self): + """Guards the design decision: Trivy has no Authentication help panel.""" + result = runner.invoke(app, ["trivy", "scan", "--help"], catch_exceptions=False) + assert result.exit_code == 0 + assert "Authentication" not in result.output + + +class TestTrivyPluginExecute: + """Direct unit tests for TrivyPlugin.execute(), bypassing the CLI/typer layer entirely. + + Mocks only TrivySuite, patched where __init__.py imports it + (posit_bakery.plugins.builtin.trivy.TrivySuite), so the real fail-on-severity + resolution/breach logic in execute() runs against real ImageTarget/TrivyReport/ + TrivyReportCollection/TrivyOptions instances. + """ + + @staticmethod + @contextmanager + def _mocked_suite(report_collection, errors, trivy_commands=None): + """Patch TrivySuite (where __init__.py imports it) to return a fixed run() result.""" + with patch("posit_bakery.plugins.builtin.trivy.TrivySuite") as mock_suite_cls: + mock_instance = MagicMock() + mock_instance.run.return_value = (report_collection, errors) + mock_instance.trivy_commands = trivy_commands or [] + mock_suite_cls.return_value = mock_instance + yield + + def test_clean_scan_no_fail_on_severity_exits_zero(self, basic_standard_image_target): + """No --fail-on-severity and no errors always exits 0, even with findings.""" + target = basic_standard_image_target + report = TrivyReport(critical_count=1) + report_collection = TrivyReportCollection() + report_collection.add_report(target, report) + + with self._mocked_suite(report_collection, None): + results = TrivyPlugin().execute(Path("/tmp"), [target], fail_on_severity=None) + + assert len(results) == 1 + result = results[0] + assert result.exit_code == 0 + assert result.artifacts["report"] is report + assert "severity_breach" not in result.artifacts + + def test_cli_fail_on_severity_breach_exits_one(self, basic_standard_image_target): + """A CLI --fail-on-severity value matching a finding severity breaches.""" + target = basic_standard_image_target + report = TrivyReport(critical_count=1) + report_collection = TrivyReportCollection() + report_collection.add_report(target, report) + + with self._mocked_suite(report_collection, None): + results = TrivyPlugin().execute(Path("/tmp"), [target], fail_on_severity="CRITICAL") + + result = results[0] + assert result.exit_code == 1 + assert result.artifacts["severity_breach"] is True + + def test_falls_back_to_target_tool_options_fail_on_severity(self, basic_standard_image_target): + """No CLI --fail-on-severity falls back to the target's resolved TrivyOptions.""" + target = basic_standard_image_target + report = TrivyReport(critical_count=1) + report_collection = TrivyReportCollection() + report_collection.add_report(target, report) + + mock_cmd = MagicMock() + mock_cmd.image_target = target + mock_cmd.tool_options = TrivyOptions(failOnSeverity=["CRITICAL"]) + + with self._mocked_suite(report_collection, None, trivy_commands=[mock_cmd]): + results = TrivyPlugin().execute(Path("/tmp"), [target], fail_on_severity=None) + + result = results[0] + assert result.exit_code == 1 + assert result.artifacts["severity_breach"] is True + + def test_cli_fail_on_severity_wins_over_tool_options(self, basic_standard_image_target): + """A CLI --fail-on-severity value fully replaces (not merges with) TrivyOptions.""" + target = basic_standard_image_target + report = TrivyReport(low_count=1, critical_count=0) + report_collection = TrivyReportCollection() + report_collection.add_report(target, report) + + mock_cmd = MagicMock() + mock_cmd.image_target = target + mock_cmd.tool_options = TrivyOptions(failOnSeverity=["LOW"]) + + with self._mocked_suite(report_collection, None, trivy_commands=[mock_cmd]): + results = TrivyPlugin().execute(Path("/tmp"), [target], fail_on_severity="CRITICAL") + + result = results[0] + assert result.exit_code == 0 + assert "severity_breach" not in result.artifacts + + def test_execution_error_for_target_exits_one(self, basic_standard_image_target): + """A per-target execution error (matched by str(target) substring) exits non-zero.""" + target = basic_standard_image_target + error = BakeryTrivyError( + f"trivy scan failed for '{str(target)}'", + "trivy", + cmd=["trivy", "image", str(target)], + exit_code=1, + ) + + with self._mocked_suite(TrivyReportCollection(), error): + results = TrivyPlugin().execute(Path("/tmp"), [target]) + + result = results[0] + assert result.exit_code == 1 + assert result.artifacts["execution_error"] is error + assert "report" not in result.artifacts + + +class TestTrivyPluginResults: + """Direct unit tests for TrivyPlugin.results().""" + + def test_clean_results_does_not_raise(self, basic_standard_image_target): + result = ToolCallResult( + exit_code=0, + tool_name="trivy", + target=basic_standard_image_target, + stdout="", + stderr="", + artifacts={"report": TrivyReport(critical_count=0)}, + ) + + TrivyPlugin().results([result]) # must not raise + + def test_severity_breach_raises_exit(self, basic_standard_image_target): + result = ToolCallResult( + exit_code=1, + tool_name="trivy", + target=basic_standard_image_target, + stdout="", + stderr="", + artifacts={"report": TrivyReport(critical_count=1), "severity_breach": True}, + ) + + with pytest.raises(typer.Exit) as exc_info: + TrivyPlugin().results([result]) + assert exc_info.value.exit_code == 1 + + def test_execution_error_raises_exit(self, basic_standard_image_target): + error = BakeryTrivyError("trivy scan failed", "trivy", cmd=["trivy"], exit_code=1) + result = ToolCallResult( + exit_code=1, + tool_name="trivy", + target=basic_standard_image_target, + stdout="", + stderr="", + artifacts={"execution_error": error}, + ) + + with pytest.raises(typer.Exit) as exc_info: + TrivyPlugin().results([result]) + assert exc_info.value.exit_code == 1 diff --git a/posit-bakery/test/plugins/builtin/trivy/test_options.py b/posit-bakery/test/plugins/builtin/trivy/test_options.py new file mode 100644 index 000000000..aabed2c0e --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_options.py @@ -0,0 +1,69 @@ +import pytest +from _pytest.mark import ParameterSet + +from posit_bakery.plugins.builtin.trivy.options import TrivyOptions + +pytestmark = [ + pytest.mark.unit, + pytest.mark.trivy, +] + + +class TestTrivyOptions: + def test_defaults(self): + opts = TrivyOptions() + assert opts.tool == "trivy" + assert opts.severity is None + assert opts.failOnSeverity is None + assert opts.disabledScanners is None + assert opts.timeout is None + + def test_explicit_values(self): + opts = TrivyOptions( + severity=["HIGH", "CRITICAL"], + failOnSeverity=["CRITICAL"], + disabledScanners=["secret"], + timeout="10m", + ) + assert opts.severity == ["HIGH", "CRITICAL"] + assert opts.failOnSeverity == ["CRITICAL"] + assert opts.disabledScanners == ["secret"] + assert opts.timeout == "10m" + + @staticmethod + def merge_params() -> list[ParameterSet]: + return [ + pytest.param( + {}, + {}, + {"severity": None, "failOnSeverity": None, "disabledScanners": None, "timeout": None}, + id="both_default", + ), + pytest.param( + {}, + {"severity": ["HIGH"], "timeout": "5m"}, + {"severity": ["HIGH"], "failOnSeverity": None, "disabledScanners": None, "timeout": "5m"}, + id="left_default_right_set", + ), + pytest.param( + {"severity": ["CRITICAL"], "disabledScanners": ["secret"]}, + {}, + {"severity": ["CRITICAL"], "failOnSeverity": None, "disabledScanners": ["secret"], "timeout": None}, + id="left_set_right_default", + ), + pytest.param( + {"severity": ["HIGH"], "timeout": "5m"}, + {"severity": ["CRITICAL"], "timeout": "10m", "failOnSeverity": ["CRITICAL"]}, + {"severity": ["HIGH"], "failOnSeverity": ["CRITICAL"], "disabledScanners": None, "timeout": "5m"}, + id="left_wins_when_set", + ), + ] + + @pytest.mark.parametrize("left,right,expected", merge_params()) + def test_update(self, left, right, expected): + left_options = TrivyOptions(**left) + right_options = TrivyOptions(**right) + merged = left_options.update(right_options) + + for key, value in expected.items(): + assert getattr(merged, key) == value, f"Expected {key} to be {value}, got {getattr(merged, key)}" diff --git a/posit-bakery/test/plugins/builtin/trivy/test_report.py b/posit-bakery/test/plugins/builtin/trivy/test_report.py new file mode 100644 index 000000000..6c7ffade6 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_report.py @@ -0,0 +1,179 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from posit_bakery.plugins.builtin.trivy.report import TrivyReport, TrivyReportCollection + +pytestmark = [ + pytest.mark.unit, + pytest.mark.trivy, +] + +TRIVY_TESTDATA_DIR = (Path(__file__).parent / "testdata").absolute() + + +class TestTrivyReport: + def test_load_from_file(self): + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + assert report.critical_count == 1 + assert report.high_count == 2 + assert report.medium_count == 1 + assert report.low_count == 0 + assert report.unknown_count == 0 + + def test_total_vulnerability_count(self): + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + assert report.total_count == 4 + + def test_empty_results(self, tmp_path): + """A SARIF file with no results should have zero counts.""" + data = json.loads((TRIVY_TESTDATA_DIR / "scan_result.sarif").read_text()) + data["runs"][0]["results"] = [] + result_file = tmp_path / "empty.sarif" + result_file.write_text(json.dumps(data)) + report = TrivyReport.load(result_file) + assert report.total_count == 0 + + def test_unknown_rule_id_counts_as_unknown(self, tmp_path): + """A result referencing a ruleId with no matching rule counts as UNKNOWN.""" + data = json.loads((TRIVY_TESTDATA_DIR / "scan_result.sarif").read_text()) + data["runs"][0]["results"].append( + { + "ruleId": "CVE-NOT-IN-RULES", + "ruleIndex": 99, + "level": "note", + "message": {"text": "orphaned result"}, + "locations": [], + } + ) + result_file = tmp_path / "orphan.sarif" + result_file.write_text(json.dumps(data)) + report = TrivyReport.load(result_file) + assert report.unknown_count == 1 + + def test_load_stamps_automation_details_id(self, tmp_path): + """scan_category is written to every run's automationDetails.id with a trailing slash. + + Code scanning reads everything before the last slash as the category; the + trailing slash leaves the run ID empty. This is how a single directory-wide + upload-sarif invocation gives each file its own category. + """ + result_file = tmp_path / "stamped.sarif" + result_file.write_text((TRIVY_TESTDATA_DIR / "scan_result.sarif").read_text()) + + TrivyReport.load(result_file, scan_category="connect-min-ubuntu-22-04-amd64") + + data = json.loads(result_file.read_text()) + assert data["runs"] + for run in data["runs"]: + assert run["automationDetails"]["id"] == "connect-min-ubuntu-22-04-amd64/" + + def test_load_without_category_leaves_automation_details_absent(self, tmp_path): + """No scan_category means no automationDetails, so upload-sarif can fill it in.""" + result_file = tmp_path / "unstamped.sarif" + result_file.write_text((TRIVY_TESTDATA_DIR / "scan_result.sarif").read_text()) + + TrivyReport.load(result_file) + + data = json.loads(result_file.read_text()) + assert all("automationDetails" not in run for run in data["runs"]) + + def test_load_stamping_preserves_counts(self, tmp_path): + """Stamping must not disturb the parsed severity counts.""" + result_file = tmp_path / "stamped.sarif" + result_file.write_text((TRIVY_TESTDATA_DIR / "scan_result.sarif").read_text()) + + report = TrivyReport.load(result_file, scan_category="some-category") + + assert report.critical_count == 1 + assert report.high_count == 2 + assert report.medium_count == 1 + assert report.total_count == 4 + + @pytest.mark.parametrize( + "severities,expected", + [ + (["CRITICAL"], True), + (["LOW"], False), + (["LOW", "MEDIUM"], True), + (["critical"], True), # case-insensitive + ], + ) + def test_breaches(self, severities, expected): + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + assert report.breaches(severities) is expected + + +class TestTrivyReportCollection: + def _make_mock_target(self, image_name, uid, version="1.0.0", variant=None, os_name=None): + target = MagicMock() + target.image_name = image_name + target.uid = uid + target.image_version.name = version + target.image_variant = None + target.image_os = None + if variant: + target.image_variant = MagicMock() + target.image_variant.name = variant + if os_name: + target.image_os = MagicMock() + target.image_os.name = os_name + return target + + def test_add_report(self): + collection = TrivyReportCollection() + target = self._make_mock_target("connect", "connect-1.0.0-std-ubuntu2204") + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + collection.add_report(target, report) + + assert "connect" in collection + assert "connect-1.0.0-std-ubuntu2204" in collection["connect"] + + def test_aggregate(self): + collection = TrivyReportCollection() + target = self._make_mock_target("connect", "connect-1.0.0", "1.0.0", "Standard", "Ubuntu 22.04") + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + collection.add_report(target, report) + + agg = collection.aggregate() + assert agg["total"]["critical"] == 1 + assert agg["total"]["high"] == 2 + assert agg["total"]["medium"] == 1 + assert agg["total"]["low"] == 0 + assert agg["total"]["unknown"] == 0 + + def test_table_returns_rich_table(self): + collection = TrivyReportCollection() + target = self._make_mock_target("connect", "connect-1.0.0", "1.0.0", "Standard", "Ubuntu 22.04") + report = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + collection.add_report(target, report) + + table = collection.table() + assert table.title == "Trivy Scan Results" + # Image, Version, Variant, OS, Critical, High, Medium, Low, Unknown + assert len(table.columns) == 9 + + def test_aggregate_disambiguates_same_name_tuple_by_uid(self): + """Two targets sharing image/version/os/variant but different uids + (e.g. a dev vs. release channel build) must both appear, not collide.""" + collection = TrivyReportCollection() + target_a = self._make_mock_target( + "connect", "connect-1.0.0-std-ubuntu2204-release", "1.0.0", "Standard", "Ubuntu 22.04" + ) + target_b = self._make_mock_target( + "connect", "connect-1.0.0-std-ubuntu2204-dev", "1.0.0", "Standard", "Ubuntu 22.04" + ) + report_a = TrivyReport.load(TRIVY_TESTDATA_DIR / "scan_result.sarif") + report_b = TrivyReport(critical_count=0, high_count=0, medium_count=0, low_count=1, unknown_count=0) + collection.add_report(target_a, report_a) + collection.add_report(target_b, report_b) + + agg = collection.aggregate() + leaf = agg["connect"]["1.0.0"]["Ubuntu 22.04"]["Standard"] + assert len(leaf) == 2 + assert leaf["connect-1.0.0-std-ubuntu2204-release"]["critical"] == 1 + assert leaf["connect-1.0.0-std-ubuntu2204-dev"]["low"] == 1 + assert agg["total"]["critical"] == 1 + assert agg["total"]["low"] == 1 diff --git a/posit-bakery/test/plugins/builtin/trivy/test_suite.py b/posit-bakery/test/plugins/builtin/trivy/test_suite.py new file mode 100644 index 000000000..adeaa3dd5 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_suite.py @@ -0,0 +1,208 @@ +import json +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from posit_bakery.image import ImageTarget +from posit_bakery.plugins.builtin.trivy.suite import TrivySuite + +pytestmark = [ + pytest.mark.unit, + pytest.mark.trivy, +] + +TRIVY_TESTDATA_DIR = (Path(__file__).parent / "testdata").absolute() + + +class TestTrivySuite: + def test_init(self, get_config_obj): + """Test that TrivySuite initializes with the correct attributes.""" + basic_config_obj = get_config_obj("basic") + suite = TrivySuite(basic_config_obj.base_path, basic_config_obj.targets) + assert suite.context == basic_config_obj.base_path + assert len(suite.trivy_commands) == len(basic_config_obj.targets) + + def test_run_creates_results_directory(self, get_tmpconfig): + """Test that run creates the results/trivy/ directory.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + sarif_bytes = (TRIVY_TESTDATA_DIR / "scan_result.sarif").read_bytes() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = b"" + mock_result.stderr = b"" + + def fake_run(cmd, **kwargs): + output_path = Path(cmd[cmd.index("--output") + 1]) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(sarif_bytes) + return mock_result + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", side_effect=fake_run): + suite.run() + + results_dir = basic_tmpconfig.base_path / "results" / "trivy" + assert results_dir.exists() + + def test_run_parses_sarif_results(self, get_tmpconfig): + """Test that run parses SARIF results for each target.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + sarif_bytes = (TRIVY_TESTDATA_DIR / "scan_result.sarif").read_bytes() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = b"" + mock_result.stderr = b"" + + def fake_run(cmd, **kwargs): + output_path = Path(cmd[cmd.index("--output") + 1]) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(sarif_bytes) + return mock_result + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", side_effect=fake_run): + report_collection, errors = suite.run() + + assert errors is None + for target in basic_tmpconfig.targets: + assert target.image_name in report_collection + assert target.uid in report_collection[target.image_name] + _, report = report_collection[target.image_name][target.uid] + assert report.critical_count == 1 + assert report.total_count == 4 + + def test_run_handles_execution_error(self, get_tmpconfig): + """A non-zero trivy exit code is always a true execution error (no policy-violation exit code exists).""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = b"FATAL: unable to pull image" + mock_result.stderr = b"" + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", return_value=mock_result): + report_collection, errors = suite.run() + + assert errors is not None + + def test_run_marks_error_on_unparseable_output(self, get_tmpconfig): + """A zero exit code but garbled SARIF output is still treated as an error, not a clean pass.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = b"" + mock_result.stderr = b"" + + def fake_run(cmd, **kwargs): + output_path = Path(cmd[cmd.index("--output") + 1]) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("not valid json") + return mock_result + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", side_effect=fake_run): + report_collection, errors = suite.run() + + assert errors is not None + + def test_run_marks_error_on_missing_output_file(self, get_tmpconfig): + """A zero exit code with no results file written is still treated as an error, not a silent success.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = b"" + mock_result.stderr = b"" + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", return_value=mock_result): + report_collection, errors = suite.run() + + assert errors is not None + + def test_run_never_passes_exit_code_flag(self, get_tmpconfig): + """Guards the Global Constraint: trivy's own --exit-code flag must never be set.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = b"" + mock_result.stderr = b"" + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", return_value=mock_result) as mock_run: + suite.run() + + for call in mock_run.call_args_list: + cmd = call.args[0] + assert "--exit-code" not in cmd + + def test_sequential_execution_one_call_per_target(self, get_tmpconfig): + """Guards the Global Constraint: sequential, one subprocess.run per target, no parallel module.""" + basic_tmpconfig = get_tmpconfig("basic") + suite = TrivySuite(basic_tmpconfig.base_path, basic_tmpconfig.targets) + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = b"" + mock_result.stderr = b"" + + with patch("posit_bakery.plugins.builtin.trivy.suite.subprocess.run", return_value=mock_result) as mock_run: + suite.run() + + assert mock_run.call_count == len(basic_tmpconfig.targets) + + @pytest.mark.slow + @pytest.mark.skipif(shutil.which("trivy") is None, reason="trivy binary not installed") + def test_run_integration(self, get_tmpconfig, monkeypatch): + """Test running trivy against a real, small public image with the real trivy binary. + + Unlike hadolint (which only lints Containerfile text on disk), trivy needs a real, + pullable image reference. The "basic" fixture's targets are template-rendered + Containerfiles that are never built anywhere in the test suite, so `ImageTarget.ref()` + would resolve to a local tag that was never built and `trivy image` would always fail + to pull it. `ref()` is monkeypatched for a single target to point at a small, + always-available public image instead. Everything else -- TrivyCommand construction, + the real trivy subprocess invocation, and TrivyReport.load() SARIF parsing -- is + exercised unmodified, against real output from a real trivy binary. + """ + basic_tmpconfig = get_tmpconfig("basic") + monkeypatch.setattr(ImageTarget, "ref", lambda self, *args, **kwargs: "alpine:3.19") + + target = basic_tmpconfig.targets[0] + suite = TrivySuite(basic_tmpconfig.base_path, [target]) + report_collection, errors = suite.run() + + assert errors is None, f"real trivy scan failed: {errors}" + assert target.image_name in report_collection + assert target.uid in report_collection[target.image_name] + _, report = report_collection[target.image_name][target.uid] + + results_file = suite.trivy_commands[0].results_file + assert results_file.exists() + raw = json.loads(results_file.read_text()) + sarif_run = raw["runs"][0] + assert sarif_run["tool"]["driver"]["name"].lower() == "trivy" + + # The category must survive into real trivy output, not just the hand-written + # fixture: upload-sarif reads it from here to give each file in a directory + # upload its own code-scanning category. + assert sarif_run["automationDetails"]["id"] == f"{suite.trivy_commands[0].scan_category}/" + + # Cross-check the parsed report against the real SARIF trivy wrote: every + # counted severity bucket is non-negative, and they add up to exactly the + # number of results trivy actually reported. This fails if TrivyReport.load() + # mis-parses the real SARIF shape in a way the hand-written testdata fixture + # (used by the mocked-subprocess unit tests above) wouldn't catch. + assert report.critical_count >= 0 + assert report.high_count >= 0 + assert report.medium_count >= 0 + assert report.low_count >= 0 + assert report.unknown_count >= 0 + assert report.total_count == len(sarif_run["results"]) diff --git a/posit-bakery/test/plugins/builtin/trivy/testdata/scan_result.sarif b/posit-bakery/test/plugins/builtin/trivy/testdata/scan_result.sarif new file mode 100644 index 000000000..0300c4822 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/testdata/scan_result.sarif @@ -0,0 +1,171 @@ +{ + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [ + { + "tool": { + "driver": { + "name": "Trivy", + "informationUri": "https://github.com/aquasecurity/trivy", + "version": "0.56.2", + "rules": [ + { + "id": "CVE-2024-0001", + "name": "OsPackageVulnerability", + "shortDescription": { + "text": "CVE-2024-0001 openssl" + }, + "fullDescription": { + "text": "A critical vulnerability in openssl." + }, + "helpUri": "https://avd.aquasec.com/nvd/cve-2024-0001", + "help": { + "text": "Package: openssl\nFixed Version: 3.0.14", + "markdown": "" + }, + "properties": { + "tags": [ + "vulnerability", + "security", + "CRITICAL" + ], + "precision": "very-high", + "security-severity": "9.8" + } + }, + { + "id": "CVE-2024-0002", + "name": "OsPackageVulnerability", + "shortDescription": { + "text": "CVE-2024-0002 curl" + }, + "fullDescription": { + "text": "A high severity vulnerability in curl." + }, + "helpUri": "https://avd.aquasec.com/nvd/cve-2024-0002", + "help": { + "text": "Package: curl\nFixed Version: 8.9.0", + "markdown": "" + }, + "properties": { + "tags": [ + "vulnerability", + "security", + "HIGH" + ], + "precision": "very-high", + "security-severity": "7.5" + } + }, + { + "id": "CVE-2024-0003", + "name": "OsPackageVulnerability", + "shortDescription": { + "text": "CVE-2024-0003 zlib" + }, + "fullDescription": { + "text": "A medium severity vulnerability in zlib." + }, + "helpUri": "https://avd.aquasec.com/nvd/cve-2024-0003", + "help": { + "text": "Package: zlib\nFixed Version: 1.3.1", + "markdown": "" + }, + "properties": { + "tags": [ + "vulnerability", + "security", + "MEDIUM" + ], + "precision": "very-high", + "security-severity": "5.3" + } + } + ] + } + }, + "results": [ + { + "ruleId": "CVE-2024-0001", + "ruleIndex": 0, + "level": "error", + "message": { + "text": "Package: openssl\nInstalled Version: 3.0.13\nSeverity: CRITICAL\nFixed Version: 3.0.14" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "usr/lib/x86_64-linux-gnu/libssl.so.3" + }, + "region": { + "startLine": 1 + } + } + } + ] + }, + { + "ruleId": "CVE-2024-0002", + "ruleIndex": 1, + "level": "warning", + "message": { + "text": "Package: curl\nInstalled Version: 8.5.0\nSeverity: HIGH\nFixed Version: 8.9.0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "usr/bin/curl" + }, + "region": { + "startLine": 1 + } + } + } + ] + }, + { + "ruleId": "CVE-2024-0002", + "ruleIndex": 1, + "level": "warning", + "message": { + "text": "Package: libcurl4\nInstalled Version: 8.5.0\nSeverity: HIGH\nFixed Version: 8.9.0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "usr/lib/x86_64-linux-gnu/libcurl.so.4" + }, + "region": { + "startLine": 1 + } + } + } + ] + }, + { + "ruleId": "CVE-2024-0003", + "ruleIndex": 2, + "level": "note", + "message": { + "text": "Package: zlib1g\nInstalled Version: 1.3\nSeverity: MEDIUM\nFixed Version: 1.3.1" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "usr/lib/x86_64-linux-gnu/libz.so.1" + }, + "region": { + "startLine": 1 + } + } + } + ] + } + ] + } + ] +} diff --git a/posit-bakery/test/pytest.ini b/posit-bakery/test/pytest.ini index 0766a5d75..4d8f2379a 100644 --- a/posit-bakery/test/pytest.ini +++ b/posit-bakery/test/pytest.ini @@ -14,6 +14,7 @@ markers = goss: Goss tool tests hadolint: Hadolint tool tests wizcli: WizCLI tool tests + trivy: Trivy tool tests build: Docker buildx build tests bake: Docker buildx bake tests container: Container related tests that leverage Docker diff --git a/setup-trivy/action.yml b/setup-trivy/action.yml new file mode 100644 index 000000000..7bb24044f --- /dev/null +++ b/setup-trivy/action.yml @@ -0,0 +1,11 @@ +name: 'Setup trivy' +description: 'Installs Trivy via aquasecurity/setup-trivy' + +runs: + using: "composite" + steps: + - name: Install trivy + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 + with: + version: "v0.74.0" + cache: true