detain / dep-sec-patcher
Multi-language dependency vulnerability scanner and patcher: audits every ecosystem in a project tree, collects GitHub's view of its vulnerabilities into a local database, applies fixes to the manifests and closes issues proven fixed.
Requires
- php: ^8.1
- ext-ctype: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
Suggests
- ext-pcntl: Runs audits in parallel (-t N) and forwards Ctrl-C to running tools; without it everything runs one at a time
- ext-posix: Lets a timeout kill a command's whole process tree and reaps daemons tools leave behind
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-21 23:49:21 UTC
README
Find, fix and ship dependency security updates across every language in a repository.
dep-sec-patcher is a command-line tool for repositories that hold projects in
many languages. Point it at a directory, a git URL or a GitHub owner/name,
and one run will:
- audit every project it finds with that ecosystem's own tooling
(
npm audit,composer audit,pip-audit,govulncheck,cargo audit, OWASP dependency-check and more); - scan the whole tree with Trivy, bandit, grype and gosec, plus a built-in check for plain-HTTP endpoints, hardcoded credentials and old TLS versions;
- collect GitHub's view of the repository (Dependabot alerts, security advisories and Mend/WhiteSource issues) into a local vulnerability database;
- in update mode, fix: update dependencies, write the recorded fixes into the manifests, merge or rebase dependency-bot pull requests, and close security issues once the manifests prove them fixed;
- optionally ship the result: push a branch, open and merge a pull request, or commit straight onto the current branch.
It is a generalised port of a tool that maintained a repository of generated API SDK samples in dozens of languages, so it was built for trees holding many projects in different ecosystems, and for running unattended from cron. It is written in PHP (8.1+) with no runtime dependencies, drives the ecosystem tools you already have installed, and skips the ones you don't.
Contents
- At a glance
- Quick start
- Check mode and update mode
- Supported languages and ecosystems
- Tools it uses
- Installation
- Targets, state and scope
- Options
- Config file
- Scanners
- GitHub integration
- NVD API key
- Vulnerability database
- Reports
- Exit status
- Concurrency
- Safety guarantees
- Troubleshooting
- Architecture
- Development
- Migrating from update-samples-security
- License
At a glance
- One command, 14 ecosystems. Maven, Gradle, sbt, npm / Yarn / pnpm, Composer, pip, Bundler, Go, Cargo, .NET, Dart, Elixir, Swift and R projects are found anywhere in the tree and checked with their own tools.
- Whole-tree scanners. Trivy (vulnerabilities, misconfigurations and secrets), bandit, grype on images built from your Dockerfiles, gosec, and a built-in network-security check.
- GitHub's findings, kept locally. Dependabot alerts, repository security advisories and Mend/WhiteSource issues are merged into a JSON database that remembers what has already been fixed.
- Fixes written into the manifests. For each package it writes the smallest
bump that clears every advisory, staying on your current release line where a
fix exists there. Transitive dependencies are pinned with npm
overrides/resolutionsor Maven<dependencyManagement>, on the line the lockfile holds or, without one, the line the issue found in use. A version is never moved backwards, and never to a new major version unless you pass--allow-major. - Dependency-bot PRs handled. Dependabot, Renovate, Mend and Snyk PRs are merged, set to auto-merge, or asked to rebase. Only PRs from those GitHub App bots are touched, never a human's.
- Issues closed on evidence.
--close-fixedcloses a security issue only when every manifest (and, for npm and Composer, every lockfile) it names holds a fixed version, and leaves the evidence in a comment. - Ships the result.
--pushpushes a branch,--propens and merges a pull request, and--push-directcommits straight onto the checked-out branch. - Tells you what to do next. When a run finds fixes it did not apply or publish, it ends with a "Next steps" section that explains why and prints the exact command to run next.
- Safe to point at your work. Check mode undoes whatever tools write into the tree. Every command has a timeout, nothing can block on a prompt, npm and Composer run without third-party scripts, and state lives outside the scanned tree.
- Any target. A local directory, a git URL or
owner/name. Narrow a monorepo with--subdir. - Built for cron. It finds
GH_TOKENandNVD_API_KEYin your shell rc files, adds per-user tool directories toPATH, runs audits in parallel within per-tool concurrency limits, and holds a lock so two runs on one target never collide.
A run goes through these steps in order: GitHub collection → auto-fix →
ecosystem updates and audits → scanners → repository settings →
dependency-bot PRs → publish → close-fixed → summary → next steps → JSON
report. The
fixes GitHub knows about are written into the manifests first, so the
ecosystem updates regenerate the lockfiles from the fixed manifests and the
audits report the result. When the run is going to publish, the
dependency-bot PRs are handled before everything else, so the run's own
updates are made on top of whatever they merged. Ecosystems, scanners, PR
triage and auto-fix each have a --no-... switch, --no-github skips every
GitHub step, and publishing and close-fixed only run when asked for.
Quick start
Install it with Composer. No release is tagged yet, so ask for the development version (see Installation for other ways):
composer global require detain/dep-sec-patcher:dev-master
Look, don't touch. Check mode audits and reports, and leaves the tree as it found it:
dep-sec-patcher --check # the current directory dep-sec-patcher --check -t 4 owner/name # a GitHub repository, cloned for the run and deleted afterwards, 4 audits at a time dep-sec-patcher --check --only node,php -v # only two ecosystems, streaming every tool's output dep-sec-patcher --check --subdir services/api ~/src/monorepo # only part of a monorepo
Fix. Update mode (the default) updates dependencies and applies the stored
fixes in place, for you to review with git diff:
dep-sec-patcher --update # update, fix, merge bot PRs dep-sec-patcher --update --allow-major # also apply fixes that need a new major version
Fix and ship. Commit the run's changes and push them:
dep-sec-patcher --update --push owner/name # to a new branch dep-sec-patcher --update --pr owner/name # to a new branch, then open a PR and squash-merge it dep-sec-patcher --update --pr --pr-merge none owner/name # open the PR, but leave it for review dep-sec-patcher --update --push-direct # straight onto the checked-out branch dep-sec-patcher --update --pr --close-fixed owner/name # ...then close the Mend issues the merged fixes resolve
Unattended. A nightly cron job (tokens are read from ~/.bashrc, see
GitHub integration):
30 3 * * * $HOME/.config/composer/vendor/bin/dep-sec-patcher --update --pr -t 4 owner/name >> $HOME/dep-sec-patcher.log 2>&1
A run prints one line per audited project ([OK], [FAIL], [WARN],
[SKIP]), then a summary table and the path of the JSON report:
[dep-sec-patcher] [FAIL] [node] web/frontend — npm audit: 9 vulnerabilities (8 high, 1 low) (8s)
[dep-sec-patcher] [OK] [php] api — composer audit: no known vulnerabilities (4s)
[dep-sec-patcher] [FAIL] [maven] sdk/java — dependency-check: 4 vulnerabilities in 2 dependencies, 2 with CVSS >= 7 (10s)
...
[dep-sec-patcher] Ecosystem Status Vulnerabilities Outdated
[dep-sec-patcher] node FAIL 195 0
[dep-sec-patcher] maven FAIL 1079 0
[dep-sec-patcher] php PASS 0 0
When the run found fixes it did not apply or publish, a Next steps section
follows. It says why, and prints the command to run next, built from your own
command line: the same target and options, with the mode and publishing flags
swapped for --update --pr (plus --push-direct and --push as
alternatives), and --allow-major or --close-fixed added when they are what
is missing. For example, after dep-sec-patcher --check -t 4 owner/name:
[dep-sec-patcher] ── Next steps ──────────────────────────────────────────────────
[dep-sec-patcher] [INFO] the audits found 1274 vulnerabilities; update mode updates the dependencies and audits again
[dep-sec-patcher] [INFO] 12 fix(es) from GitHub's alerts and issues would be written into the manifests
[dep-sec-patcher] [INFO] 3 dependency-bot pull request(s) can be merged
[dep-sec-patcher] [INFO] 1 fix(es) or bot pull request(s) need a major version upgrade, which may break the code that uses them
[dep-sec-patcher] [INFO] security issues are recorded; --close-fixed closes the ones the manifests prove fixed
[dep-sec-patcher] Apply the fixes, commit them, open a pull request and merge it:
[dep-sec-patcher] dep-sec-patcher --update --pr --allow-major --close-fixed -t 4 owner/name
[dep-sec-patcher] Or commit straight to the default branch:
[dep-sec-patcher] dep-sec-patcher --update --push-direct --allow-major --close-fixed -t 4 owner/name
[dep-sec-patcher] Or push a branch for review only:
[dep-sec-patcher] dep-sec-patcher --update --push --allow-major --close-fixed -t 4 owner/name
It appears after a check run that found vulnerabilities, fixes it would write
or mergeable bot PRs; after an update run without a push that left
changes in the tree; when issues could be closed, fixes need a major upgrade,
or issues are recorded without --close-fixed. A target with no GitHub
repository or git checkout gets a plain --update suggestion instead. The
suggestion never repeats --nvd-api-key, drops --branch for --push-direct,
and drops --pr-merge without --pr.
Check mode and update mode
--check |
--update (default) |
|
|---|---|---|
| Ecosystem audits | yes | yes, after updating dependencies |
Dependency updates (npm update, composer update, mvn versions:use-latest-releases, ...) |
no | yes |
| Lockfiles created only so an audit can run | created, then removed | kept |
| Anything else a tool writes into the tree | undone | build/dependency directories removed, other changes kept |
| Scanners | yes | yes |
| GitHub collection into the vuln DB | yes | yes |
| Dependency-bot PRs | listed with their merge state | merged, auto-merged or asked to rebase |
| Fixes from the vuln DB applied to manifests | counted only | applied (a new major version only with --allow-major) |
--close-fixed |
reports what it would close | labels and closes |
--push |
nothing | commits the changes to a new branch and pushes it |
--pr |
nothing | as --push, then opens a pull request and merges it |
--push-direct |
nothing | commits the changes onto the checked-out branch and pushes it |
Check mode is safe to run against a working tree you care about: every file a
tool changes is restored and every file it creates is deleted, so git status
(ignored files included) is the same afterwards (see
Safety guarantees). One exception: a build or cache
directory that already existed before the run (obj/, .build/,
node_modules/, ...) is left in place, and tools may refresh cache files
inside it (NuGet restore metadata, SwiftPM lock files). Update mode changes
manifests and lockfiles in place and expects you to review git diff, or to
publish them with --push, --pr or --push-direct.
The default is update mode, as it was in the original tool. Set
SECURITY_UPDATE_MODE=check in the environment to change the default; --check
or --update on the command line always wins.
Supported languages and ecosystems
Each ecosystem finds its projects by manifest name anywhere in the tree.
Dependency and build directories (node_modules, vendor, target, .git,
.venv, _build, ...) are never descended into, symlinked directories are not
followed, and --exclude-dir adds more. The Name column is what
--no-<name>, --only and the config file's ecosystems key use.
| Language | Name | Detected by | Vulnerability audit | Update mode |
|---|---|---|---|---|
| Java | maven |
pom.xml |
OWASP dependency-check (NVD); fails at CVSS >= --cvss-threshold |
versions:use-latest-releases and versions:update-properties rewrite pom.xml |
| Java, Kotlin | gradle |
gradlew |
none: outdated report only (dependencyUpdates) |
resolves dependencies (informational) |
| Scala | sbt |
build.sbt |
none: outdated report only (dependencyUpdates) |
dependencyUpdates (informational) |
| JavaScript, TypeScript | node |
package.json |
npm audit, yarn audit / yarn npm audit, or pnpm audit |
npm update + npm audit fix (--force with --allow-major), yarn upgrade (Yarn 1), pnpm up (--latest with --allow-major) |
| PHP | php |
composer.json |
composer audit (also lists abandoned packages) |
composer update -W rewrites composer.lock |
| Python | python |
requirements.txt |
pip-audit, or safety |
pip-audit --fix rewrites requirements.txt |
| Ruby | ruby |
Gemfile |
bundle-audit (bundler-audit) |
bundle lock --update rewrites Gemfile.lock |
| Go | go |
go.mod |
govulncheck (plus a gosec source scan) |
go get -u ./... and go mod tidy |
| Rust | rust |
Cargo.toml |
cargo audit |
cargo update rewrites Cargo.lock |
| C#, F# (.NET) | dotnet |
*.sln, *.csproj, *.fsproj |
dotnet list package --vulnerable --include-transitive |
dotnet-outdated -u:Auto rewrites project files, then each package still vulnerable goes to the newest release on its major line (any line with --allow-major) via dotnet add package, a transitive one pinned by a direct reference |
| Dart | dart |
pubspec.yaml |
none: outdated report only (dart pub outdated) |
dart pub upgrade (--major-versions with --allow-major) |
| Elixir | elixir |
mix.exs |
mix hex.audit (retired packages) |
mix deps.update --all |
| Swift | swift |
Package.swift |
none: dependency listing only | swift package update |
| R | r |
DESCRIPTION, renv.lock |
none: lists outdated packages in the host R library | nothing written |
Notes:
- Node uses Yarn for a project with a
yarn.lockand pnpm for one with apnpm-lock.yamlwhen that tool is installed, and npm otherwise. Yarn 2+ is audited but not upgraded. - Python directories that only have a
pyproject.tomlorPipfileare found but skipped (no requirements.txt to audit). Auto-fix still edits those files. - Gradle in check mode needs the ben-manes gradle-versions-plugin for its report; without it the project is SKIPped with that reason. sbt needs sbt-updates: when neither the project nor the global plugins have it, the tool adds it for the one command (sbt 1.4 and later, sbt 2 included), and only an older sbt is SKIPped, with the line to add. See Ecosystem tools.
- Gradle runs on Java only up to the release its version predates (Gradle
7.0 up to Java 16, 8.5 up to 21; on a newer JVM a wrapper fails with
"Unsupported class file major version"). Such a wrapper runs on the newest
JDK under
/usr/lib/jvmit supports, with a note, or is SKIPped naming the Java it needs. A failed Gradle build shows its "What went wrong" section rather than the end of a stack trace. - Trivy and the GitHub collection run whatever the per-ecosystem audits find or skip.
A FAIL result means vulnerabilities at or above the threshold were found:
critical, high or unrated findings for most tools (pip-audit reports no
severity, so each of its findings counts), and CVSS >= --cvss-threshold for
Maven. Lower findings are WARN, and so is a failed step in an ecosystem that has
no advisory database. A tool that crashes or times out is shown as
[FAIL] ... [tool error] with the end of its output. Go standard-library
findings belong to the local Go toolchain and are reported as a note, without
failing the project.
Per-ecosystem details: the exact commands, and concurrency
| Name | Tools | Check mode runs | Update mode changes | Concurrent runs |
|---|---|---|---|---|
maven |
mvn, OWASP dependency-check-maven (--enable-native-access=ALL-UNNAMED is added to MAVEN_OPTS when mvn runs on Java 17+) |
dependency-check:check against the database refreshed once per run (autoUpdate=false) |
versions:use-latest-releases (falling back to use-latest-versions only if that fails), then versions:update-properties for versions taken from <properties>, rewrite pom.xml; both with -DallowMajorUpdates=false unless --allow-major, and both pass over milestones, release candidates, alphas, betas and other prereleases (-Dmaven.version.ignore), which the plugin otherwise counts as releases |
Maven >= 3.9.0, with resolver file locks; serial below |
gradle |
the project's gradlew, run with sh (or bash when its first line names bash) |
dependencyUpdates if the ben-manes versions plugin is applied, else SKIP |
dependencies (resolves; informational) |
yes |
sbt |
sbt + sbt-updates plugin, always in the foreground (--server on sbt 2, SBT_NATIVE_CLIENT=false), so no sbt server is left running |
dependencyUpdates; without the plugin in project/*.sbt or the global plugins, it is added with --addPluginSbtFile (sbt 1.4+), else SKIP. sbt before 1.6 on Java 18 to 23 gets -Djava.security.manager=allow, without which it cannot start. A build without project/build.properties runs on sbt 1.10.11 under sbt 2's runner, since sbt 2 cannot load an sbt 1 build definition, and -Dsbt.skip.version.write=true keeps sbt from writing a build.properties into it |
dependencyUpdates (informational) |
sbt >= 1.3.0 (Coursier); serial below |
node |
npm, or yarn / pnpm when their lockfile is present |
npm: npm install --package-lock-only, then npm audit --omit=dev; Yarn 1: yarn audit; Yarn 2+: yarn npm audit --all; pnpm: pnpm audit |
npm update and npm audit fix (lockfile only, scripts disabled; --force, which makes major upgrades, only with --allow-major; when vulnerabilities remain, a note says how many need a major upgrade and how many have no fix), Yarn 1: yarn install then yarn upgrade (scripts disabled), pnpm up (scripts disabled; --latest only with --allow-major). When package.json itself has conflicting peer dependencies (npm ERESOLVE), every npm step is retried with --legacy-peer-deps and a note says so |
npm, pnpm: yes; yarn >= 2.0.0, serial for Yarn 1 |
php |
composer |
composer update -W --no-install if there is no lockfile, then composer audit --locked; a lockfile created for the audit is deleted afterwards |
composer update -W --no-install rewrites composer.lock, or creates one for the audit and deletes it afterwards when the project had none (so --push never commits it). If Composer refuses because only versions with security advisories meet a requirement, the update is retried with its advisory blocking off, and a note says so |
always serial |
python |
python3, plus pip-audit or safety |
pip-audit -r requirements.txt (or safety check -r requirements.txt) |
pip-audit --fix rewrites requirements.txt (safety changes nothing) |
yes |
ruby |
bundle, bundle-audit (installed with gem if missing) |
advisory database refreshed once, bundle lock if there is no lockfile, then bundle-audit check |
bundle lock --update rewrites Gemfile.lock (no gems installed) |
Bundler >= 2.6.0; serial below |
go |
go, govulncheck, optional gosec |
go list -m -u all (outdated count), govulncheck (reachable code, falling back to -scan module when the package does not build) |
go get -u ./... and go mod tidy |
yes |
rust |
cargo, cargo-audit |
cargo generate-lockfile if needed, then cargo audit |
cargo update rewrites Cargo.lock |
yes |
dotnet |
dotnet, dotnet-outdated |
dotnet restore, dotnet list package --vulnerable --include-transitive (a solution owns the projects it references, so they are audited once) |
dotnet-outdated -u:Auto rewrites project files; packages still listed as vulnerable are moved with dotnet add package --version (newest release, major line kept unless --allow-major; transitive ones get a direct PackageReference) and the audit reruns |
always serial |
dart |
dart |
dart pub outdated (informational, no advisory database) |
dart pub upgrade, with --major-versions only under --allow-major |
always serial |
elixir |
mix (Hex set up once with mix local.hex --force) |
mix deps.get, then mix hex.audit (retired packages) |
mix deps.update --all, then mix hex.audit |
always serial |
swift |
swift |
swift package show-dependencies (informational) |
swift package update |
always serial |
r |
Rscript |
old.packages() on the host R library (informational) |
same; nothing is written | yes |
Manifests auto-fix can edit
Auto-fix writes the fixes recorded in the vulnerability database into these files (see GitHub integration for how a version is chosen):
| Manifest | Ecosystem | Version written as |
|---|---|---|
package.json |
npm | a declared dependency (including optionalDependencies) keeps its ^ / ~ / >= prefix; *, x, latest or an empty spec becomes >=X.Y.Z; unions (||), git / file / alias specs and dist-tags are left alone. A transitive dependency gets a caret pin ^X.Y.Z: in resolutions when there is a yarn.lock, and in overrides when there is a package-lock.json / npm-shrinkwrap.json or no yarn.lock |
composer.json |
Composer | ^X.Y.Z in require or require-dev; * becomes >=X.Y.Z; unions and dev- branches are left alone |
requirements*.txt, constraints*.txt |
pip | name==X.Y.Z, keeping extras, environment markers and comments. The name must match whole (boto is not boto3); a file using --hash is left alone |
pyproject.toml, Pipfile |
pip | the existing operator kept (>= when there is none) |
pom.xml |
Maven | the literal <version>, or the ${property} it refers to; a transitive dependency goes into <dependencyManagement> |
build.gradle, build.gradle.kts |
Gradle | "group:artifact:X.Y.Z" string coordinates |
go.mod |
Go | vX.Y.Z on require lines for the exact module path; exclude and replace lines are untouched and +incompatible is kept |
mix.exs |
Hex | ~> X.Y.Z |
*.csproj, *.fsproj, *.nuspec, packages.config |
NuGet | the Version / version attribute |
pubspec.yaml |
pub | ^X.Y.Z, for first-level entries under dependencies, dev_dependencies and dependency_overrides |
Gemfile |
RubyGems | ~> X.Y.Z |
Cargo.toml |
Cargo | "X.Y.Z" or { version = "X.Y.Z" } |
A fix reported against a lockfile is redirected to its source manifest:
package-lock.json, npm-shrinkwrap.json, yarn.lock and pnpm-lock.yaml to
package.json, composer.lock to composer.json, Gemfile.lock to Gemfile,
Cargo.lock to Cargo.toml, go.sum to go.mod, mix.lock to mix.exs,
pubspec.lock to pubspec.yaml, Pipfile.lock to Pipfile, poetry.lock to
pyproject.toml, and packages.lock.json to the .csproj / .fsproj beside
it. JSON manifests keep their indentation.
The editor is chosen by the manifest's file name, so GitHub's ecosystem names
do not matter (it reports Cargo as rust, Mix as erlang, and Gradle builds as
maven). Package names and versions that do not look like names and versions
are never written, and a manifest path is resolved (symlinks included) and must
lie inside the tree before it is edited. A fix the editor cannot make is
reported as "cannot edit" and stays pending.
Major versions. By default auto-fix does not move a declared dependency, or
a transitive one whose version in use is known, to a new major version. Below
1.0 a new minor version counts as major, the way npm's caret treats it (0.8.x
to 0.9.0). Such a fix prints a [WARN], stays pending with a note, and is
applied by a later run with --allow-major (or "allow-major": true in the
config file).
Transitive dependencies. For npm the lockfile is read first: yarn.lock
(v1 and Berry), package-lock.json / npm-shrinkwrap.json (v1 to v3) or
pnpm-lock.yaml. An npm package the lockfile holds no copy of is not pinned
(nothing installs it). Without a lockfile, for npm and Maven, the versions the
security issue found in use stand in for it (Mend names the path of each copy,
such as ~/.m2/repository/io/netty/netty-codec-http2/4.1.76.Final/). When
every copy already clears the advisories, the record is marked applied without
a pin. When the copies span several major lines, nothing is pinned and the
record stays pending ("pin it by hand"). Otherwise the pin is chosen on the
line in use, from the newest copy, so it moves none of them back: netty 4.1.76
gets 4.1.135.Final, not 4.2.15.Final. With no version in use known, the pin is
the highest fix any advisory suggested for that package.
Tools it uses
dep-sec-patcher bundles no scanners: it drives the tools installed on the
host. Every check is auto by default, which means it runs when its tool is on
PATH and something in the tree needs it. A missing tool never stops the run;
see When a tool is missing.
Core
| Tool | Needed for | Without it |
|---|---|---|
PHP 8.1+ with ctype, json and mbstring |
running dep-sec-patcher | nothing runs |
PHP pcntl extension (optional) |
parallel audits (-t N), and passing Ctrl-C on to running tools |
everything runs one job at a time |
PHP posix extension and setsid from util-linux (optional) |
running each command in its own session, so a timeout or Ctrl-C stops the whole process tree and leftover daemons are reaped | a timeout can only signal the command's own process, and without posix not even that |
git |
remote targets, reading the origin remote, --push / --pr / --push-direct |
remote targets and publishing fail; a local target needs --repo for the GitHub steps |
gh, authenticated |
every GitHub step, and --pr |
the GitHub steps print [SKIP] or [WARN]; the local audits still run |
Linux is supported, and macOS with caveats: setsid (util-linux) must be on
PATH for process-group kills, and macOS does not ship it. Windows is not
supported.
Ecosystem tools
| Name | Needs (else the ecosystem is SKIPped) | Optional extras | Get it |
|---|---|---|---|
maven |
mvn |
none to install: Maven fetches the OWASP dependency-check-maven and versions-maven-plugin plugins itself. An NVD API key is strongly recommended |
maven.apache.org |
gradle |
a gradlew wrapper in the project (run with sh, or bash when it is a bash script, so it need not be executable) |
the gradle-versions-plugin (com.github.ben-manes.versions), applied in build.gradle(.kts), settings.gradle(.kts) or ~/.gradle/init.d/, for the check-mode report |
ships with the project |
sbt |
sbt |
none for sbt 1.4+ and 2: the sbt-updates plugin is used from project/*.sbt or the global plugins (~/.sbt/1.0/plugins/; sbt 2 has none), and otherwise added for the command. Older sbt needs it in ~/.sbt/0.13/plugins/ or project/, else its projects are SKIPped |
scala-sbt.org |
node |
npm |
yarn and pnpm, for projects with their lockfile (npm is used otherwise) |
nodejs.org |
php |
composer |
getcomposer.org | |
python |
python3, plus pip-audit or safety |
pip-audit is preferred: it lists each finding and --fix updates requirements.txt. safety is a check-only fallback |
pip-audit |
ruby |
bundle, which must run |
bundle-audit: installed automatically with gem install bundler-audit when gem is available; without it Gemfiles are resolved but not audited ([WARN]) |
bundler-audit |
go |
go |
govulncheck (without it projects are not audited, [WARN]), gosec |
go.dev |
rust |
cargo |
cargo-audit (without it projects are not audited, [WARN]) |
rustup.rs |
dotnet |
dotnet |
dotnet-outdated, which upgrades packages in update mode; without it the audit still runs |
dotnet.microsoft.com |
dart |
dart |
dart.dev | |
elixir |
mix |
Hex is installed or updated with mix local.hex --force before the first project |
elixir-lang.org |
swift |
swift |
swift.org | |
r |
Rscript |
r-project.org |
Scanners and their tools
| Scanner | Needs | Without it | Get it |
|---|---|---|---|
| Trivy | trivy |
[SKIP] trivy (trivy not installed) |
aquasecurity/trivy |
| bandit | bandit |
[SKIP] bandit (bandit not installed) |
PyCQA/bandit |
| grype | grype and docker, with the daemon running |
skipped without a line when either is missing, with one SKIP line when the daemon cannot be reached | anchore/grype, Docker |
| gosec | gosec (runs inside the Go audit) |
not run, without a line | securego/gosec |
| govulncheck | govulncheck (the Go audit) |
Go projects are not audited, [WARN] |
golang.org/x/vuln |
| OWASP dependency-check | mvn (the Maven audit) |
the maven ecosystem is SKIPped |
fetched by Maven |
| netsec | nothing: built in | always available |
Installing the optional tools
The official install methods for the extras, in one place. Install only what your repositories need:
# GitHub CLI (https://cli.github.com/), then log in, or put export GH_TOKEN=... in ~/.bashrc gh auth login # Python: pip-audit and bandit (pipx keeps them out of the system Python; into ~/.local/bin) pipx install pip-audit pipx install bandit # Go: govulncheck and gosec (into ~/go/bin) go install golang.org/x/vuln/cmd/govulncheck@latest go install github.com/securego/gosec/v2/cmd/gosec@latest # Rust: cargo-audit (into ~/.cargo/bin) cargo install cargo-audit --locked # Ruby: bundler-audit (also installed automatically when gem is available) gem install bundler bundler-audit # .NET: dotnet-outdated (into ~/.dotnet/tools) dotnet tool install --global dotnet-outdated-tool # Node: Yarn and pnpm, if your projects use them npm install -g yarn pnpm # Trivy and grype, with their official install scripts (grype also needs Docker) curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sudo sh -s -- -b /usr/local/bin
Cron and systemd start with a minimal PATH, so the tool adds the usual
per-user tool locations that exist. In front of PATH: /usr/local/go/bin,
$GOBIN, $GOPATH/bin (or ~/go/bin), $CARGO_HOME/bin (or ~/.cargo/bin),
~/.dotnet/tools, and every SDKMAN candidate's current/bin. Behind it, to
fill gaps without overriding your own choices: /usr/local/bin (cron's
default PATH is only /usr/bin:/bin), ~/.local/bin (pipx,
pip --user), ~/.yarn/bin, ~/.local/share/swiftly/bin, $PNPM_HOME (or
~/.local/share/pnpm), $GEM_HOME/bin, and the newest Node.js installed with
nvm ($NVM_DIR, or ~/.nvm).
When a tool is missing
| You see | What it means |
|---|---|
[SKIP] go (go not installed) |
the ecosystem's main tool is missing, so none of its projects run |
[SKIP] maven (no pom.xml found) |
nothing in the tree needs that ecosystem |
[SKIP] [sbt] svc — sbt-updates plugin not configured for sbt 0.13.15 (...): add addSbtPlugin(...) to ~/.sbt/0.13/plugins/sbt-updates.sbt |
the project needs a plugin or a file it does not have; the line says what to add |
[WARN] [go] api — govulncheck not installed, not audited |
the project was updated or listed, but its optional audit tool is missing |
[SKIP] gh (not installed - GitHub checks unavailable) |
the GitHub steps are skipped; everything local still runs |
Install the tool, or silence the line with --no-<name> or the config file.
Installation
Global install with Composer (puts dep-sec-patcher in Composer's global bin
directory, usually ~/.config/composer/vendor/bin or ~/.composer/vendor/bin):
composer global require detain/dep-sec-patcher
As a development dependency of a project:
composer require --dev detain/dep-sec-patcher vendor/bin/dep-sec-patcher --check
From a clone (no composer install is needed to run it, since the tool has no
runtime dependencies; install only to run the tests):
git clone https://github.com/detain/dep-sec-patcher.git dep-sec-patcher/bin/dep-sec-patcher --help
Until a release is tagged, Composer installs need the development version:
composer global require detain/dep-sec-patcher:dev-master.
Targets, state and scope
TARGET is the positional argument (default: the current directory):
| TARGET | Meaning |
|---|---|
path/to/dir |
A local directory, scanned in place. It must not itself be a symlink. |
https://..., ssh://..., git://..., file://..., git@host:path |
A git remote, cloned into the workdir. |
owner/name |
GitHub shorthand for https://github.com/owner/name.git, used when no local directory of that name exists. |
Remote targets. A remote target is cloned into STATE/checkout (or
--workdir) for the run and deleted when the run ends, however it ends: after
the summary, after an error, or on Ctrl-C. Update mode's changes are deleted
with it, so publish them with --push, --pr or --push-direct.
--keep-clone keeps the clone instead, for inspecting what update mode did;
the next run then fetches it and resets it to the tip of the remote's default
branch, deleting untracked and ignored files and warning about local changes,
so a run always starts from a pristine tree. The tool only resets or deletes a
directory it cloned itself (it leaves a marker in .git/) or one that was
empty, and refuses to use anything else. Private
repositories need git credentials that work without a prompt: an SSH key, or
gh auth setup-git for HTTPS. When GH_TOKEN or GITHUB_TOKEN is set (or
harvested, see GitHub integration) and gh is
installed, git authenticates to github.com over HTTPS through gh with that
token, ahead of any stored credential, so clones and pushes use the same
account as the GitHub steps. Git runs with GIT_TERMINAL_PROMPT=0 and SSH in
batch mode, so a missing credential fails instead of hanging.
--subdir PATH limits the scan to part of the target. Paths in the output,
in the reports and in the vulnerability database stay relative to the target
root, which is how GitHub reports manifest paths. Auto-fix and close-fixed only
act on manifests inside the subdirectory, and records for other manifests stay
pending. Pass the repository root as TARGET and narrow it with --subdir; the
tool warns when a TARGET lies inside a git work tree without being its root,
because GitHub's manifest paths would not line up.
--repo OWNER/NAME names the GitHub repository for the GitHub steps. By
default it is parsed from the target: the URL of a remote target, or the
origin remote of a local checkout (git@github.com:o/n.git,
https://github.com/o/n, ssh://git@github.com/o/n and so on). When the target
is not on github.com and no --repo is given, every GitHub step prints a
[SKIP] line saying why. GitHub Enterprise hosts are not detected
automatically; pass --repo and configure gh for the host.
State lives outside the scanned tree, by default under
${XDG_STATE_HOME:-~/.local/state}/dep-sec-patcher/:
dep-sec-patcher/
├── vulns/
│ └── <slug>.json vulnerability database, kept between runs (--vuln-db)
└── <slug>/
├── reports/ the latest run's reports (--report-dir)
├── run.lock only while a run is going
└── checkout/ a remote target's clone, only while a run is going (--workdir, --keep-clone)
Nothing a rerun needs is in <slug>/: the lock and the clone are deleted when
the run ends, and the directory itself when nothing is left in it (the reports
stay until the next run replaces them). The tools' own advisory databases (the
NVD copy dependency-check keeps, Trivy's, govulncheck's, ...) are in each tool's
cache and shared by every target. A database left at the old default,
<slug>/vulns.json, is moved to vulns/<slug>.json by the next run.
The slug is owner__name (lowercased) when there is a GitHub repository, and
<directory-name>-<8 hex chars> otherwise, where the hash comes from the
target's realpath or from its normalised URL. --state-dir puts everything,
the database included (STATE/vulns.json), in one directory of your choosing,
which is never deleted. --vuln-db, --report-dir and --workdir move
individual parts. A state directory, database or report directory inside the
scanned tree is refused: it would be audited, wiped by check mode, or committed.
Options
--opt value and --opt=value are equivalent. dep-sec-patcher --help prints
the same list.
Target and state
| Option | Default | Description |
|---|---|---|
TARGET |
current directory | Directory, git URL or OWNER/NAME to scan |
--subdir PATH |
Scan only PATH inside the target |
|
--repo OWNER/NAME |
from the target's origin / URL |
GitHub repository for the GitHub steps (env GH_REPO) |
--state-dir DIR |
$XDG_STATE_HOME/dep-sec-patcher/<slug> |
Base for the reports, lock and clone, and with this option the database too |
--vuln-db PATH |
$XDG_STATE_HOME/dep-sec-patcher/vulns/<slug>.json (STATE/vulns.json with --state-dir) |
Vulnerability database (env VULN_DB) |
--report-dir DIR |
STATE/reports |
Where reports are written |
--workdir DIR |
STATE/checkout |
Clone location for a remote target, deleted when the run ends |
--keep-clone |
Keep a remote target's clone after the run; the next run refreshes and reuses it | |
--config FILE |
TARGET/.dep-sec-patcher.json if present |
Config file |
Mode
| Option | Description |
|---|---|
--update |
Update dependencies, apply fixes, merge bot PRs, then audit (default; env SECURITY_UPDATE_MODE) |
--check |
Audit only; anything a tool writes into the tree is undone |
--apply-stored |
Only apply fixes recorded in the vuln DB: no audits, scanners or GitHub collection |
--strict |
Exit with status 3 when any check fails |
Publishing
Update mode only (they do nothing with --check), and command line only. See
Publishing the changes.
| Option | Default | Description |
|---|---|---|
--push |
Commit the run's changes to a new branch, push it to origin, and check the starting branch out again. The tree must have been clean at the start |
|
--pr |
Implies --push. Then open a pull request from that branch into the branch the tree started on, merge it, and delete the branch |
|
--pr-merge METHOD |
squash |
How --pr merges: squash, merge, rebase, or none to open the PR and leave it for review. Only valid with --pr |
--push-direct |
Implies --push. Commit straight onto the checked-out branch and push it: no side branch, no PR (a commit that cannot land is pushed to a side branch instead). Cannot be combined with --pr or --branch |
|
--branch NAME |
dep-sec-patcher/<UTC timestamp> |
Branch for --push and --pr (the default looks like dep-sec-patcher/20261018-033000) |
Running
| Option | Default | Description |
|---|---|---|
-t, --threads N |
1 | Concurrent audits (see Concurrency) |
--timeout SECONDS |
1800 | Per-command timeout, 0 = none |
--nvd-timeout SECONDS |
3600 | Timeout for the NVD database update |
--nvd-api-key KEY |
$NVD_API_KEY |
NVD API key for OWASP dependency-check |
--only LIST |
Run only these local checks: ecosystem names and/or bandit, trivy, grype, netsec |
|
--exclude-dir PATH |
Skip a directory: a bare name anywhere in the tree, or a path relative to the target (repeatable) | |
--allow-http PATTERN |
Accept plain-HTTP URLs starting with PATTERN in the netsec check (repeatable; * matches anything) |
|
-v, --verbose |
Stream every tool's output as it runs | |
--debug |
Print each command before running it, and what check mode undid | |
-V, --version |
Print the version | |
-h, --help |
Print the help |
Ecosystems and scanners
| Option | Description |
|---|---|
--no-maven --no-gradle --no-sbt --no-node --no-php --no-python --no-ruby --no-go --no-rust --no-dotnet --no-dart --no-elixir --no-swift --no-r |
Skip an ecosystem |
--trivy / --no-trivy |
Trivy filesystem scan; --no-trivy also disables grype |
--grype / --no-grype |
grype scan of images built from Dockerfiles |
--gosec / --no-gosec |
gosec, run inside the Go audit |
--bandit / --no-bandit |
bandit Python source scan |
--netsec / --no-netsec |
Built-in plain-HTTP / credential / TLS check |
--cvss-threshold N |
Minimum CVSS score that fails a Maven audit (default 7.0) |
Every check is "auto" by default: it runs when its tool is installed and
something in the tree needs it. --<scanner> turns a scanner back on when the
config file turned it off.
GitHub
| Option | Default | Description |
|---|---|---|
--max-issues N |
0 | Maximum open issues to read, newest first (0 = all; read 100 per GraphQL page, about a minute for 6,000 issues) |
--no-github |
Skip every GitHub step | |
--no-merge-prs |
Skip dependency-bot PR triage: no merges, auto-merges or rebase requests | |
--no-auto-fix |
Record vulnerabilities but never edit manifests | |
--allow-major |
Let auto-fix move a dependency to a new major version when that is the only fix (by default such fixes are reported and left pending), and let the updaters cross major lines (Maven's versions plugin, npm audit fix --force, pnpm up --latest, dart pub upgrade --major-versions) |
|
--close-fixed |
Close security issues whose fixes the manifests now declare | |
--close-label NAME |
security: verified fixed |
Label added before closing (created if missing; empty disables) |
--close-delay N |
5 | Seconds between closes |
--close-limit N |
400 | Stop after N closes (0 = unlimited) |
Environment
| Variable | Use |
|---|---|
SECURITY_UPDATE_MODE |
check or update: the default mode |
GH_REPO, VULN_DB |
Defaults for --repo and --vuln-db |
GH_TOKEN, GITHUB_TOKEN |
GitHub token for gh (also read from shell rc files, see GitHub integration) |
NVD_API_KEY |
NVD API key (also read from shell rc files) |
XDG_STATE_HOME |
Base of the default state directory |
HOME, GOPATH, GOBIN, CARGO_HOME, DOTNET_CLI_HOME, SDKMAN_DIR, PNPM_HOME, GEM_HOME, NVM_DIR |
Used to find per-user tool installs (see Tools it uses) |
Config file
Option defaults can live in .dep-sec-patcher.json in the target root, or in
any file passed with --config. Precedence, lowest first: built-in defaults,
the config file, the environment, the command line. On the command line
--only replaces the config's list and re-enables anything it names that the
config disabled, and --exclude-dir / --allow-http add to the config's lists.
{
"$schema": "ignored: keys starting with $ or _ are comments",
"ecosystems": { "r": false, "swift": false },
"scanners": { "trivy": true, "grype": false, "netsec": true },
"only": ["maven", "node", "php", "netsec"],
"threads": 4,
"timeout": 1800,
"nvd-timeout": 3600,
"cvss-threshold": 7.0,
"max-issues": 300,
"close-label": "security: verified fixed",
"close-delay": 5,
"close-limit": 400,
"allow-http": ["intranet.example.com/", "*.corp.example/"],
"exclude-dirs": ["docs", "test/fixtures"],
"allow-major": false
}
| Key | Type | CLI equivalent |
|---|---|---|
ecosystems |
object, ecosystem name to true/false |
--no-<name> |
scanners |
object: bandit, trivy, gosec, grype, netsec to true/false |
--<name> / --no-<name> |
only |
list of check names | --only |
threads |
integer >= 1 | -t |
timeout, nvd-timeout, max-issues, close-delay, close-limit |
integer >= 0 | same name |
cvss-threshold |
number 0 to 10 | --cvss-threshold |
close-label |
string | --close-label |
allow-http |
list of URL prefixes | --allow-http |
exclude-dirs |
list of directory names or relative paths | --exclude-dir |
allow-major |
true / false |
--allow-major |
Unknown keys, unknown ecosystem or scanner names, and values of the wrong type
are errors (exit status 1) that name the file and key, so a typo cannot fall
back silently to a default. For a remote target the file is read from the clone.
The config file belongs to the scanned tree, so some options cannot be set
there: the paths (state directory, database, reports, workdir), the mode, and
the publishing options (--push, --pr, --pr-merge, --push-direct,
--branch), so that a scanned tree can never turn on pushing.
Scanners
| Scanner | Runs | Result |
|---|---|---|
trivy |
trivy fs --scanners vuln,misconfig,secret over the scanned tree, once. If Maven Central rate-limits its POM lookups (HTTP 429), a Retry-After of up to 5 minutes is waited out and the scan retried once; a longer block, or a second 429, reruns it with --offline-scan |
Secrets or high/critical vulnerabilities fail; misconfigurations warn. An offline fallback adds "offline scan" to the result. Report trivy/_root/trivy-fs-results.json. |
bandit |
bandit -r . once, when there are .py files |
HIGH findings fail, others warn. Report bandit/_root/bandit-results.json. |
grype |
Builds an image from each of up to 5 Dockerfiles and scans it; needs grype and docker; images are removed afterwards |
By severity. Skipped silently when either tool is missing, and with one SKIP line when the docker daemon is not reachable. |
gosec |
Inside each Go audit | Finding count as a note; report under gosec/. |
netsec |
Built in, over every indexed file | See below. |
OWASP dependency-check and govulncheck are not separate scanners: they run as part of the Maven and Go audits (see Supported languages and ecosystems).
netsec looks for:
- plain
http://URLs in.yaml,.yml,.jsonand.tomlfiles whose path does not containexampleortest(WARN); - hardcoded
password,api_key,secret,token, AWS access keys and private keys, ignoring placeholders such aspassword_exampleorYOUR_API_KEY(FAIL); TLSv1.0,TLSv1.1orsslversionin config and.propertiesfiles (WARN).
Lockfiles, binary files and files over 2 MB are not scanned.
Plain-HTTP URLs netsec accepts by default
Identifier-like URLs are not endpoints, so these prefixes (after http://) are
allowed: localhost, 127.0.0.1, 0.0.0.0, [::1], www.w3.org/,
json-schema.org/, www.apache.org/licenses, opensource.org/,
maven.apache.org/, xmlns.*, schemas.*, purl.org/, example.com,
example.org, example.net and petstore.swagger.io. Extend the list with
--allow-http or the config's allow-http.
GitHub integration
All GitHub work goes through the gh CLI. Each call
has a timeout, and failures are reported as "could not list ..." rather than
read as "nothing found".
Authentication. When gh's active account has no working token
(gh auth status --active, or plain gh auth status on gh before 2.40), the
tool looks for export GH_TOKEN=... or export GITHUB_TOKEN=... in
~/.bashrc, ~/.bash_profile, ~/.profile and ~/.zshrc, takes the last
assignment, and retries. Cron jobs therefore work without sourcing the rc file.
The same harvesting finds NVD_API_KEY. Without gh, or without a working
token, the GitHub steps print [SKIP] or [WARN] lines and the local audits
still run.
Repository. --repo, otherwise the target's GitHub origin (see
Targets), canonicalised through gh api repos/...
so that a renamed repository resolves to its current name.
Collection (every mode except --apply-stored):
-
Dependabot alerts:
repos/{repo}/dependabot/alerts?state=open, all pages. This needs a token that can read security alerts (repository admin, or thesecurity_eventsscope). -
Repository security advisories: published, draft and triage advisories. A 404 counts as none.
-
Security issues: every open issue by default, newest first, read 100 at a time through GraphQL (a failed page is retried on its own with a smaller page, and a rate limit waits for the reset).
--max-issues Nstops after N (a WARN says so; the rest are read as earlier ones close). Their bodies become manifest edits, so only issues opened by a GitHub App bot whose login starts withmend,whitesource,snyk,renovateordependabot(optionally prefixedapp/) are read; anything a person could have written is ignored. Two Mend Bolt / WhiteSource formats are parsed:- the per-CVE format ("Path to dependency file", "Fix Resolution",
"Direct dependency fix Resolution", and the bare coordinate lists): one
remediation record per package, fix version and manifest. A bare list
names packages loosely, so it is narrowed to the libraries the issue
reports, taken in full from the paths Mend found them at (a Maven, Gradle or
Ivy cache path gives the groupId, a
node_modulespath the npm scope, the Go module cache the module path). Alternatives that no manifest uses are dropped (bcprov-lts8onbesidebcprov-jdk18on,tools.jackson.corebesidecom.fasterxml.jackson.core); a bare version or a tarball name gets its full name (core-7.29.0.tgzis@babel/core, not the npm packagecore); and a repository URL (https://github.com/netty/netty.git - 4.1.135.Final) becomes the version of the libraries built there, never a package named after the repository. For a Maven or Go library, whose next major can be published under another name, a repository tag is only taken on a major line in use, or where the list names a sister package with the same groupId at that version: jackson-databind's 3.x tags aretools.jackson.corereleases. An npm, PyPI or NuGet package keeps its name, somultiparty.git - 4.3.0is multiparty's fix whatever line is in use. Each record also keeps the versions the issue found in use in that manifest; - the grouped format, one issue per library (such as
"axios-1.3.5.tgz: 40 vulnerabilities (highest severity is: 9.3)") with a
"Sample Path to Dependency File", a summary table and a
<details>section per CVE: one record per CVE. A CVE whose section GitHub truncated is taken from the table's "Fixed in (X version)" column, and a CVE with no known fix becomes a record without a fixed version (stateskipped), which keeps close-fixed from closing that issue.
Versions from issue bodies must look like versions, and manifest paths that climb out of the tree are dropped. Listing is retried three times before the issue set is given up for the run.
- the per-CVE format ("Path to dependency file", "Fix Resolution",
"Direct dependency fix Resolution", and the bare coordinate lists): one
remediation record per package, fix version and manifest. A bare list
names packages loosely, so it is narrowed to the libraries the issue
reports, taken in full from the paths Mend found them at (a Maven, Gradle or
Ivy cache path gives the groupId, a
The records are merged into the vulnerability database,
and records GitHub no longer reports are marked resolved (see there). On gh
before 2.48, which has no api --slurp, the paginated listings fall back to
--jq.
Repository settings. Reports whether secret scanning, Dependabot security updates and code-scanning default setup are enabled. Secret scanning or Dependabot security updates being off is a FAIL line.
Dependency-bot PR triage. Only non-draft PRs opened by a GitHub App bot
whose login starts with dependabot, renovate, mend, whitesource or
snyk (optionally prefixed app/) are considered; a human's PR is never
touched, even one from an account named like a bot. GitHub computes mergeability lazily, so it is polled for up to 12
seconds. Check mode only lists each PR with its state. In update mode:
- mergeable PRs are squash-merged (branch deleted), or set to auto-merge when required checks are still pending;
- a PR whose checks are failing or still running (GitHub's
UNSTABLEstate) is left for a later run rather than merged; - a PR that moves a dependency to a new major version (below 1.0, a new minor
one) is not merged without
--allow-major. The bumps are read from Dependabot's title and its "Updates `x` from A to B" lines, and from Renovate's`A` -> `B`table rows; - a PR that is behind its base, or has conflicts, gets one
@dependabot rebaseor@renovatebot rebasecomment. The comment carries a hidden<!-- security-update:rebase -->marker, and no new request is posted until the branch has moved; - conflicting PRs from bots without a rebase command get manual rebase instructions;
- PRs the bot refuses to rebase, and PRs blocked by branch protection, are reported for a human.
Auto-fix. Pending records are grouped per manifest and package. For each group:
- a lockfile path is redirected to its source manifest (
package-lock.jsontopackage.json,composer.locktocomposer.json,packages.lock.jsonto the.csproj, and so on); - the version the manifest declares is read (for a transitive npm dependency, the versions its lockfile holds), and the fix is chosen per advisory: the lowest fix that clears the declared version on its own release line (a project on Tomcat 9.0 gets the 9.0 fix, not the 11.0 one), then the highest of those across advisories;
- nothing is written when the declared version already satisfies every advisory, when a Maven/NuGet range already admits a fixed version, or when the fix would move a declared version backwards;
- a prerelease that sorts above every fix without being known to include one
(netty
5.0.0.Alpha2against4.1.137.Final/4.2.17.Final) has no fix to move up to, and going back to a release is not done unasked: it stays pending with a note and a warning saying so; - a fix that needs a new major version is held back unless
--allow-majoris given; - a transitive dependency is pinned instead: npm
overrides/resolutions, or Maven<dependencyManagement>, on the line its lockfile or, without one, its issue says is in use. An npm package that the lockfile holds no copy of is not pinned at all (nothing installs it), and a pin never goes to a prerelease when a release fixes the same advisories; - Maven
${property}versions are bumped at the property definition.
The files it can edit, how each writes the version, and the rules for major
versions and transitive npm dependencies are in
Manifests auto-fix can edit. Each record's
outcome is written back to the database: applied, skipped with the reason
in note, or still pending with a note when it needs a major upgrade, cannot
be edited, or needs a pin by hand. The step ends with a line such as
auto-fix: applied=3 already-ok=5 not-declared=1 missing-manifest=0 cannot-edit=1 needs-major=2.
Check mode works out the same edits without writing anything: it prints
would set npm qs -> 6.15.2 in package.json lines and an
auto-fix (check mode): would-apply=… summary, and the
Next steps section turns them into a command line.
How versions are compared
- A declared requirement is reduced to the lowest version it admits, counting
only lower bounds and exact pins:
^4.17.1and>= 4.17.1give4.17.1,<1.27,>=1.21.1gives1.21.1, and<2gives nothing (it admits any old version, so an issue is never closed on the strength of it). - A union (
^5.4 || ^6.0, Composer's|, Maven's[1,2),[3,)) counts as its lowest alternative. - Pre-releases rank below their release, including the forms without a dash:
4.2rc1,2.0.0.dev1,7.0.0.rc1,1.0.M1. - An advisory lists one fix per release line. A version is fixed when it is at
or above the fix on its own line (Tomcat
9.0.110against9.0.106/10.1.42/11.0.8), or above every fix. Being above some other line's fix is not enough: netty4.2.5.Finalis above4.1.137.Finaland still waits for4.2.17.Final. - A prerelease above every fix may still predate them all (netty
5.0.0.Alpha2is from 2015), so it only counts when its major line had a release before it and no fix was listed for that line: spring-boot4.2.0-M2against a fix in3.0.0does, spring7.1.0-M2against7.0.8does not. Qualifiers that name a flavour of a release (33.4.0-jre,4.1.42.Final) are not prereleases. 1.2and1.2.0are the same version.- Otherwise versions compare like GNU
sort -V, with semver pre-release ordering.
Close-fixed (--close-fixed). For every open issue that has records in the
database, each package it reports is looked up in the current manifests. For
npm and Composer, when a lockfile beside the manifest (including
composer.lock) holds the package, every locked copy must meet the fix
instead, and the evidence shows it as x.y.z (locked). The issue is closed
only when every package is fixed by the rules under "How versions are
compared" above: at or above the fix on its own
release line, or above every fix (a prerelease only as described there). With
--debug, each issue left open says what it still needs
(io.netty:netty-codec-http is 4.2.16.Final, needs >= 4.1.137.Final or 4.2.17.Final).
Anything unverifiable (manifest gone, package not
declared, a CVE with no known fix) stays open. The issue is labelled
--close-label first, then closed with a comment holding the evidence table
(package, required version, declared version, manifest) and a hidden
<!-- security-update:closed-verified --> marker. GitHub limits
content-creating calls to roughly 80 a minute and 500 an hour, so closes are
paced (--close-delay), back off on "submitted too quickly" responses (1, 4,
then 9 minutes), and stop at --close-limit. Re-run to continue. If the list
of open issues cannot be fetched, close-fixed is skipped rather than working
through every issue ever recorded.
It only closes issues on the strength of changes that are on the base branch.
In check mode it lists what it would close. When the run publishes, it runs
after publishing and closes only once the changes landed (pushed with
--push-direct, merged by --pr during the run, or nothing needed changing);
otherwise it lists what it would close. In an update run without a publishing
option, a tree left with uncommitted changes also only gets the list: commit
and push them, or use --push-direct or --pr.
Publishing the changes
In update mode, --push, --pr and --push-direct turn the run's changes
into a commit on origin:
--push |
--pr |
--push-direct |
|
|---|---|---|---|
| The commit lands on | a new branch: --branch NAME, or dep-sec-patcher/<UTC timestamp> |
a new branch, as with --push |
the checked-out branch (for a remote target, the default branch) |
| Pull request | none; with a GitHub repository, the run prints the gh pr create command to open one by hand |
opened with gh pr create into the branch the tree started on (for a remote target, the default branch) |
none |
| Merge | gh pr merge with the --pr-merge method (default squash), then the branch is deleted on origin and locally. With --pr-merge none the PR is left open for review and the branch is kept |
||
If origin moved during the run |
the commit is rebased onto origin's copy of the starting branch before the push; if it conflicts, it is pushed un-rebased so the conflict shows up in review |
as with --push |
the commit is rebased onto origin's copy and the push retried once |
| When it cannot go through | the failed push is reported | blocked by required checks: auto-merge is enabled instead, and the run says so. Conflicting, or not mergeable at all: the PR is left open and reported | a conflict, a protected branch or a rejected push: the commit is moved to dep-sec-patcher/<UTC timestamp> and pushed there for you to merge by hand, and the local branch is reset to where it started |
| The working tree afterwards | back on the branch it started on | back on the branch it started on; after a merge, a local checkout's base branch is fast-forwarded to the merged result when possible | on the same branch, with the new commit |
| Needs | push access to origin |
push access, plus gh authenticated for a GitHub repository; without them the branch is still pushed and the run says to open the PR by hand |
push access to the branch, and a checked-out branch (a detached HEAD is refused) |
All three:
- need TARGET to be the top of a git work tree that was clean when the run started, checked before anything touches it (a remote target's clone always is clean), so nothing but this run's changes can end up in the commit. Otherwise nothing is committed and the run says why;
- change the order of the run: dependency-bot PRs are triaged first and the
checked-out branch is fast-forwarded to
origin's copy (unless it has diverged), so the dependency updates are made on top of the bot PRs just merged instead of conflicting with them; - commit everything the run changed: updated manifests and lockfiles, lockfiles update mode created, and the fixes auto-fix wrote. When the run changed nothing, nothing is committed;
- write a commit message (and, for
--pr, a PR body) that lists the fixes auto-fix applied, such asnpm qs -> 6.15.2 in web/package.json, and the files changed; - commit with
git commit --no-verify(hooks do not run), asdep-sec-patcher <dep-sec-patcher@users.noreply.github.com>when git has nouser.emailconfigured; - can only be given on the command line, never in the config file, because the config file belongs to the scanned tree;
- do nothing in check mode.
The outcome is the push row of the summary and the JSON report: PASS when the
commit was pushed (for --pr: and the PR was merged, set to auto-merge, or
left open with --pr-merge none), SKIP when nothing changed, and FAIL when the
push, the pull request or its merge failed, or --push-direct had to fall back
to a side branch. --close-fixed runs after publishing and only closes issues
once the changes are on the base branch (see
Close-fixed above).
--pr-merge is only valid with --pr, and --push-direct cannot be combined
with --pr or --branch; these are usage errors (exit status 1).
NVD API key
Maven projects are checked with OWASP dependency-check, which needs a local copy of the NVD vulnerability database. Without an API key NVD throttles a client to about 5 requests every 30 seconds. Once the local copy is more than 120 days old, a refresh is a full download of roughly 370,000 records, which takes hours without a key and minutes with one. An interrupted refresh records nothing, so the next run starts again from scratch. A keyless run therefore looks hung.
The tool refreshes the database once per run, before any project is
checked, using dependency-check:update-only with --nvd-timeout (default one
hour). Every project is then checked with -DautoUpdate=false. If the refresh
fails, the checks run against the existing copy and are marked as possibly
stale.
Get a free key at https://nvd.nist.gov/developers/request-an-api-key, then
either export NVD_API_KEY=... (in ~/.bashrc is enough for cron) or pass
--nvd-api-key. The key reaches Maven through the environment
(-DnvdApiKeyEnvironmentVariable=NVD_API_KEY), never on the command line,
where other users could read it with ps. The run warns when no key is set.
Vulnerability database
dep-sec-patcher/vulns/<slug>.json (STATE/vulns.json with --state-dir, or
--vuln-db) is a JSON file per target holding one record per vulnerability,
package and manifest. It is per target because its records are: they name that
repository's manifest paths and issue numbers, and what was applied there.
sourceisdependabot,advisoryorissue. Keys areecosystem|package|manifest|idfor alerts,ecosystem|package||ghsafor advisories, andmanifest|package|idfor issues. An issue refiled for the same CVE, package and manifest therefore updates the existing record, and its number is added torefs.stateispendinguntil auto-fix handles the record, thenappliedorskipped, with the reason innote; a fix held back (a major upgrade, a spec it cannot edit) stayspendingwith a note. A record can also arrive with its own state, such as a Mend CVE with no known fix (skipped). Re-collecting a record keepsfirst_seen,state,applied_atandnote, and unionsrefsandfix_versions.- An issue read again replaces what it reported before: a record it no longer
yields (the bot edited the issue, or an older version of this tool named the
wrong package, such as
babelfor@babel/core) drops that issue from itsrefs, and is deleted when no issue is left. Records of issues not read in the run are untouched. - After a complete listing, a
pendingDependabot record whose alert is no longer open, and apendingissue record none of whose issues is still open, becomeresolvedwith a note. Issue records are only retired when every open issue was listed, so a listing cut short by--max-issuesretires nothing. - Writes are atomic (a temp file in the same directory, renamed over the original) and the file is mode 0600. A corrupt file is reported and treated as an empty database, which the next save replaces.
--apply-storedreplays the database without collecting from GitHub or running audits and scanners.--close-fixedand the publishing options still work alongside it.
File format
{
"schema": 1,
"repo": "owner/name",
"updated": "2026-10-16T20:00:00Z",
"vulns": {
"npm|qs|web/package-lock.json|GHSA-xxxx-xxxx-xxxx": {
"key": "npm|qs|web/package-lock.json|GHSA-xxxx-xxxx-xxxx",
"source": "dependabot",
"id": "GHSA-xxxx-xxxx-xxxx",
"cve": "CVE-2025-0000",
"ref": 324,
"refs": [324],
"ecosystem": "npm",
"package": "qs",
"manifest": "web/package-lock.json",
"manifests": ["web/package-lock.json"],
"scope": "runtime",
"severity": "medium",
"cvss": 5.3,
"vulnerable_range": "< 6.15.2",
"fixed_version": "6.15.2",
"fix_versions": ["6.15.2"],
"direct": true,
"summary": "...",
"url": "https://github.com/owner/name/security/dependabot/324",
"first_seen": "2026-10-01T00:00:00Z",
"last_seen": "2026-10-16T20:00:00Z",
"state": "pending",
"applied_at": null,
"note": ""
}
}
}
Reports
Everything is written under the report directory, never into the scanned tree:
reports/
├── security-scan-report.json
├── maven/<project path, / replaced by __>/dependency-check-report.json
├── gosec/<project path>/gosec-results.json
├── bandit/_root/bandit-results.json
└── trivy/_root/trivy-fs-results.json
security-scan-report.json summarises the run. Its ecosystems object holds
one row per ecosystem, scanner and GitHub step: the ecosystem names, bandit,
trivy, grype, network-security (the netsec check), dependabot,
advisories, sec-issues, secret-scanning, dep-prs, auto-fix,
close-fixed and push. With several projects per ecosystem the worst status
wins and the counts are added.
Example report
{
"tool": "dep-sec-patcher 0.1.0",
"scan_timestamp": "2026-10-16T20:00:00Z",
"root_dir": "/path/to/target",
"subdir": "",
"target": "/path/to/target",
"github_repo": "owner/name",
"mode": "check",
"cvss_threshold": 7.0,
"summary": { "pass": 26, "fail": 33, "warn": 4, "skip": 16, "info": 24 },
"ecosystems": {
"node": { "status": "FAIL", "vulnerabilities": 195, "outdated_packages": 0, "last_scan": "2026-10-16T20:00:00Z" }
},
"tools": { "trivy": { "enabled": true }, "gosec": { "enabled": true }, "bandit": { "enabled": true },
"grype": { "enabled": true }, "netsec": { "enabled": true } }
}
Exit status
| Status | Meaning |
|---|---|
| 0 | The run completed, whatever it found |
| 1 | Usage, config or setup error: unknown option, conflicting options, invalid config file, target not found, clone failed, state inside the tree, or another run holds the lock |
| 3 | --strict and at least one check failed |
| 129, 130, 143 | Stopped by SIGHUP, SIGINT or SIGTERM; running tools were stopped too |
Concurrency
-t N runs up to N audits at once in forked workers, and reports results as
they finish. Some package managers corrupt a shared cache when two copies run
at the same time, so each tool has a concurrency group with its own limit, and
a job only starts when its group has room. Jobs from different groups still
overlap: a serialised Maven build runs alongside npm audits.
| Tool | Concurrent from | Below that, or always |
|---|---|---|
| Maven | 3.9.0 (resolver named file locks, enabled with -Daether.syncContext.named.factory=file-lock) |
serial: no cross-process locking on ~/.m2 |
| Yarn | 2.0.0 | serial: Yarn 1 can corrupt its cache |
| Bundler | 2.6.0 | serial: unprotected compact-index cache |
| sbt | 1.3.0 (Coursier) | serial: Ivy cache |
| Composer, dotnet/NuGet, dart, swift, mix | always serial: no concurrency-safe release known | |
| npm, pnpm, Gradle, Cargo, Go, pip-audit, R | always |
At startup a [WARN] line names each version-limited tool, its detected
version and the version to upgrade to, for example:
[WARN] maven 3.8.7 runs one job at a time: Maven before 3.9 has no cross-process locking on the
shared ~/.m2 repository, so concurrent runs can corrupt downloaded artifacts. Upgrade to
maven >= 3.9.0 to run it concurrently.
[INFO] One job at a time regardless of version (no concurrency-safe release known): composer 2.10.1, dotnet 10.0.112
Two audits of the same directory, such as a package.json and a
composer.json side by side, never run at the same time, whatever -t is.
Without the pcntl extension, or with -t 1, everything runs one job at a time
in-process.
Safety guarantees
- Timeouts. Every external command has a timeout (
--timeout, and--nvd-timeoutfor the NVD refresh). A command that times out gets SIGTERM, then SIGKILL 10 seconds later. - Process groups. With
setsidand theposixextension available, each command runs in its own session, so a timeout or Ctrl-C stops the whole tree it started, not just the top process. Anything a command leaves running in its session after it exits (MSBuild nodes, compiler servers) is killed. .NET runs with node reuse and build servers disabled. - No prompts. stdin is
/dev/null, git runs withGIT_TERMINAL_PROMPT=0and SSH in batch mode, and package managers run non-interactively. A tool that wants input fails; it does not wait. - Progress. A command that has been silent for 60 seconds produces a
"still running after ..." line.
-vstreams all output. - Workspace guard. Before each audit the project directory's top level (and
sbt's
project/) is snapshotted. In check mode, afterwards, every new entry is deleted and every changed file restored. The contents of build and dependency directories that already existed are not snapshotted, so a tool may refresh caches inside them. In update mode only new build and dependency directories are removed (node_modules,vendor,target,obj,bin,build, ...), and aproject/that sbt created for nothing but its own output goes with them, so--pushcannot commit it. Entries that existed before are never deleted, and aprojectthat is a symlink is never looked into. Read-only directories left by Go and Cargo are made writable before removal. - Third-party code. npm runs with
--ignore-scriptsand lockfile-only; Composer with--no-scripts --no-plugins --no-install; Bundler resolves without installing gems. - Run lock. One run per target at a time (
STATE/run.lock,flock). The lock is opened close-on-exec, so a daemon a tool leaves behind cannot keep holding it. The run deletes the file while still holding the lock, and a starting run checks that the file it locked is still the one at that path, so two runs never hold locks on different files. - Deleting the clone. The clone is moved aside before it is deleted, so a
deletion cut short never leaves a half-deleted tree in the next run's way.
Symlinks inside it are unlinked, never followed or
chmoded (a cloned repository can link anywhere on the host), and only a clone the tool made, or an empty directory, is ever deleted. - Workers. A forked worker writes its result to a temp file and then kills itself with SIGKILL, so shutdown handlers and destructors inherited from the parent never run twice. SIGINT, SIGTERM and SIGHUP are passed on to workers and to every running command; the handlers are installed before the first clone, so Ctrl-C during it stops git too. Before exiting, a stopped worker runs the workspace guard of the audit it was in, so Ctrl-C in check mode still undoes that audit's changes.
- Untrusted input. Issue bodies are only read from GitHub App bots, and dependency-bot PRs are only touched when a GitHub App bot opened them. Package names and versions taken from issues and advisories must look like names and versions before they reach a manifest, and every manifest path is resolved and must lie inside the tree before it is edited.
- State stays outside the tree, a symlinked TARGET is refused,
--subdircannot escape the target, and only clones the tool made itself are ever reset. - Publishing is opt-in and contained. Nothing is committed or pushed
without
--push,--pror--push-directon the command line, only the paths the run changed that were clean before it are committed (whatever was already uncommitted stays out of the commit and in the tree, and a file that was dirty and that the run changed too is left out with a warning), and the config file cannot enable any of it. - Secrets.
-D...key/token/password/secret=arguments and URL credentials are masked in printed commands; the NVD key is passed through the environment. - Terminal output. A tool's output is printed without its colour codes and other escape sequences, so a build in a cloned repository cannot set the terminal's title, hyperlinks or cursor through this tool.
Troubleshooting
It looks hung. Almost always the NVD refresh without an API key: see
NVD API key. Run with -v to watch each tool's output, or
--debug to see each command. Every command prints a "still running" line
after a minute of silence, and nothing runs longer than --timeout.
The first run is slow. The first run fills several caches: the NVD
database, ~/.m2, the npm, Composer, Cargo and Go module caches, and the
Trivy, govulncheck and bundler-audit advisory databases. Later runs reuse them.
A fix stays pending. The auto-fix line and the record's note say why:
it needs a major upgrade (rerun with --allow-major), the declared spec is one
it cannot edit (a union, a git or file spec, a dist-tag, a --hash file), or
the lockfile (or the issue) has the package in use on several major lines and
it needs a pin by hand. The "Next steps" section at the end of the run suggests
the command.
Gradle X runs on Java N at most. The project's wrapper predates the
host's Java and no JDK under /usr/lib/jvm is old enough for it. Install one
(apt install openjdk-17-jdk-headless, for instance, covers Gradle 7.3 to
8.4), or update the wrapper.
A project is SKIPped. The reason is on the line: tool not installed, no
lockfile to audit, or a Gradle/sbt plugin that is not applied. Install the tool
(see Tools it uses), or silence it with --no-<ecosystem> or
the config file.
Nothing was pushed. The publishing options only act in update mode, on a
TARGET that is the top of a git work tree, when the run changed something in
a path that was clean before it; the run prints which of these stopped it, and
names the paths it left out because they were already uncommitted.
--push-direct also needs a checked-out branch rather than a detached HEAD.
Directories that cannot be deleted after a run was killed with SIGKILL:
Go's module cache and some Cargo outputs are read-only. Use
chmod -R u+w DIR && rm -rf DIR, or go clean -modcache for the Go cache.
Normal runs clean up after themselves.
another run is already in progress (pid N). Another run holds this
target's lock. If that process is gone, the lock was already released when it
exited (the lock is an flock, not the file's existence; a run that was
killed with SIGKILL leaves the file, and the next run simply takes it).
Dependabot: could not list alerts. The token cannot read the repository's
security alerts. That needs admin access or the security_events scope.
gh is installed but not authenticated. Run gh auth login, or put
export GH_TOKEN=... in ~/.bashrc.
refusing to use DIR: it exists and is not a clone made by dep-sec-patcher.
--workdir points at a directory the tool did not create. Choose an empty or
non-existent directory.
Trivy says "offline scan". Maven Central answered Trivy's parent-POM
lookups with 429 Too Many Requests. It rate-limits by IP, and the Maven
audits earlier in the same run count against the limit. A block of up to five
minutes is waited out once; anything longer (Central has asked for 1600
seconds) makes Trivy rescan with --offline-scan. That run still reports
vulnerabilities, misconfigurations and secrets, but Java dependencies that
only resolve through a remote parent POM can be missing. The next run, once
the block has cleared, scans online again.
Architecture
bin/dep-sec-patcher finds an autoloader (Composer's, or a built-in PSR-4 one)
src/Application options, TARGET/state resolution, config, lock, signals
src/Options, Config command line and config file, merged in precedence order
src/GitUrl, Target, remote URL parsing, TARGET classification and clones,
TargetResolver, state directory derivation, tool PATH
StatePaths, Environment
src/Filesystem recursive deletion that never follows symlinks
src/SecurityRun the run: audits, scanners, GitHub, auto-fix, close-fixed, publishing, summary
src/ProcessRunner timeouts, process groups, heartbeat, output capture
src/ForkPool forked workers with per-group concurrency limits
src/WorkspaceGuard undoes what tools write into the tree
src/ManifestIndex one walk of the tree shared by every ecosystem
src/Auditor/* one class per ecosystem (SimpleAuditor covers dart, elixir, swift, r)
src/Scanner/* trivy, bandit, grype, and the netsec checks
src/GitHub, TokenHarvester gh wrapper and token discovery
src/IssueParser, GitHub data to vulnerability records
RecordNormalizer
src/VulnDb, FixPlanner the database and the fix/close decisions
src/Version GNU `sort -V` port, prereleases, floors, unions, Maven/NuGet ranges
src/ManifestEditor reads and rewrites declared versions in each supported manifest format
src/Lockfile versions a yarn/npm/pnpm/composer lockfile resolved a package to
src/BranchPublisher git side of --push, --pr and --push-direct: commit, rebase, push
src/NextSteps the command a "Next steps" section suggests
The pure classes (Version, IssueParser, RecordNormalizer, VulnDb, FixPlanner, ManifestEditor, ManifestPaths, JsonFile, GitUrl, StatePaths, Config, Options, NetworkScanner, NextSteps) do no I/O and carry most of the tests. Comments that mention "the original shell script" describe the bash implementation this replaced, and explain why the code does what it does.
Development
composer install composer test # PHPUnit composer test:coverage # PHPUnit with a Clover report in build/logs/clover.xml (needs pcov or xdebug) composer analyse # PHPStan, level 8, no baseline
The tests need git and a POSIX shell and do not use the network: a local bare
repository stands in for a remote, and the end-to-end tests run only the checks
that need no external tools. CI runs the suite and PHPStan on PHP 8.1 to 8.4,
and uploads coverage from the PHP 8.3 job.
Migrating from update-samples-security
dep-sec-patcher replaces MyAdmin's scripts/api/update-samples-security.sh.
Every option keeps its name and meaning, except for the project profiles, which
were specific to that repository.
| update-samples-security | dep-sec-patcher |
|---|---|
update-samples-security.sh [DIR] |
dep-sec-patcher [TARGET]; TARGET may also be a git URL or OWNER/NAME |
| Default root: the detected profile's samples checkout | Default TARGET: the current directory |
--profile NAME, --project DIR, --list-profiles |
Removed. Pass the directory as TARGET, and narrow it with --subdir |
API_SAMPLES_PROFILE, API_SAMPLES_PROJECT |
Removed (ignored) |
--repo default: origin remote, then the profile's samples repo |
Origin remote only; without one the GitHub steps SKIP |
Vuln DB default: the parent project's logs/security-vulns.json |
$XDG_STATE_HOME/dep-sec-patcher/vulns/<slug>.json. Pass --vuln-db to keep using an existing database: the format is unchanged |
Reports: logs/security-reports/ beside the database |
STATE/reports/ |
Lock: .update-samples-security.lock in the report directory |
STATE/run.lock |
Output prefix [security-update] |
[dep-sec-patcher] |
| Always exits 0 after a run | Still 0; --strict adds status 3 on failures |
New: --subdir, --state-dir, --workdir, --keep-clone, --config, --exclude-dir, --allow-http, --grype/--no-grype, --netsec/--no-netsec, --push, --pr, --pr-merge, --push-direct, --branch, --allow-major, --strict, -V |
|
PR markers <!-- security-update:rebase --> and <!-- security-update:closed-verified --> |
Unchanged, so rebase requests posted by the old tool are still recognised |
The old default run is equivalent to:
dep-sec-patcher PROJECT/vendor/detain/interserver-api-samples \
--vuln-db PROJECT/logs/security-vulns.json \
--report-dir PROJECT/logs/security-reports
License
MIT, see LICENSE.