Skip to content

feat(trivy): add reusable Trivy vulnerability scan workflow - #92

Merged
phil-davis merged 4 commits into
mainfrom
feat/trivy-reusable-workflow
Aug 18, 2026
Merged

feat(trivy): add reusable Trivy vulnerability scan workflow#92
phil-davis merged 4 commits into
mainfrom
feat/trivy-reusable-workflow

Conversation

@DeepDiver1975

Copy link
Copy Markdown
Member

Why

migrate_to_ocis shipped a bundled rclone built with Go 1.22.4 carrying 23 HIGH/CRITICAL CVEs — including CVE-2025-68121 (crypto/tls), CVE-2024-45337 (x/crypto) and CVE-2026-33186 (gRPC) — and nothing anywhere reported it. No app repo has any vulnerability scan today, and the one scan that did exist (on the docker image) was blind to it.

This adds the scan the app repos are missing. Callers: one security-scan.yml per repo, rolled out across the 43 oc11 app repos plus owncloud/core.

What it does

checkout → optional PHP/node setup → make <target>trivy rootfs over the built tree, failing on HIGH/CRITICAL with a fix available.

Three decisions are load-bearing, each learned the hard way:

1. scan-type: rootfs, not fs. The fs scanner does not enable Trivy's gobinary analyzer, so bundled Go binaries are silently skipped. Verified on trivy 0.55.0 and 0.69.3 against the same tree:

trivy fs      → 0 findings
trivy rootfs  → migrate_to_ocis/bin/rclone_linux_amd64 (gobinary): 23 HIGH/CRITICAL

An fs scan here would have reproduced the exact blind spot this is meant to close.

2. The unpacked tree, not the tarball. Trivy does not recurse into archives, and composer/npm dependencies only exist after a build.

3. trivyignores defaults to empty. The action fails hard on a named ignore file that does not exist:

# trivy-action entrypoint.sh:30
if [ ! -f "$f" ]; then echo "ERROR: cannot find ignorefile '${f}'." >&2; exit 1; fi

Defaulting it to .trivyignore would have broken all 43 repos on day one, since none has the file. Trivy reads a repo-root .trivyignore on its own, so a repo opts in simply by committing one — verified: 23 findings → 21 after listing two CVEs.

Scan path autodetection

The app repos disagree on where make dist writes, so the path is detected rather than configured — that keeps the caller file byte-identical in every repo:

layout repos
build/dist/<app> 29
build/artifacts/appstore/<app> 14
build/appstore/<app> notes

Anything else passes scan-path. A missing tree fails the job rather than scanning nothing — Trivy reports a nonexistent path as clean, so a broken build would otherwise read as a green scan.

Inputs

Input Default Notes
app-name repo name used for path detection
make-target dist core needs dist-dir
php-version '' empty: use the runner's PHP, as the other app workflows do
php-extensions curl, gd, json, xml, zip only when php-version is set
node-version '' core needs it: its dist-dir runs yarn run clean-modules
scan-path autodetected
severity HIGH,CRITICAL matches the docker image scans
ignore-unfixed true matches the docker image scans
skip-files ''
trivyignores '' see above

The build command is taken as a make target, not a shell string, so a caller cannot inject arbitrary commands into the runner.

Testing

  • Resolve-path logic unit-tested in POSIX sh across six cases: all three layouts, explicit scan-path, missing tree, and explicit-but-wrong path. The two failure cases exit 1 and print the build/ tree.
  • Actions pinned to the latest releases, verified via releases/latest + tag dereference: trivy-action v0.36.0, setup-php 2.37.2, setup-node v7.0.0.
  • YAML parses; yamllint profile matches the existing build.yml.
  • The end-to-end case this exists for: on owncloud/server:11.0.0 the analyzer never ran (0 findings, CI green); with the exec bit as the only change it reports all 23; with upstream rclone v1.75.0 it runs and reports clean.

Related

🤖 Generated with Claude Code

Scans the built distribution tree of an app (or core) for known HIGH/CRITICAL
vulnerabilities and fails the job when any are found.

Two details are load-bearing:

- scan-type must be rootfs, not fs. The fs scanner does not enable Trivy's
  gobinary analyzer, so bundled Go binaries go unscanned - which is exactly how
  a Go 1.22.4 rclone with 23 HIGH/CRITICAL CVEs shipped unnoticed in
  migrate_to_ocis.
- It scans the unpacked tree, not the .tar.gz: Trivy does not recurse into
  archives, and composer/npm dependencies only exist after the build.

The scan path is autodetected because the app repos disagree on the layout:
29 use build/dist/<app>, 14 the older build/artifacts/appstore/<app>, and notes
uses build/appstore/<app>. Callers with none of those pass scan-path. A missing
tree fails the job rather than scanning nothing, since Trivy reports a
nonexistent path as clean.

Build command is taken as a make target rather than a shell string so a caller
cannot inject arbitrary commands into the runner.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975

