diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index bcee92f14..3f6d8768f 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -456,8 +456,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 +488,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 +498,36 @@ 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" + - 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 1cfe3d802..e2982f7cd 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 33ea8e246..5bfd5e1a2 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 0bd32e8df..068a6c07e 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/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/plugins/builtin/trivy/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py new file mode 100644 index 000000000..5b40bafc2 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py @@ -0,0 +1,350 @@ +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, + 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], + *, + 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, + 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..5ebe579d8 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py @@ -0,0 +1,117 @@ +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.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)] + + # 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, + 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, + severity=severity, + disabled_scanners=disabled_scanners, + timeout=timeout, + trivy_config=trivy_config, + ) + + @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..9eee44ed8 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py @@ -0,0 +1,173 @@ +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) -> "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. + """ + raw = filepath.read_text() + data = json.loads(raw) + + 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..bd5be8653 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py @@ -0,0 +1,116 @@ +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], + *, + 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, + 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) + 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 71a1dd48e..c4e4bce91 100644 --- a/posit-bakery/pyproject.toml +++ b/posit-bakery/pyproject.toml @@ -59,6 +59,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_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/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..855b3edea --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_command.py @@ -0,0 +1,225 @@ +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from posit_bakery.plugins.builtin.trivy.command import TrivyCommand + +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_results_files_are_unique_per_target(self, get_config_obj): + """Every target in a multi-version project must get its own SARIF file. + + The uid is the only per-target identifier that includes the version, so a + results_file keyed on anything coarser (image/variant/OS/arch) silently + overwrites earlier versions' output within a single scan run. + """ + config_obj = get_config_obj("basic") + results_dir = config_obj.base_path / "results" / "trivy" + files = [ + TrivyCommand.from_image_target(image_target=target, results_dir=results_dir).results_file + for target in config_obj.targets + ] + assert len(set(files)) == len(config_obj.targets) + + 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..cf1694e53 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_report.py @@ -0,0 +1,140 @@ +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 + + @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..3cd7a8c46 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/trivy/test_suite.py @@ -0,0 +1,203 @@ +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" + + # 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