From 1ee8321212fda9ec3d8b47cfc5c19d003533d984 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Fri, 28 Aug 2026 09:42:27 -0500 Subject: [PATCH 1/6] Emit latest flag from bakery ci matrix Add a `latest` boolean to each `bakery ci matrix` entry, alongside the existing `dev` field, so CI workflows can gate per-version steps on whether a version is the latest release of its image. Without it, a workflow whose matrix has already pinned one version has no way to express "only act on the latest one" except by passing --latest to the pinned command. Those two filters intersect to nothing on every non-latest version, which bakery reports as an error rather than as the intentional no-op the caller meant. The value comes from ImageVersion.is_latest_release, which is also what the --latest filter calls via matches_latest_filter. A workflow gating on the field and a command using the filter always select the same versions; there is no second copy of the rule that could drift. is_latest_release is a plain property rather than a computed_field: ImageVersion is round-tripped into bakery.yaml via model_dump, and a computed field would write a derived key into the user's config. --- posit-bakery/posit_bakery/cli/ci.py | 6 + posit-bakery/posit_bakery/config/config.py | 8 +- .../posit_bakery/config/image/version.py | 34 +++++ .../test/cli/test_ci_matrix_dev_versions.py | 7 +- .../test/cli/test_ci_matrix_latest.py | 119 ++++++++++++++++++ .../testdata/ci/matrix/barebones/default.json | 2 +- .../cli/testdata/ci/matrix/basic/default.json | 2 +- .../ci/matrix/basic/image_version_match.json | 2 +- .../testdata/ci/matrix/changeset/full.json | 2 +- .../ci/matrix/changeset/version_only.json | 2 +- .../merge-multi-image/image_name_filter.json | 2 +- .../ci/matrix/multiplatform/default.json | 2 +- .../multiplatform/exclude_platform.json | 2 +- .../test/config/image/test_version.py | 59 +++++++++ 14 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 posit-bakery/test/cli/test_ci_matrix_latest.py 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/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/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/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 From 90f259f5af682679613cd51e8b5a257453abab7e Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Fri, 28 Aug 2026 14:10:24 -0500 Subject: [PATCH 2/6] Add Trivy vulnerability scanning plugin and CI wiring Ports Trivy scanning from images-shared issue #218. Adds a bakery plugin for running `trivy image` scans and wires it into the native build workflow. Scans gate on `matrix.img.latest` so only the latest version of each image is scanned on production pushes, matching the Wiz scan policy. The existing Scan step is renamed to Wiz Scan for clarity. Trivy runs after Wiz Scan (and after Test, for the same reason: Test's `docker run` is what makes every target addressable in the local daemon). The Trivy version is pinned in setup-trivy/action.yml with no runtime override; updates go through Renovate so every bump has a human review. Co-authored-by: feat/trivy-security-scan --- .github/workflows/bakery-build-native.yml | 30 +- .github/workflows/ci.yml | 5 + CLAUDE.md | 2 +- posit-bakery/README.md | 2 +- posit-bakery/docs/architecture.qmd | 6 +- posit-bakery/docs/configuration.qmd | 27 ++ posit-bakery/docs/index.qmd | 3 +- .../plugins/builtin/trivy/__init__.py | 350 +++++++++++++++++ .../plugins/builtin/trivy/command.py | 117 ++++++ .../plugins/builtin/trivy/errors.py | 39 ++ .../plugins/builtin/trivy/options.py | 40 ++ .../plugins/builtin/trivy/report.py | 173 +++++++++ .../plugins/builtin/trivy/suite.py | 116 ++++++ posit-bakery/pyproject.toml | 1 + .../test/plugins/builtin/trivy/__init__.py | 0 .../test/plugins/builtin/trivy/conftest.py | 31 ++ .../plugins/builtin/trivy/test_command.py | 225 +++++++++++ .../test/plugins/builtin/trivy/test_init.py | 352 ++++++++++++++++++ .../plugins/builtin/trivy/test_options.py | 69 ++++ .../test/plugins/builtin/trivy/test_report.py | 140 +++++++ .../test/plugins/builtin/trivy/test_suite.py | 203 ++++++++++ .../builtin/trivy/testdata/scan_result.sarif | 171 +++++++++ posit-bakery/test/pytest.ini | 1 + setup-trivy/action.yml | 11 + 24 files changed, 2107 insertions(+), 7 deletions(-) create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/command.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/errors.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/options.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/report.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/__init__.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/conftest.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/test_command.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/test_init.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/test_options.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/test_report.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/test_suite.py create mode 100644 posit-bakery/test/plugins/builtin/trivy/testdata/scan_result.sarif create mode 100644 setup-trivy/action.yml diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 0125faa12..5b28c496b 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -456,7 +456,7 @@ 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 + - name: Wiz Scan if: ${{ inputs.push && steps.filter-steps.outputs.wiz-auth == 'true' }} continue-on-error: true env: @@ -497,6 +497,34 @@ jobs: "${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 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/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 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/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 From 50c4534c276e65ab9cd759c48cd5af3439f9b17c Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Fri, 28 Aug 2026 14:15:05 -0500 Subject: [PATCH 3/6] Gate Wiz scan on matrix latest field The Wiz Scan step passed --latest to a `bakery wizcli scan` already pinned to one version by the build matrix. On every non-latest version those two filters resolved to an empty target set, so bakery exited 1; continue-on-error hid it as a red annotation on an otherwise green job. Gate the step on (dev-versions == 'only' || matrix.img.latest) and drop LATEST_FLAGS. The decision is now made once at matrix generation time rather than per job from a filter combination that cannot match. Also fix _describe_active_filters (cli/common.py) to include --latest when set (it was omitted because False is falsy, so the one filter that emptied the selection was the one never shown) and render --image-platform as a string rather than a Python list repr. --- .github/workflows/bakery-build-native.yml | 17 +++++---- posit-bakery/posit_bakery/cli/common.py | 44 +++++++++++++++-------- posit-bakery/test/cli/test_common.py | 38 ++++++++++++++++++++ 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 5b28c496b..7410b42dd 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. + # 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' }} + 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,7 +498,6 @@ jobs: "${DEV_SPEC_FLAGS[@]}" \ "${POLICY_FLAGS[@]}" \ "${PROJECT_FLAGS[@]}" \ - "${LATEST_FLAGS[@]}" \ --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ --context "$CONTEXT" 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/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()) == "" From aa429f465d06b9e7a3ebf8db8bf7df36097d4141 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 11 Aug 2026 11:06:54 -0500 Subject: [PATCH 4/6] Carry code-scanning category in SARIF, not filename `bakery trivy scan` needs two different identifiers per target, and the previous design conflated them. The code-scanning category must omit the version so a PR can be diffed against its baseline, but the results filename must include it or two versions of one image overwrite each other -- 50 connect targets collapsed onto 6 files. Split them. results_file stays keyed on image_target.uid; scan_category is stamped into each run's automationDetails.id as "/". upload-sarif only fills automationDetails in when it is absent, so a single directory upload can carry a distinct category per file, which is what code scanning has required since runs sharing a category stopped being combined in July 2025. The category's architecture now comes from the platform the CLI actually resolved rather than the host's. A target is not platform-scoped (image_os.platforms is a list), so host arch was only correct for native scans and mislabelled every cross-arch scan. --- .../plugins/builtin/trivy/__init__.py | 3 + .../plugins/builtin/trivy/command.py | 32 +++++++++ .../plugins/builtin/trivy/report.py | 13 +++- .../plugins/builtin/trivy/suite.py | 4 +- .../plugins/builtin/trivy/test_command.py | 72 ++++++++++++++++--- .../test/plugins/builtin/trivy/test_report.py | 39 ++++++++++ .../test/plugins/builtin/trivy/test_suite.py | 5 ++ 7 files changed, 157 insertions(+), 11 deletions(-) diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py index 5b40bafc2..49d985c92 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/__init__.py @@ -213,6 +213,7 @@ def scan( results = plugin.execute( c.base_path, c.targets, + scan_platform=platform, severity=severity, fail_on_severity=fail_on_severity, disabled_scanners=disabled_scanners, @@ -228,6 +229,7 @@ def execute( 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, @@ -238,6 +240,7 @@ def execute( suite = TrivySuite( base_path, targets, + scan_platform=scan_platform, severity=severity, disabled_scanners=disabled_scanners, timeout=timeout, diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py index 5ebe579d8..03ccad8c4 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/command.py @@ -1,3 +1,4 @@ +import re from pathlib import Path from typing import Annotated, Self @@ -5,6 +6,7 @@ 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"] @@ -35,6 +37,11 @@ class TrivyCommand(BaseModel): # 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)] @@ -48,6 +55,7 @@ def from_image_target( 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, @@ -67,12 +75,36 @@ def from_image_target( 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: diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py index 9eee44ed8..2e5b67c2f 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/report.py @@ -42,15 +42,26 @@ def breaches(self, severities: list[str]) -> bool: return any(counts.get(sev.strip().upper(), 0) > 0 for sev in severities) @classmethod - def load(cls, filepath: Path) -> "TrivyReport": + 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) diff --git a/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py b/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py index bd5be8653..23e82b70f 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py +++ b/posit-bakery/posit_bakery/plugins/builtin/trivy/suite.py @@ -20,6 +20,7 @@ def __init__( context: Path, image_targets: list[ImageTarget], *, + scan_platform: str | None = None, severity: str | None = None, disabled_scanners: str | None = None, timeout: str | None = None, @@ -32,6 +33,7 @@ def __init__( TrivyCommand.from_image_target( target, results_dir=self.results_dir, + scan_platform=scan_platform, severity=severity, disabled_scanners=disabled_scanners, timeout=timeout, @@ -79,7 +81,7 @@ def run(self) -> tuple[TrivyReportCollection, BakeryToolRuntimeError | BakeryToo if exit_code == 0: if trivy_command.results_file.exists(): try: - report = TrivyReport.load(trivy_command.results_file) + 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}") diff --git a/posit-bakery/test/plugins/builtin/trivy/test_command.py b/posit-bakery/test/plugins/builtin/trivy/test_command.py index 855b3edea..5d66da3c7 100644 --- a/posit-bakery/test/plugins/builtin/trivy/test_command.py +++ b/posit-bakery/test/plugins/builtin/trivy/test_command.py @@ -1,9 +1,11 @@ +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, @@ -198,20 +200,72 @@ def test_results_file_is_uid_scoped(self, basic_standard_image_target): 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. + 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. - 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. + 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("basic") + config_obj = get_config_obj("changeset") results_dir = config_obj.base_path / "results" / "trivy" - files = [ - TrivyCommand.from_image_target(image_target=target, results_dir=results_dir).results_file + cmds = [ + TrivyCommand.from_image_target(image_target=target, results_dir=results_dir) for target in config_obj.targets ] - assert len(set(files)) == len(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.""" diff --git a/posit-bakery/test/plugins/builtin/trivy/test_report.py b/posit-bakery/test/plugins/builtin/trivy/test_report.py index cf1694e53..6c7ffade6 100644 --- a/posit-bakery/test/plugins/builtin/trivy/test_report.py +++ b/posit-bakery/test/plugins/builtin/trivy/test_report.py @@ -53,6 +53,45 @@ def test_unknown_rule_id_counts_as_unknown(self, tmp_path): 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", [ diff --git a/posit-bakery/test/plugins/builtin/trivy/test_suite.py b/posit-bakery/test/plugins/builtin/trivy/test_suite.py index 3cd7a8c46..adeaa3dd5 100644 --- a/posit-bakery/test/plugins/builtin/trivy/test_suite.py +++ b/posit-bakery/test/plugins/builtin/trivy/test_suite.py @@ -190,6 +190,11 @@ def test_run_integration(self, get_tmpconfig, monkeypatch): 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() From ea5a9dc25f95ddcdd5977a9100cca6ee1f054d99 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 11 Aug 2026 11:07:02 -0500 Subject: [PATCH 5/6] Upload Trivy SARIF from native builds Re-adds the security-events: write grant and the upload step held back from #722, now that the category no longer has to be smuggled through the SARIF filename. Uses github/codeql-action/upload-sarif rather than a hand-rolled `gh api` loop. The action handles gzip/base64 and validation, and it surfaces a 403 instead of swallowing it the way the previous `|| true` did. No `category:` input is passed, because each file already supplies its own via automationDetails.id -- passing one would give every run in the directory the same category, which code scanning rejects. Deliberately not added to bakery-build-pr.yml. Fork PRs get a read-only token, so a job requesting security-events: write there would fail for exactly the contributors that workflow's Detect Fork job exists to support. Main also only analyses latest versions, so PR-time uploads of older versions would have no baseline and would report every finding as new. Merging this requires the three product repos to grant security-events: write to their calling jobs first, per #729. --- .github/workflows/bakery-build-native.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 7410b42dd..1ec34b1d7 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) @@ -528,6 +529,18 @@ jobs: --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ --context "$CONTEXT" + # No `category:` input on purpose. Each SARIF file already carries its own + # version-stable category in runs[].automationDetails.id, written by + # `bakery trivy scan`. upload-sarif only fills automationDetails in when it + # is absent, so those survive; a `category:` input here would instead give + # every run in the directory the same category, which code scanning rejects + # (https://github.blog/changelog/2025-07-21-code-scanning-will-stop-combining-multiple-sarif-runs-uploaded-in-the-same-sarif-file/). + - 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: From c84c966c3d3830266dd3419a113dd7436878121f Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 11 Aug 2026 14:08:53 -0500 Subject: [PATCH 6/6] Trim the SARIF upload comment Keeps only why `category:` is omitted; the mechanism is in the commit that introduced the stamping. --- .github/workflows/bakery-build-native.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 1ec34b1d7..1bb3e4dee 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -529,12 +529,8 @@ jobs: --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ --context "$CONTEXT" - # No `category:` input on purpose. Each SARIF file already carries its own - # version-stable category in runs[].automationDetails.id, written by - # `bakery trivy scan`. upload-sarif only fills automationDetails in when it - # is absent, so those survive; a `category:` input here would instead give - # every run in the directory the same category, which code scanning rejects - # (https://github.blog/changelog/2025-07-21-code-scanning-will-stop-combining-multiple-sarif-runs-uploaded-in-the-same-sarif-file/). + # 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