Copy link
Copy Markdown
Member Author

Validated on real CI

Ran this workflow from a throwaway caller in owncloud/migrate_to_ocis (owncloud/migrate_to_ocis#57), based on master — i.e. carrying the old bundled rclone fork build:

scanning build/dist/migrate_to_ocis
bin/rclone_linux_amd64 (gobinary)
Total: 48 (HIGH: 46, CRITICAL: 2)
  stdlib                  CVE-2025-68121  CRITICAL  v1.22.4  -> 1.24.13, 1.25.7
  google.golang.org/grpc  CVE-2026-33186  CRITICAL  v1.62.0  -> 1.79.3
  ...
##[error]Process completed with exit code 1

So: the build ran, build/dist/<app> was autodetected, scan-type: rootfs did run the gobinary analyzer, and exit-code: 1 blocked the job. (48 rather than the 23 I saw locally — my local Trivy DB is frozen at 2026-06-05, so local counts are a lower bound.)

Blocker before this can be rolled out: the org actions allowlist

The first attempt failed at startup (0s, no jobs, referenced_workflows: []). Cause is not this file — the owncloud org runs allowed_actions: selected with sha_pinning_required (owncloud/admin/actions-allowlist.yml), and it lists only aquasecurity/trivy-action@57a97c7e (v0.35.0). This PR pins the latest release, v0.36.0 (ed142fd0), which the policy rejects.

Isolated by pointing the identical caller at build.yml on this same branch: starts and passes in 14s.

owncloud/admin#232 adds the v0.36.0 SHA (commit is from 2026-04-22, so the 7-day cooldown gate passes). That has to merge before this one, otherwise every caller startup-fails. The alternative is pinning v0.35.0 here instead, which needs no allowlist change but contradicts the pin-to-latest-release convention.

@DeepDiver1975

Copy link
Copy Markdown
Member Author

Green half validated too

owncloud/migrate_to_ocis#58 ran the same workflow on top of the upstream rclone v1.75.0 swap, with trivyignores: .trivyignore.yaml:

scanning build/dist/migrate_to_ocis
Using YAML ignorefile '.trivyignore.yaml':

→ job passes, exit 0.

So all four behaviours this workflow exists for are now demonstrated on real CI, not reasoned about:

  • make dist runs, and build/dist/<app> is autodetected (the scan-path fallback chain works)
  • scan-type: rootfs does run the gobinary analyzer — the analyzer that fs skips, which is why the CVEs were invisible
  • exit-code: 1 blocks the job on findings
  • an ignorefile passed via trivyignores is honoured, including the YAML format (the action picks it up as a single YAML file since v0.35.0), while leaving the default empty so repos without one do not fail

Both throwaway PRs and branches are being deleted now.

The cache was keyed on build/yarn.lock, which only core generates.
setup-node hard-fails when the lockfile it is pointed at is missing
("Dependencies lock file is not found in ..."), so the two app repos that
need node — external and files_mediaviewer, whose Makefiles $(error) at
parse time without npm/yarn — would never reach the build step.

Caching is now opt-in: node-cache-dependency-path empty means no cache,
and core passes build/yarn.lock explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975

Copy link
Copy Markdown
Member Author

Pushed one fix found while pre-checking the repos that have no main.yml today (example-files, external, files_mediaviewer) and customgroups:

  • The Node.js cache was keyed on build/yarn.lock, which only core generates. setup-node hard-fails on a missing lockfile, so external and files_mediaviewer — whose Makefiles $(error) at parse time without npm/yarn — would never have reached the build step. Caching is now opt-in via node-cache-dependency-path; core passes build/yarn.lock.

Pre-check results for the four odd repos, for the record:

repo make dist tree caller override
example-files plain distdir + package build/dist/example-files none
external needs npm (parse-time $(error)) build/dist/external node-version
files_mediaviewer needs yarn (js-deps, build-js) build/dist/files_mediaviewer node-version
customgroups works — dist comes from rules/dist.mk build/customgroups scan-path

customgroups does have a dist target (my earlier note that it had none was wrong — it is in the rules/dist.mk include), but it is the only repo on the build/<app> layout, so it gets a scan-path rather than a fourth autodetect candidate.

Signing is skipped in all of them without a key (CAN_SIGN unset), which is what lets make dist run unattended.

Every other reusable workflow here is self-invoked by test.yml, so a
regression is caught before it reaches the callers. The trivy workflow
was the exception, which is the worst place for that gap: its failure
mode is a scan that reports clean because it never looked at anything.

Scanning this repository is not an option - `make dist` produces only a
tarball, and trivy does not look inside archives - so the gate needs a
real app. The new app-repository input, matching the one php-codestyle
and php-unit already have, lets the local invocation build and scan one.

Two jobs, one per dist layout, so both branches of the scan path
autodetection stay covered; the second also exercises the optional PHP
and Node.js setup steps.

Also quote the candidate list in the error path, reported by shellcheck.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975

Copy link
Copy Markdown
Member Author

Added the missing self-test. test.yml invokes every other reusable workflow here locally; this one was the exception, which is the worst place for that gap — its failure mode is a scan that passes because it never read a file.

What was added

  • trivy.yml: an app-repository input, identical to the one php-codestyle.yml/php-unit.yml already have (empty → the calling repository, so app callers are unaffected). Needed because a local uses: call checks out this repo, whose make dist produces only a .tar.gz — and Trivy does not look inside archives, so self-scanning would be green by construction.
  • test.yml: two jobs, one per dist layout — Trivy against owncloud/activity (build/dist/<app>) and Trivy (artifacts layout) against owncloud/announcementcenter (build/artifacts/appstore/<app>); the second also exercises the optional setup-php/setup-node steps. Neither app has third-party composer packages and both dist recipes are plain cp/tar, so the jobs are fast and cannot go red over a dependency CVE in someone else's tree.
  • Note for later readers: the jobs deliberately pass no app-name. The app is checked out at the workspace root, so its Makefile's app_name=$(notdir $(CURDIR)) evaluates to reusable-workflows — exactly what app-name defaults to. app-name: activity would look for build/dist/activity and fail.
  • Changelog entry (was missing) and one shellcheck quoting fix in the error path.

Verified locally: both make dist targets resolve through the autodetect to build/dist/<dir> and build/artifacts/appstore/<dir>, both trees scan clean, and a bogus scan-path still produces ::error::no distribution tree found rather than a silent green.

⚠️ Merge order. The run on this branch is startup_failure with no log, and that is expected: the local invocation is now the first reference in this repo to aquasecurity/trivy-action@ed142fd0 (v0.36.0), which the org allowlist does not yet contain (owncloud/admin main has only v0.35.0 57a97c7e). One unlisted SHA kills the whole run, so this PR must not merge before https://github.com/owncloud/admin/pull/232, or every PR to this repo goes red at startup. #232 is green now (its cooldown gate needed https://github.com/owncloud/admin/pull/233, merged today) and waiting on review. I'll re-run CI here once it lands.

Arguably the gate already earned its keep: without it this dependency would have surfaced in 44 app repos instead of in one PR.

@DeepDiver1975

Copy link
Copy Markdown
Member Author

owncloud/admin#232 is merged and the org sync applied it, so CI starts now instead of startup_failureing — and the two new jobs immediately caught a second, transitive gap in the same allowlist:

The action aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 is not allowed in owncloud/reusable-workflows

trivy-action v0.36.0 is a composite action whose second step runs setup-trivy v0.2.6, and the org resolves nested pins at job start, so allowing the outer SHA was not sufficient. The listed setup-trivy@e6c2c5e3 is v0.2.4, which is what trivy-action v0.35.0 pins. https://github.com/owncloud/admin/pull/234 adds the v0.2.6 SHA (2026-01-15, well past the cooldown; its cooldown check is green) and is waiting on review. setup-trivy itself only nests actions/cache and actions/checkout, both implicitly allowed, so there should be no third round.

Nothing to change here — the workflow is unmodified. I'll re-run this PR's CI once #234 lands.

Worth noting for the rollout: had the self-test not existed, this would have landed as 44 app repos plus core all failing in "Set up job" with an error that names an action nobody wrote down.

@DeepDiver1975

Copy link
Copy Markdown
Member Author

reopening to retrigger CI now that owncloud/admin#234 is synced

@DeepDiver1975

Copy link
Copy Markdown
Member Author

owncloud/admin#234 is synced and both gate jobs are green (run 32145827918). The evidence that they scanned something rather than passing on an empty path:

job resolved tree trivy invocation
Trivy (activity) scanning build/dist/reusable-workflows trivy rootfs build/dist/reusable-workflows
Trivy (artifacts layout) (announcementcenter) scanning build/artifacts/appstore/reusable-workflows trivy rootfs build/artifacts/appstore/reusable-workflows

Both report summaries are empty, so exit 0. Step-level: Validate app-repositoryCheckout codeBuildResolve scan pathTrivy scan all green in both; the first job correctly skips Setup PHP/Setup Node.js/Enable yarn, the second runs all three and acquires Node 18 without touching the yarn cache — which is the branch a6cb84d fixed.

Both dist layouts are therefore covered, and setup-trivy now resolves through the newly allowlisted SHA. Ready for review.

candidates="${SCAN_PATH}"
else
candidates="build/dist/${APP_NAME}
build/artifacts/appstore/${APP_NAME}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a minute to realise that this and the net line are part of a string, not commands inside the else/fi.

@phil-davis
phil-davis merged commit c74cd12 into main Aug 18, 2026
64 of 66 checks passed
@phil-davis
phil-davis deleted the feat/trivy-reusable-workflow branch August 18, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants