Forgejo Actions

Automatically fixing vulnerable Poetry dependencies with Grype and Syft

A scheduled dependency-security workflow for my Python projects: Syft inventories what is locked, Grype finds vulnerable packages, Poetry applies targeted updates, and Forgejo opens a pull request only after a second scan proves the fix actually helped.

From an alert to something actionable

I have a handful of Python projects hosted in my own Forgejo instance: Bouquin , cspresso , Enroll , Heckle , and JinjaTurtle . They all use Poetry and have poetry.lock files, and they all already use Forgejo Actions for their normal CI.

For a while I also had a scheduled Trivy job watching dependencies. It did the useful first half of the job - find something bad - and then sent a notification to my Node-RED endpoint. But that still left me with the same manual sequence every time: identify the affected package, work out whether a fixed release exists, update the lockfile, run tests, re-check the vulnerability, then open a pull request.

I deliberately removed Trivy from this setup for other reasons , but I still wanted dependency scanning. This time I wanted to move past "there is a problem" and into "here is a small, reviewable patch that appears to solve the problem".

The result is a scheduled Forgejo Actions workflow built around Syft , Grype , and Poetry .

flowchart TD A[Scheduled Forgejo Action] --> B[Checkout repository] B --> C[Syft: build Python SBOM] C --> D[Grype: scan SBOM] D --> E{Fixable findings?} E -- No findings --> F[Finish cleanly] E -- Only unfixed findings --> G[Report in log and finish] E -- Yes --> H[Poetry update only affected packages] H --> I[poetry check --lock] I --> J[Syft: build new SBOM] J --> K[Grype: re-scan] K --> L{Did fixable findings disappear?} L -- No --> M[Fail and notify] L -- Yes --> N[Push security branch] N --> O[Open or update Forgejo PR] O --> P[Normal project CI runs on bot push]

Why Syft and Grype are separate

Syft and Grype are both Anchore projects, but they do different jobs.

Syft inventories software. In this workflow it scans the repository with only the Python cataloguers enabled and emits a Syft JSON SBOM. That gives the next stage a structured list of the Python packages and versions represented by the project, including the Poetry lockfile.

syft dir:. \
  --select-catalogers python \
  -o syft-json > before-sbom.json

Grype evaluates that inventory for vulnerabilities. Rather than asking Grype to independently walk the repository, I hand it the SBOM Syft just produced:

grype sbom:before-sbom.json -o json > before-grype.json

The inventory is an artefact in its own right, and the scanner consumes a well-defined representation of what Syft found. It also makes the workflow easy to reason about: first establish the dependency inventory, then assess it.

Scheduled, but staggered

Each repository gets the same Dependency security workflow, with both a scheduled trigger and workflow_dispatch so I can run it manually from Forgejo:

name: Dependency security

on:
  schedule:
    - cron: '17 1 * * *'
  workflow_dispatch:

I kept the old daily 01:00 UTC window I had used for dependency scanning, but staggered the five repositories through the hour rather than waking the runner up with five jobs at once. The exact minute is different in each repo.

The workflow runs on my existing docker runner. It installs Poetry plus the small set of system packages it needs, then downloads pinned Syft and Grype release tarballs. Importantly, it also downloads Anchore's checksum file and verifies the archive before installing the binary:

curl -fsSL "${base_url}/${archive}" -o "${tmpdir}/${archive}"
curl -fsSL "${base_url}/${tool}_${version}_checksums.txt" -o "${tmpdir}/checksums.txt"
(
  cd "$tmpdir"
  grep -F "  $archive" checksums.txt | sha256sum -c -
)

At the time of writing I pin Poetry 2.4.1, Syft 1.52.0, and Grype 0.119.0. I prefer explicit CI versions over quietly getting whatever happens to be latest on the day a scheduled job runs.

Deciding what is actually fixable

The Grype JSON output contains the package, installed version, vulnerability identifier, severity, fix state, and any advertised fixed versions. The helper turns those into a small internal Finding object.

The important distinction is between a vulnerability and a vulnerability for which Grype knows a fixed version.

That mirrors something I liked about my old Trivy setup, where I used --ignore-unfixed. A CVE that has no upstream fix is still useful information, but waking me up every day about something I can't act on doesn't improve security, it just creates alert fatigue.

So the workflow behaves like this:

  • no findings at all: success;
  • findings, but none with an advertised fix: log them and succeed;
  • one or more fixable findings: attempt remediation;
  • a fix is theoretically available but cannot be applied safely: fail and notify me.

Targeted Poetry updates

I did not want an automated vulnerability job to run a blanket poetry update and hand me a pull request containing twenty unrelated dependency changes. That would make review harder, make regressions more likely, and obscure which change was actually intended to fix the vulnerability.

Instead, the helper collects only the package names from Grype's fixable findings and asks Poetry to re-resolve those packages:

poetry update package1 package2 --lock --no-interaction
poetry check --lock

Poetry still resolves the dependency graph correctly, of course, so a transitive dependency may move where required by the resolver. But the request is deliberately limited to packages for which the security scan says a fix exists.

There is another important boundary: the automation never widens dependency constraints in pyproject.toml.

If the project says it requires a dependency below version 2 and the vulnerability is only fixed in version 2.1, that is a maintainer decision. The workflow isn't allowed to quietly turn a security patch into a potentially breaking major-version upgrade.

If Poetry cannot produce a changed poetry.lock within the existing constraints, the job fails instead of opening a misleading pull request.

The second scan is the gate

After Poetry changes the lockfile, the helper runs Syft and Grype again from scratch. It compares the package/vulnerability pairs from before and after and checks that at least one of the fixable findings has genuinely disappeared.

If the new lockfile does not remove any fixable Grype finding, the automation refuses to publish it:

The updated lockfile did not remove any fixable Grype finding;
refusing to publish a PR.

It also refuses to continue if Poetry changes anything other than poetry.lock. That is another small fail-closed check: this job has one job, and an unexpected working-tree change is something I would rather investigate than normalise.

If the re-scan fixes some findings but other fixable findings remain, the workflow can still publish the partial remediation, but the job exits as failed so the normal failure-notification path still fires. I get the useful patch and I get told there is still work to do.

The pull request is the audit trail

Once the re-scan proves that the lockfile improved things, the helper creates a branch called:

security/grype-dependency-fixes

and commits only poetry.lock with:

security: remediate dependency vulnerabilities

The pull request body is generated from the actual before/after scan data. It includes counts of findings before and after, the targeted packages, a table of remediated vulnerabilities, any remaining findings, and a note that the workflow re-ran both Grype and poetry check --lock.

If the security PR already exists, the next scheduled run force-updates the same security branch and updates the existing pull request instead of opening an endless sequence of duplicate PRs.

The bot does not merge anything. I still get a normal pull request to review, and the project still has to pass its normal CI.

Why I use a bot PAT instead of FORGEJO_TOKEN

Forgejo Actions provides an automatic workflow token, and it would be tempting to use that to push the remediation branch.

Forgejo suppresses workflows caused by changes made with its automatic token. That behaviour is useful for avoiding accidental workflow loops, but it is exactly the wrong behaviour here: if a security workflow writes a new lockfile, I very much want my ordinary test and lint workflow to run against it.

So the checkout explicitly avoids retaining credentials:

- name: Checkout
  uses: actions/checkout@v4
  with:
    persist-credentials: false

and the publication step uses a dedicated Forgejo bot PAT instead.

On Forgejo 15 I can restrict that PAT to the five repositories in question, with write:repository access rather than giving it broad account-wide powers. The bot is able to push the security branch and create or update its pull request, but it has no reason to have access to anything else. Branch protection rules are also in place to prevent that bot merging its own PRs into main branch or pushing directly to that branch.

There are two Actions settings:

Actions secret:
  SECURITY_BOT_TOKEN

Actions variable:
  SECURITY_BOT_USERNAME

Keeping the token away from the scanners

At startup it removes the bot username, PAT, Forgejo API URL, server URL, and repository name from the process environment. The values remain only inside the Python process. Syft, Grype, Poetry, and ordinary Git commands are therefore not launched with the PAT sitting in their inherited environment.

The token only comes back into play at publication time: as an HTTP authentication header for the Git push and as the authorization header for Forgejo's API when opening or updating the pull request.

That doesn't make a CI credential magically risk-free, of course, but it reduces the number of subprocesses that ever have a chance to see it.

Notifications still have a role

Several of these projects already send Forgejo Actions failures to a Node-RED webhook. The new security job plugs into that same failure path. But instead of notifying me simply because a vulnerability exists, it normally does the useful work first.

I get notified when something needs human attention: a fix exists but Poetry cannot select it inside the current constraints, the re-scan doesn't show the expected result, a partial remediation leaves other fixable findings behind, publication fails, or some other part of the job breaks in a way I can't imagine.

Where's the script?

You can see an example of the Python script here and the corresponding Actions workflow here .

Talk is cheap! Does it work?

Yep! See this Pull Request .

At a glance
  • CI: Forgejo Actions on a self-hosted docker runner
  • Projects: Bouquin, cspresso, Enroll, Heckle, JinjaTurtle
  • Inventory: Syft Python SBOM
  • Vulnerability scanner: Grype
Want less noisy security automation?
I do contract Linux infrastructure and DevSecOps work, including CI/CD, dependency security, and supply-chain controls.
Contact me