.agents/skills/triage-image-cves/SKILL.md
On-demand triage of vulnerabilities in a published Activepieces Docker image on Docker Hub
(activepieces/activepieces) using grype. The skill lists the
3 most-recent tags and lets the user pick which to scan. Produces a review-ready report
per affected package and proposes fixes that are proven non-breaking in an isolated worktree —
image rebuilds, the target CVE clears with no new High/Critical introduced, tests pass, the
container boots, and the codebase diff is scoped. Nothing is ever applied to the working tree or
committed. The user decides per finding: approve / dismiss / escalate.
Scope (decided with the user; defaults below — re-confirm if they want different):
activepieces/activepieces, one of the 3 most-recent tags,
chosen by the user at the start of the run.deb, fix = Dockerfile base/apt change) and app
npm deps (fix = lockfile bump).Artifacts go in the .security-triage/ workspace (gitignored).
Write all artifacts to .security-triage/ (gitignored — confirm with git status after a run
that nothing new appears under tracked paths). Image CVEs are public, so fixes may use normal PRs
(no private-fork flow) — but still never commit triage artifacts.
grype on PATH (grype version). If missing, install it yourself first — do not pipe a
remote installer into a shell. Prefer a package manager (brew install grype) or download the
pinned release binary from https://github.com/anchore/grype/releases and verify its published
checksum before use. If grype is absent, this skill stops and asks you to install it rather than
fetch-and-exec an installer in the security workspace.docker running.grype db update if a run reports a stale DB.List the 3 most-recent tags from Docker Hub and let the user choose which to scan (use
AskUserQuestion, one option per tag, newest first, showing the push date):
mkdir -p .security-triage
curl -s "https://hub.docker.com/v2/repositories/activepieces/activepieces/tags?page_size=50&ordering=last_updated" \
| jq -r '[.results[] | select(.name|test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))][0:3][]
| "\(.name)\t\(.last_updated)"'
The select(... test ...) keeps only semver release tags and drops the floating latest pointer
(it just aliases one of them). If the API call fails or returns nothing, fall back to
docker search / ask the user for a tag explicitly. Set TAG to the user's choice, then scan:
docker pull activepieces/activepieces:$TAG
# scan EVERYTHING (do not pass --only-fixed here — we still want to surface no-fix CVEs for a
# risk writeup). Pin the platform so the result is reproducible.
grype activepieces/activepieces:$TAG --platform linux/amd64 -o json \
> .security-triage/grype.json
Validate it is a real scan, not an error. A failed scan can still write a small JSON object;
guard on the matches array explicitly:
jq -e '.matches | type=="array"' .security-triage/grype.json >/dev/null \
|| echo "No matches array — grype scan failed (pull/DB/network). Stopping."
Filter to High + Critical, dedupe to the real triage unit (CVE, package, version) — the same
CVE recurs once per install location — and split OS vs app by artifact.type:
# the deduped High/Critical set, tagged class=os|app
jq -r '
.matches[]
| select(.vulnerability.severity=="High" or .vulnerability.severity=="Critical")
| { id:.vulnerability.id, sev:.vulnerability.severity,
pkg:.artifact.name, ver:.artifact.version, type:.artifact.type,
fixState:.vulnerability.fix.state,
fixedIn:((.vulnerability.fix.versions // []) | join(",")),
class:(if (.artifact.type|test("^(deb|apk|rpm)$")) then "os" else "app" end) }
' .security-triage/grype.json \
| jq -s 'unique_by(.id + "|" + .pkg + "|" + .ver)' \
> .security-triage/grype-filtered.json
# headline table, OS first then app, critical first
jq -r 'sort_by(.class!="os", (.sev=="Critical"|not), .pkg)[]
| "\(.class)\t\(.sev)\t\(.pkg) \(.ver)\t\(.id)\tfix=\(.fixState):\(.fixedIn)"' \
.security-triage/grype-filtered.json
fixState is fixed / not-fixed / wont-fix / unknown. Only fixed findings have a fix to
test; the rest go to the risk-writeup path (Step 4, NO_FIX_YET).
Reachability caveat for OS findings: the shipped image's run stage is FROM base, so the
build toolchain in the Dockerfile (g++, build-essential, git, python3, poppler-utils,
curl, …) is present in the final image, not stripped. "Present" therefore does not mean
"reachable" — a CVE in g++ only matters if a runtime path invokes it. Decide reachability in
Step 2, do not assume.
Spawn one read-only subagent per package (not per match). Each returns a verdict backed by
evidence. Keep subagents read-only; they write reports/image-<pkg>.md and return a compact
verdict line.
For every finding, ground the version in the scan, not recall: the installed version is
artifact.version from grype.json; compare it to the advisory's fixed version
(vulnerability.fix.versions). Pull the advisory link from vulnerability.dataSource.
OS / base-image (deb) findings:
git → git-sync; python3/poppler-utils → PDF/AI pieces; ca-certificates/curl
→ outbound HTTP; g++/build-essential/node-gyp → native-module rebuild at install (build-
time, but the binary still ships). State which.apt-get install -y --only-upgrade <pkg> or pin the
fixed version. Low risk.FROM node:<x>-bullseye-slim to the latest patch
digest. Medium risk (Node minor/patch).isolated-vm, sandboxes). Must be proven by the
full gate, never proposed on paper.not-fixed/wont-fix → NO_FIX_YET.App (npm) findings:
bun.lock (this repo is bun), not
from recall. For a package with multiple advisories, being ≥ one fix version does not mean safe.bun install --production over a regenerated lockfile (pieces are stripped in the build), so the
set of installed npm packages in the image can differ from the dev bun.lock. The version grype
reports is the one actually shipped — treat that as ground truth.Emit one verdict per package:
| Verdict | Meaning |
|---|---|
FIXABLE | Reachable, a patched version exists, fix path is in-distro / minor bump. |
FIX_VIA_BASE_BUMP | Only fixed by a base-image tag/release bump (higher risk; must pass full gate). |
NOT_REACHABLE | Package present in image but the vulnerable component is not invoked by AP. |
NO_FIX_YET | No upstream fix (not-fixed / wont-fix). Risk writeup, no build. |
Write .security-triage/reports/image-<pkg>.md per affected package: class (os/app), the CVEs +
severities, installed version (from the scan), fixed version(s), fixState, reachability verdict
with the runtime path, the proposed fix path + risk class, and any self-hosting concern.
Then the consolidated .security-triage/IMAGE-CVE-TRIAGE-SUMMARY.md (distinct filename so it
never collides with other tools' artifacts in the shared gitignored .security-triage/ workspace):
jq '.descriptor' grype.json), so the run is reproducible.Present headline verdicts in chat, runtime-reachable first; surface NOT_REACHABLE /
build-only separately so a scary-looking CVE in a build tool doesn't bury a reachable one. Then
ask the user which findings to fix before doing anything.
Only after the user picks findings. All build/test/scan work happens in a throwaway git worktree so the working tree is never touched.
# <short> MUST be unique per run (e.g. $(date +%s)-$RANDOM) so paths never collide across runs;
# `git worktree add` also aborts if the path already exists, so a collision fails here.
git worktree add ../ap-cve-fix-<short> HEAD # isolated copy; main tree stays clean
Group approved fixes sensibly: all approved OS findings → one Dockerfile change set; all
approved npm bumps → one lockfile change set (the repo has a single shared bun.lock, so per-
package branches would all conflict on it — bump all approved npm packages in one batch, never one
worktree/PR per package). Then, inside the worktree:
Dockerfile (and Dockerfile.worker if affected) — pin the upgraded apt package,
bump the FROM digest, or change the Debian release. Keep it minimal and self-hosting-safe.bun install;
patch-bump any versioned internal package touched (packages/core/shared,
packages/server/utils) per the repo rule (once per branch).docker build -t ap-cve-test:<short> .
(plus -f Dockerfile.worker if it was changed). A failed build = fix rejected; report the
build error.grype ap-cve-test:<short> --platform linux/amd64 -o json > .security-triage/grype-after.json
# target CVE(s) gone?
jq -r '[.matches[].vulnerability.id] | unique' .security-triage/grype-after.json # must NOT contain the target ids
# no NEW high/critical vs baseline?
comm -13 \
<(jq -r '.matches[]|select(.vulnerability.severity|test("High|Critical"))|.vulnerability.id' .security-triage/grype.json | sort -u) \
<(jq -r '.matches[]|select(.vulnerability.severity|test("High|Critical"))|.vulnerability.id' .security-triage/grype-after.json | sort -u)
comm output must be empty (no High/Critical present after that wasn't there before).npx turbo run build lint test --filter=<pkg> … (one
--filter per affected package). Triage failures against base — re-run a failing test on
HEAD before blaming the fix; only green-on-base / red-after is yours. For a pure base-image bump
with no code change, the build + smoke run below is the proof; still run a representative
package's tests to catch a glibc/native-module regression.docker run --rm -d --name ap-cve-smoke -p 8080:80 ap-cve-test:<short>
# poll the health/entrypoint for boot, then:
docker logs ap-cve-smoke; docker rm -f ap-cve-smoke
git -C ../ap-cve-fix-<short> status + git diff. Confirm the
change is scoped to the intended files (Dockerfile / lockfile / the one adapted call site)
with no stray churn or unrelated edits. Report the diff stat.<short> is unique to this run, so the
path is provably this run's: git worktree remove ../ap-cve-fix-<short> --force
(and docker rmi ap-cve-test:<short>).NO_FIX_YET findings get no build — produce a risk-acceptance writeup in the package report: where it's reachable in the image, any sandboxing that mitigates it, and the options (accept with justification, pin/patch locally, swap the component, or wait for upstream).
Everything lands in .security-triage/ (gitignored): grype.json (baseline scan),
grype-filtered.json (High/Critical deduped), grype-after.json (post-fix re-scan),
IMAGE-CVE-TRIAGE-SUMMARY.md, and one report per affected package under reports/image-*.md.
The working tree and git history are never modified; all fix validation happens in a discarded
worktree.