@nekoruri ちょうど今そういうの作ってますわ!
future-architect/uzomuzo-oss
vuls.biz
2 likes · 96 views
A maintained package can still ship malware. What a health check can and can't see.
---
title: "A maintained package can still ship malware. What a health check can and can't see."
published: true
description: "A hijacked package still looks maintained, because it is. How uzomuzo now flags malicious versions from OSV, and why it marks a release after a year of silence for 7 days."
tags: security, opensource, npm, supplychain
---
On September 8, 2025, the maintainer of chalk and debug was phished, and malicious versions of those and other widely used npm packages were published from that account. The maintainer disclosed it within hours, and clean versions were out the same afternoon. chalk had shipped 5.6.0 three weeks earlier. By any maintenance measure, it was a maintained package.
uzomuzo, the dependency health checker I maintain, judges dependencies by exactly that kind of measure. Its job is to find dependencies with maintenance or lifecycle concerns. Until v0.6.0, uzomuzo scan pkg:npm/chalk@5.6.1 returned ✅ ok … Active. A hijacked release of a maintained package is the case that kind of check can't see.
This post covers the two things we added to narrow that gap, the numbers behind each decision, and what still gets through:
1. Malicious versions: read OSV's malicious-package records and stop the versions they name.
2. Before a record exists: for 7 days, mark a release that comes after a year of silence.
1. Stopping versions that OSV says are malicious
The OpenSSF malicious-packages project publishes records with IDs starting with MAL-, served through OSV. For a hijack, a record usually names the specific versions that were malicious; for a package that was malicious from the start, it covers every version.
Here's what uzomuzo v0.10.0 shows (output excerpt, October 26, 2026):
$ uzomuzo scan pkg:npm/chalk@5.6.1 pkg:npm/chalk@5.6.2 pkg:cargo/rustdecimal@1.23.1
STATUS PURL LIFECYCLE BUILD
🔴 replace pkg:npm/chalk@5.6.1 Active ☠ MAL-2025-46969 —
✅ ok pkg:npm/chalk@5.6.2 Active Moderate 4.4
🔴 replace pkg:cargo/rustdecimal@1.23.1 Review Needed ☠ MAL-2022-1 —
Three design choices are visible here.
The lifecycle label doesn't change. chalk is still Active, because it is. Whether a project is maintained and whether one of its versions is malicious are separate facts, so the malicious record sits next to the label (☠ MAL-…) instead of replacing it. Other tools that read the label keep working.
The match is by version. Chalk's record names 5.6.1, so 5.6.2, the clean release that followed, isn't affected by it. A flag on the whole package would push people away from the fix. (rustdecimal's record covers every version, because that package was malicious from the start.)
Any CI gate catches it. If --fail-on names anything at all, a matched malicious version fails the build. A pipeline that only ever wrote --fail-on eol-confirmed will stop on chalk@5.6.1 too:
$ uzomuzo scan pkg:npm/chalk@5.6.1 --fail-on eol-confirmed > /dev/null; echo $?
1
Two conditions apply. Without --fail-on, findings alone don't make uzomuzo exit with an error (command errors still do). And if the OSV lookup fails (no network, for example), uzomuzo warns that the check is incomplete; it doesn't fail the build on that alone.
No waiting period
uzomuzo already waits 14 days before acting on a RustSec "unmaintained" advisory, because those advisories are sometimes withdrawn and filed again. For malicious-package records there is no waiting period. Chalk's record was published about four hours after the malicious version (13:13 → 17:11 UTC, the same day). Waiting a few days before trusting such a record would let the version through during exactly the days it matters.
That has a cost, and we accepted it. In June 2024, MAL-2024-2929 named react@1.0.0 and react@35.0.0 as malicious. Those versions don't exist on npm, and the record was withdrawn about six days later as "False positive caused by problematic ingestion". Nobody's build could have broken on that one. But if a record like it named a version people actually use, our rule would fail every gate on that version until the record was withdrawn. The flag clears on the next run after a withdrawal; it doesn't undo a build that already failed.
What it misses
uzomuzo only acts on records that say, in a field a machine can read, that the code is malicious: a MAL- ID, or a GitHub advisory tagged CWE-506 ("embedded malicious code"). ctx 0.2.6 on PyPI was hijacked in 2022. Its advisories list the affected versions and describe the takeover in prose, but none carries that classification. We considered reading the prose and decided against it: in a CI gate, misreading free text is expensive.
2. Before a record exists
A record needs time to appear. We looked at npm records in the OpenSSF data (as of October 8, 2026) that name only some versions of a package, kept packages that had existed for at least a year with at least three normal releases before the malicious one, and treated those as takeovers. That left 1,556 packages.
For the 1,515 of them with a usable timestamp, the median time from the first malicious publish to the earliest timestamp in its record was about 6 hours. 89% were within a day, and 98% within 7 days. That timestamp approximates when the malware was found, not when OSV started serving the record.
Those figures are flattered by a few huge events. Most of the 1,556 packages belong to bulk account compromises, where one stolen account published to every package it owned and records came quickly (1,418 packages, 99% within 7 days). Outside those waves the tail is longer: of 97 packages, 86% had a record within 7 days, and 8% took more than 30 days.
So for hours, sometimes for weeks, a check that only reads records says nothing. Can anything be said in that window?
What these packages looked like before the attack
In 737 of the 1,556 packages (47%), the malicious version was the first release in more than a year.
That number needs three qualifications.
- It's mostly a few events. 724 of the 737 come from 13 bulk account compromises — the Shai-Hulud waves, the September 2025 chalk/debug phishing, and others. Outside those waves, only 13 of 103 packages had been quiet for a year.
- It doesn't show that attackers prefer quiet packages. On the dates of those waves, between about a quarter and a half (27–53%) of the same packages that hadn't been hit yet had also been quiet for a year. That fits bulk attacks publishing from accounts that already held many quiet packages; it says nothing about which packages attackers prefer across npm.
- It compares different units. 47% is per hijacked package. Among the normal releases of the same packages, about 0.5% came after a year of silence (counting every release), or 6.8% if you look only at the last normal release before the attack.
What the numbers support is narrow: a release after a year of silence is uncommon among normal releases and common among these malicious ones. That makes it a reason to look at a release, not evidence of compromise.
The marker
So for npm packages, uzomuzo now marks a stable release that comes after at least a year without releases. Here's a benign release that triggered it. This is an excerpt from a v0.8.0 scan captured on October 15, 2026; the marker expired on October 18, so running the command today won't show it.
$ uzomuzo scan pkg:npm/dateformat@5.0.4
STATUS PURL LIFECYCLE BUILD
✅ ok pkg:npm/dateformat@5.0.4 Active ⚠ 1695d silence Moderate 5.0
The detailed view explains it:
⚠ Release after silence: This version came out 2026-10-11,
after 1695 days with no release.
Takeovers of npm packages that
had gone quiet looked like this,
and so does a maintainer who
ships again after a break. No OSV
malicious-package record names it
yet. Before using it, check who
published it (npm view
<package>@<version> _npmUser).
This marker clears on 2026-10-18.
The verdict stays ok. We did what the message says: npm view dateformat@5.0.4 _npmUser shows the same account that published 5.0.3 four years earlier, and 5.0.5 followed on October 24. That's what a project coming back after a break looks like.
The publisher check is a starting point, not proof. In a takeover the account name stays the same, and a legitimate handover changes it. Look at the release itself and at what the maintainers have said, too.
The marker covers the first release after the silence, plus anything published within a day of it, since a follow-up the next day comes from the same silence. dateformat@5.0.3, the release before the silence, has no marker.
Why 7 days
The marker is a bridge until a record can take over. Once an OSV record names the version, the first mechanism stops it, whatever its age.
!Timeline of September 8, 2025: chalk@5.6.1 and ansi-styles@6.2.2 were published maliciously within a minute. chalk had released 22 days earlier, so nothing flagged it until its MAL record about 4 hours later. ansi-styles had been silent for 1,061 days, so the release-after-silence marker would have covered it from the first minute until the record took over.
Two packages from the same attack, with today's rules applied to the published timestamps. The marker only helps the one that had been quiet. So the question is: for takeovers that started with a release after a year of silence, how long did the record take?
The percentages above count packages, so a bulk wave of hundreds of packages counts hundreds of times. For choosing a marker period the right unit is the incident, so we counted again: incidents whose first malicious version came after a year of silence, a bulk wave counted once, timed from the first malicious publish to when the record entered the OpenSSF data. 27 incidents qualified: 20 that touched one release line (such as 5.x), and 7 that touched two or more at once. An incident qualified if at least one of its packages matched, and the earliest publish and the earliest record can belong to different packages. So these counts describe timing per incident, not a record for every affected version.
| Marker kept for | One-line incidents with a record by then (of 20) | Multi-line incidents with a record by then (of 7) |
|---|---|---|
| 1 day | 11 | 6 |
| 3 days | 13 | 7 |
| 7 days | 16 | 7 |
| 14 days | 17 | 7 |
| 365 days | 19 | 7 |
One one-line incident has no import time; it stays in the count but never counts as recorded. The other three that took longer than 7 days took about 12.6, 57 and 310 days.
Going from 7 to 14 days would have added one incident. A year would have added three, and kept every ordinary return from a break flagged for a year. So 7 days is a policy whose window held a record for 16 of the 20 one-line incidents and all 7 multi-line ones we've seen so far. It isn't proof that 7 days will be enough next time, and when the marker expires, that says nothing about whether the release is safe.
This incident count is a separate selection from the package counts above. It was made outside the uzomuzo repository: the design record publishes the results and limitations, but not the full selection.
Why it doesn't fail CI by default
On the lockfiles of VS Code and the npm CLI, 186 of about 6,100 and 146 of about 2,600 stable releases in the year to October 2026 would have carried the marker. A team that adopts and scans every release within its first week could have run into all of them. None of them is known to be malicious.
So by default the marker only shows up in the output. To fail CI on it:
uzomuzo scan --sbom bom.json --show-transitive --fail-on dormant-release
One exception: when a quiet package returns with releases on two or more lines at once (as node-ipc did in May 2026, with three malicious versions in 55 seconds), an Active or Legacy-Safe label also becomes Review Needed for those 7 days, so an existing --fail-on review-needed gate fails too.
What it can't catch
- Attacks on recently active packages. chalk itself had released three weeks earlier, so there was no silence to mark. A release cooldown can delay adopting such a version, and a matching record identifies it once one exists; neither guarantees it's caught in time. (Several other packages published from the same account that day had been quiet for years, and would have carried the marker.)
- Patient attackers. An attacker who ends the silence with a harmless release, then publishes the malicious one more than a day later, walks past the marker.
- Takeovers that never get a record. Some older incidents have only a GitHub advisory tagged as malicious code, which uzomuzo also reads. Some have nothing.
Turn on a release cooldown too
The marker can't help with the attack this post opened with. What would have helped is simply not taking a brand-new release. chalk@5.6.1 was gone from npm within about 2 hours 40 minutes, so a team that waited even one day before adopting new releases would never have installed it.
So, separately from uzomuzo, we recommend a release cooldown in whatever updates your dependencies. It doesn't detect anything; it just gives the ecosystem time to notice.
Dependabot (.github/dependabot.yml):
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
Renovate (renovate.json):
{
"minimumReleaseAge": "3 days"
}
pnpm (pnpm-workspace.yaml, in minutes; pnpm 11 waits one day by default):
minimumReleaseAge: 4320
Two caveats. Dependabot's cooldown applies to version updates, not security updates, so a fix for a known vulnerability still arrives right away; that's usually what you want. And a cooldown is a delay, not a guarantee: event-stream's malicious version went unnoticed for about 72 days.
The two work together. A cooldown holds back every new release for a few days. The marker picks out the small share of releases that end a long silence and asks you to look at them. The MAL record stops the version once someone has identified it.
In v0.10.0 we also added a quieter notice for any selected version that is 7 days old or less, in any ecosystem, when uzomuzo can find its publish time. It's always in the JSON output, and appears in the detailed view unless the release-after-silence marker or a malicious record already covers the version. It doesn't change the verdict or fail CI. It tells you that you're early, before records have had time to arrive.
Try it
go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@v0.10.0 # or download a release binary
export GITHUB_TOKEN=... # optional; adds commit history and repository signals
# One package
uzomuzo scan pkg:npm/chalk@5.6.1
# A whole project, via a CycloneDX SBOM
trivy fs . --format cyclonedx | uzomuzo scan --sbom - --show-transitive --fail-on malicious
Include --show-transitive. Without it, uzomuzo skips the dependencies your SBOM marks as transitive, and chalk and debug usually arrive as transitive ones. What gets checked also depends on what your SBOM contains.
The malicious-version check needs v0.6.0 or later, and the release-after-silence marker v0.7.0 or later. The design records behind this post are ADR-0027 and ADR-0028.
---
Earlier posts on uzomuzo: "Your dependencies are 48% unmaintained — and SCA tools can't see it" and "Your CI runs other people's code. Is anyone still maintaining it?"
Kota Kanbe — Creator of vuls, an open-source vulnerability scanner, and maintainer of uzomuzo.
2 comments
We cut our scanner binary by 68% — and removing packages one by one wasn't how
---
title: "We cut our scanner binary by 68% — and removing packages one by one wasn't how"
published: true
description: "Two weeks of replacing dependencies one by one barely changed our dependency list. Removing one integration layer cut the scanner binary from 106.6 MB to 34.1 MB. Here's how, and how we tested it."
tags: security, opensource, go, devops
cover_image:
---
Finding unmaintained dependencies is the easy part. Removing them is where it gets hard.
This is the story of how we slimmed down vuls, the open-source vulnerability scanner I created — what didn't work, what did, and the tooling we built after doing it all by hand.
The easy win: 15 minutes
mitchellh/go-homedir has been archived for years. Vuls used it in a couple of lines in two source files:
- import "github.com/mitchellh/go-homedir"
- dir, err := homedir.Dir()
+ dir, err := os.UserHomeDir()
Fifteen minutes, start to finish. The standard library has done this since Go 1.12.
Trivy used the same package, so we sent them a similar fix. The Trivy maintainers reviewed and merged it within three weeks.
We felt good. Let's keep going.
The part where almost nothing happened
We kept going. Over two weeks we replaced 9 direct dependencies with standard-library code or a few lines of our own. go.mod went from 60 direct dependencies to 51.
Then we looked at what had actually left the build. go.sum went from 1,220 lines to about 1,210. Only 5 modules disappeared completely. Most of the packages we had stopped calling were still required by other modules, so they stayed in the dependency list anyway.
Adding a dependency takes one line. Removing one can take real work and barely change what you ship.
Just like a real diet.
What worked: removing a whole layer
So we stopped picking off packages one by one and looked at which layer they came in through.
Vuls uses Trivy's parsers to read lockfiles (package-lock.json, go.mod, Cargo.lock, and so on). We called them through fanal, Trivy's analysis framework. fanal is built for everything Trivy scans — container images, disk images, filesystems, infrastructure-as-code. That breadth is exactly what Trivy needs. Our scanner only needed the part that reads lockfiles, but the way we had wired fanal in registered 25+ analyzers through blank imports, and with them the infrastructure-as-code and misconfiguration packages that Trivy needs and we never called.
We changed Vuls to call Trivy's lockfile parsers directly. Much less code compiled in, and — as we checked below — the same results on almost every project we tested. (Vuls still depends on Trivy — we use its parsers, just not through fanal.)
The result (vuls PR #2476):
| Metric | Before | After | Change |
|---|---|---|---|
| Scanner binary | 106.6 MB | 34.1 MB | -68% |
| Scanner build time | 7.8 s | 3.2 s | -59% |
How we measured: scanner-only builds (-tags scanner) with our release build settings, CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go build -a -trimpath -ldflags "-s -w". The build times are with a warm build cache.
To be precise about what shrank: this is the scanner binary. The full vuls binary, which also includes the vulnerability detection and reporting code, went from 139.1 MB to 138.5 MB after this change and a follow-up one. That code still uses Trivy's vulnerability database layer, so most of the weight stays.
!Left: after vuls removed its direct import of go-homedir, nine other modules, including trivy and go-getter, still required it, so go.sum stayed at 1,220 lines. Right: when vuls stopped going through fanal and called Trivy's lockfile parsers directly, what only fanal had pulled in left the build: 2,345 packages became 737, and the scanner binary went from 106.6 MB to 34.1 MB.
Packages in the scanner build counted with go list -tags scanner -deps ./cmd/scanner before and after PR #2476.
Don't pick leaves one by one. Find the branch they hang from.
How we tested it
The change rewrote how the whole scanner package reads lockfiles. Here's how we checked it.
Layer 1: Golden tests. JSON output for 35 lockfile types, compared for an exact match.
Layer 2: A/B comparison on real projects. We collected real lockfiles from 129 open-source projects across 13 ecosystems. Then:
old code + lockfile → result A
new code + lockfile → result B
diff(A, B)
No mocks. No hand-written expected values. Just: "does the new code produce the same results as the old code?"
127 of 129 were identical. The other 2 differed only in the pnpm dev flag, which our old wrapper code had been losing along the way. Calling Trivy's pnpm parser directly gave us the parser's correct output.
Building the comparison set taught us something too. On the first pass, many fixtures produced zero packages — requirements.txt files with no pinned versions, a nearly empty yarn.lock, a root pom.xml with only <modules>. "Identical" on an empty result proves nothing, so we replaced those fixtures with ones that actually exercised the parsers.
This lives in vuls as make compare-lockfile. It didn't catch everything: code review and testing on Windows still found a case the comparison had missed (.exe files weren't detected as executables), which we fixed before merging.
The tool we built afterwards: uzomuzo diet
We did all of the above by hand, in March 2026. Looking back, the expensive part wasn't writing code. It was deciding where to start — and our first choice was wrong.
So in April we added a diet command to uzomuzo, our dependency lifecycle scanner. It ranks your direct dependencies as candidates for removal:
1. Dependency graph — from a CycloneDX SBOM: what would disappear along with each direct dependency?
2. Source coupling — tree-sitter reads your code: how many files import it, how many call sites?
3. Health — maintenance status (Active, Stalled, Archived, ...), from the same signals uzomuzo scan uses
4. Score — high impact, high risk, and easy to remove rises to the top
scan and diet are rule-based. No LLM is involved. Their results reflect the dependency and maintenance data available when you run them.
One caveat up front: diet is experimental. An audit against hand-checked expected values found cases where a dependency that is in use is reported as unused or trivial — for example when it's imported from a skipped directory or in an import style the analyzer doesn't recognize yet. The CLI prints a notice saying so after every run, and the Accuracy section of the docs lists what to check. Treat its output as a list of places to look, not a list of things to delete.
Here's an excerpt from a run on Terraform (commit 9ec49e0, October 13, 2026; SBOM generated with cyclonedx-gomod; run on October 26, 2026):
$ uzomuzo diet --sbom terraform-bom.json --source ./terraform
RANK SCORE EFFORT PURL REMOVES IMPORTS CALLS STATUS
3 0.13 trivial github.com/mitchellh/gox 1 1 1 Archived
5 0.10 hard github.com/mitchellh/colorstring 0 22 79 Archived
6 0.10 moderate github.com/mitchellh/go-homedir 0 9 11 Archived
...
72 0.00 hard github.com/hashicorp/hcl/v2 0 352 6764 Active
(Edited for width: four of the 77 direct dependencies, PURLs shortened to module paths, and the REMAINS column omitted. Ranks 1, 2 and 4 are not shown: 1 and 4 are Terraform's own nested internal/backend/remote-state/* modules, which the SBOM lists as direct dependencies, and 2 is an active OpenTelemetry package. The full run also reported zero "quick wins".)
The ranking is a starting point for investigation, not a verdict:
- gox is archived and has one import and one call site, so it gets a trivial effort label. That's exactly the kind of label to verify: check that one call site, what it does, and what replaces it.
- go-homedir is the same package we removed from vuls in 15 minutes. Here it's imported in 9 files, so it's rated moderate. Removing the direct use wouldn't drop it from Terraform's dependency list either — another module still requires it.
- hcl/v2 is at rank 72: 352 importing files, 6,764 call sites, and actively maintained. Don't start here.
It works for Go, Python, JavaScript/TypeScript, and Java.
The last mile: Claude Code skills
Ranking is automated. Actually removing a dependency — tracing what data flows through it, writing the replacement, getting the change accepted — still takes judgment. uzomuzo ships three Claude Code skills for that part:
/diet-assess-risk (how risky is it to keep?), /diet-evaluate-removal (is it worth the effort?), and /diet-remove (draft the change or the upstream report). They use an LLM, so review what they propose before you use it.
The rules in /diet-remove come from our own mistakes. When we found that Next.js still depended on @vercel/kv, which Vercel has deprecated on npm in favor of Upstash Redis, we tried three channels by hand:
- PR → we closed it ourselves after CI failed. It's a large pnpm-workspaces monorepo, and we couldn't reproduce its CI locally.
- Issue → the repository's bot closed it within seconds, pointing to the bug report template. The issue tracker is for bugs with a reproduction, and ours wasn't one.
- Discussion → the channel the repository's templates point to. It's still open with no reply yet.
So the skill now defaults to drafting an issue rather than a PR for projects you don't own, checks whether the repository accepts blank issues, and falls back to a Discussion when it doesn't. Those rules are written from that manual experience; we haven't yet published an end-to-end run of the skill itself. And getting the channel right doesn't guarantee a response. Of our four upstream reports in April, two have been fixed and two are still waiting. Whether to fix something is the maintainers' call.
Start your diet
go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@latest
CGO_ENABLED=1 go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo-diet@latest # needs a C compiler
export GITHUB_TOKEN=... # read-only is enough; gives uzomuzo repository signals such as archived status
The output in this post was produced with v0.10.0.
1. Run uzomuzo scan --file go.mod (or pipe in an SBOM for other ecosystems). See what's unmaintained.
2. Generate a CycloneDX SBOM — for example trivy fs . --format cyclonedx -o bom.json, or for Go, cyclonedx-gomod mod -json -output bom.json — and run uzomuzo diet --sbom bom.json --source ..
3. Pick a dependency whose use and replacement you understand. Remove it, then measure: binary size, dependency list, build time. If nothing moved, look for the layer it came in through.
And don't give up on your diet.
---
Earlier posts: "Your dependencies are 48% unmaintained — and SCA tools can't see it" and "Your CI runs other people's code. Is anyone still maintaining it?"
Source and docs: future-architect/uzomuzo-oss (Apache 2.0)
Kota Kanbe — Creator of vuls, an open-source vulnerability scanner.
Your CI runs other people's code. Is anyone still maintaining it?
---
title: "Your CI runs other people's code. Is anyone still maintaining it?"
published: true
description: "Workflow scanners check how you use GitHub Actions. Few people check whether each Action is still maintained. What we found in Grafana's workflows — and how they fixed it."
tags: security, opensource, github, devops
cover_image:
---
Every third-party uses: line in a GitHub Actions workflow runs someone else's code in your job. Depending on the workflow, that code can reach your GITHUB_TOKEN, the secrets passed to that step, and your checked-out source.
In March 2025, the widely used tj-actions/changed-files Action was compromised: its tags were pointed at a malicious commit that printed CI secrets into workflow logs (CISA alert, CVE-2025-30066). That project was actively maintained — a maintenance check would not have prevented it — and a fixed release followed.
That last part is the point of this post. When something goes wrong in an Action that is still maintained, a fix comes out and you upgrade. When the Action is no longer maintained, there is no next release.
The question workflow scanners don't ask
There are good tools for securing your workflows:
| Tool | What it checks |
|---|---|
| OpenSSF Scorecard | Your repo's practices: dangerous workflow patterns, token permissions, pinned dependencies |
| zizmor | Your workflow files: injection risks, pull_request_target misuse, and more |
| actionlint | Your workflow syntax and expressions |
All three look at your workflow. Scorecard does have a Maintained check, and it gives an archived repository the lowest score — but only for the repository you run it on. Few teams run Scorecard against each of the 30-odd Actions their workflows reference.
Pinning Actions by commit SHA is good practice. It stops a tag from being silently repointed. But it doesn't tell you whether anyone will ship a fix for the code you pinned.
!Three layers of code run in your job: your workflow file, the Actions its uses: lines reference, and Actions called inside composite Actions. Scorecard, zizmor and actionlint read the first layer. uzomuzo with --include-actions checks the repository behind each referenced Action, such as the archived tibdex/github-app-token in Grafana's release-build.yml; --show-transitive adds the third layer. You can fix the second layer by changing a uses: line; the third needs the parent Action's maintainer.
uzomuzo does that inventory for you: it reads your workflows, lists every Action they reference, and reports the maintenance status of each. It complements the tools above — it doesn't replace them.
What we found in Grafana (April 2026)
Grafana is one of the most popular open-source projects on GitHub. In April 2026 we scanned the Actions referenced in its workflows. One of them was archived: tibdex/github-app-token.
- The repository was archived in July 2025 (its last push is July 7, 2025). Its README says the Action is deprecated and points to GitHub's official actions/create-github-app-token — the maintainer did exactly the right thing.
- Grafana used it in 14 places across 13 workflow files, including release-build.yml and release-npm.yml.
- It declares runs.using: node20. GitHub removed Node 20 from Actions runners on September 23, 2026, and an archived Action can't be updated to Node 24. Anyone still using it after that date has a broken workflow.
An Action runs in your job, with access to its workspace, not in an isolated sandbox. Depending on the workflow, it can reach GITHUB_TOKEN (with whatever permissions the job grants), the secrets passed to it, and the checked-out source. Release jobs often hold sensitive credentials. That doesn't mean anything was exposed here — it means an archived Action in a release job deserves a look.
What happened next
We filed an issue on April 5, 2026.
Grafana had already begun moving to actions/create-github-app-token in one workflow in October 2025. A community member opened a PR for the remaining files three days after our issue. A maintainer closed it, explaining that the team had a new recommended approach using their own internal workflow. We don't know whether our issue influenced their timing.
On September 15, 2026, the issue was closed as completed: "This has been done, we no longer use that action :)" — a week before Node 20 was removed.
When we scanned Grafana's workflows again on October 26, 2026, tibdex/github-app-token no longer appeared, and none of the 32 Actions was archived or got a replace verdict.
Reporting doesn't always lead to a fix. Of the four upstream reports we filed in April, two have been resolved and two have had no response yet. Whether and how to fix something is the maintainers' call. What a scanner can do is make sure the question gets asked.
Scan your own Actions
Install
go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@latest
Or download a binary from GitHub Releases. You need v0.8.0 or later for --fail-on archived and the · archived marker. The examples below assume GITHUB_TOKEN is set: scanning a repository's Actions reads workflow files through the GitHub API, and the token also gives uzomuzo the repository signals (archived, commit history) it needs.
Check a repository
export GITHUB_TOKEN=... # a read-only token is enough
# Actions referenced directly in your workflows
uzomuzo scan https://github.com/your-org/your-repo --include-actions
# Also include Actions used inside composite Actions
uzomuzo scan https://github.com/your-org/your-repo --include-actions --show-transitive
A composite Action can itself uses: other Actions. Those run in your job too. The difference is who can fix them: you can swap a direct Action today, but a transitive one needs the parent Action's maintainer to act.
You can also scan a single Action or workflow file:
uzomuzo scan https://github.com/tibdex/github-app-token
uzomuzo scan --file .github/workflows/ci.yml
What an archived Action looks like
$ uzomuzo scan https://github.com/tibdex/github-app-token https://github.com/actions/github-script
STATUS PURL LIFECYCLE BUILD
🔴 replace https://github.com/tibdex/github-app-token Stalled · archived —
⚠️ caution https://github.com/actions/github-script Stalled —
Both Actions are Stalled: neither has had much recent activity. Only one is archived, and that's the one marked · archived and given a replace verdict.
The lifecycle label stays Stalled rather than EOL-Confirmed on purpose. uzomuzo uses EOL-Confirmed only for an explicit end-of-life signal, such as an npm deprecation or a Packagist "abandoned" flag. Archiving alone isn't one: some archived repositories are packages that moved to a monorepo and keep shipping releases from there. So the archive is shown as a separate fact next to the label instead. (tibdex's README does say "This action is deprecated", but uzomuzo deliberately doesn't treat README prose as a signal — free text is easy to misread.)
If you read the earlier post in this series: its table showed archived repositories as EOL-Confirmed. Current versions show them as above.
Gate your CI
uzomuzo scan https://github.com/your-org/your-repo --include-actions --fail-on archived
--fail-on archived fails the build when any referenced Action's repository is archived — and, like every --fail-on value, when a version is known to be malicious. It doesn't fire on Actions that are merely quiet. That matters: --fail-on stalled would also catch archived Actions, but in our October scan of Grafana it would have failed on four Actions that are simply less active, including actions/github-script. --fail-on eol-confirmed alone wouldn't fail on an archived Action at all.
Start with a report-only run, look at what comes back, then turn the gate on.
Run it on a schedule
The uzomuzo repository has a monthly workflow that generates an SBOM with aquasecurity/trivy-action, runs uzomuzo scan --sbom, and opens an issue with the results — SCA and lifecycle checks side by side. That workflow covers package dependencies, not Actions. To cover Actions as well, add a step that runs uzomuzo scan https://github.com/$GITHUB_REPOSITORY --include-actions --fail-on archived with GITHUB_TOKEN set.
Two layers, two questions
Scorecard, zizmor, and actionlint answer: "Is my workflow configured safely?"
uzomuzo answers: "Is the code my workflow runs still maintained?"
A safely configured workflow can still depend on an Action that will never get another release. That isn't a known vulnerability. It's something to review before it becomes a broken build — or worse.
---
Earlier post: "Your dependencies are 48% unmaintained — and SCA tools can't see it"
Source and docs: future-architect/uzomuzo-oss (Apache 2.0)
Kota Kanbe — Creator of vuls, an open-source vulnerability scanner.
Homelab security on 512 MB: warden scores my logs and asks before it bans anything
I wanted one place that tells me what is going on in my homelab: who is poking at it from outside, which boxes have known-exploited CVEs, what needs patching, and whether anything new turned up on the network. And I wanted it on a small box, without putting an agent on every machine.
The obvious tools are good, and I'll say that up front. Wazuh does far more than this, but it wants an agent per host and its all-in-one install asks for 4 vCPU and 8 GB. CrowdSec is brilliant at reading logs and banning, but it bans on its own, and behind a Cloudflare tunnel that matters more than you'd think. So I built a smaller thing that glues existing scanners together, and I've just launched it. It's called warden, it's on GitHub under AGPL-3.0, and this is how it works and what went wrong while I built it.
Repo: casareanderson/warden
!The warden console Overview on demo data: headline numbers, detection breakdowns, a 30-day timeline and the top-scoring addresses
What it is (and isn't)
warden is plain Python and one SQLite file. Collectors run on systemd timers and write to the database. The console, a REST API and an MCP server read that file and nothing else. It reaches other machines over SSH, so there's nothing to install on them.
What it does:
- Log detection. It reads the reverse proxy log (NPMplus/nginx), the Authelia log and the cloudflared tunnel log. It scores things like /.env and /.git/config probes, path traversal, SQL injection, scanner user agents and login failures. It proposes bans per IP and per /24.
- Vulnerabilities. It checks host packages and what's actually inside running Docker containers against osv.dev. Anything on CISA's known-exploited list goes to the top, then it ranks the rest by EPSS (how likely it is to get exploited). This used to be trivy, see the update below.
- Patching. It writes a patch plan per box (apt, or docker compose pull && up -d for containers). It also finds Proxmox LXC containers by itself.
- Hardening and drift. Lynis scores each box, and a file integrity check flags changes to things like sshd_config, cron and systemd units.
- Network. Suricata for IDS, matching against threat feeds (FireHOL level 1, Feodo, OpenPhish), plus new-device alerts on the LAN.
- Edge. It snapshots the Cloudflare side: public hostnames, WAF rules, which names sit behind SSO.
What it isn't: it isn't EDR in the industry sense. There's no agent and no process telemetry. The vulnerability data comes from OSV.dev, and the host checks and network alerts from Lynis and Suricata, not from me. warden is the bit in the middle that joins it all up, scores it, and asks you what to do.
How it compares
Every piece of this exists somewhere else, often done better. So here's where it sits, from each project's own docs:
| Tool | Good at | How warden differs |
|---|---|---|
| Wazuh | Full SIEM: vulns, file integrity, active response | Agent on every host, all-in-one install wants 4 vCPU / 8 GB. warden is agentless, tiny, and does less |
| CrowdSec | Log parsing, community blocklists | Its bouncers block on their own. warden asks first, and is built for a Cloudflare tunnel. They run fine together |
| PatchMon | Patching with approvals, Proxmox LXC | Agent per host plus Postgres and Redis. warden patches over SSH and does detection too |
| Vuls | Agentless vuln scanning over SSH | A scanner. warden does the scanning with no local vuln database, and adds patch plans and approvals |
| Security Onion | Network monitoring, Suricata, hunting | A full platform with far bigger hardware needs. warden just reads your Suricata alerts |
| Fail2ban | Bans from log patterns | Single-purpose and automatic. Behind a tunnel its firewall bans hit the tunnel, not the attacker |
So the gap warden fills is the combination: agentless, one small box, edge bans from the logs you already have, and nothing changes until you say yes.
It runs on 1 vCPU and 512 MB for the core. I measured the console at 29 MB resident on my box. Vulnerability scanning used to add roughly 1 GB of RAM and 2 GB of disk, mostly trivy's database. That's gone now: the new scanner peaked at 117 MB scanning every container on my NAS, and it keeps a small cache instead of a 1.4 GB database.
!How warden fits together: collectors on timers write to one SQLite file; the console, REST API and MCP server only read it; bans and patches go to the owner for approval first
Rule one: it never acts on its own
Out of the box warden is detect-only. It records what it would ban and changes nothing.
When it does want to act, it asks. A proposed edge ban or a patch plan turns up in Discord with ✅ and ❌, and nothing happens until I tap one. The same decisions show in the console.
Two reasons for this.
The first is the tunnel. My public services sit behind a Cloudflare tunnel. Behind a tunnel, the network-layer source of every request is cloudflared or the Cloudflare edge. The attacker's address only exists in the HTTP layer. So a local firewall ban keyed on the attacker never matches a packet. And banning what does show up at layer 3 means banning Cloudflare, which takes the whole estate offline. That's why there's no iptables code in warden at all. Bans go to Cloudflare's IP Access Rules at the edge, before the tunnel.
The second is that automatic bans need a detector you trust completely, and I don't trust mine that much. The rest of this post is why.
The bugs that shaped it
Event time has to come from the log line
The first time warden read the Authelia log, it read the whole backlog at once and stamped every event with the time it was ingested. That put weeks of July history inside the "last 60 minutes" ban window. 19 of 19 events landed in the window. With bans switched on, that would have banned people for things they did in July.
The fix is a timestamp extractor per source that reads the time from the line itself. After that, 0 old events landed in the window.
A related one: a fixed --since 10m lookback under a 5-minute timer counts every event twice. That quietly pushes scores towards a ban. It's now a real watermark per source, plus a dedup table.
A per-IP threshold misses a spread-out attacker
Seven addresses in one /24 each scored between 3 and 6, under the per-IP threshold of 12. Nothing fired. Now scores roll up by /24 as well. A range ban needs a higher total (20) and at least 3 different addresses. That's stricter on purpose, because blocking a /24 is a much blunter instrument. It also refuses any range that contains an allowlisted address, like my own.
(Those seven turned out to be old OIDC client-auth failures, not a live brute force. Check the dates before calling something an attack.)
My own IP was the loudest attacker
This one was this morning. 1,526 of the last 1,577 events came from my own WAN address. An Uptime Kuma monitor was hitting a URL that 404s once a minute, and every 404 scored.
My own IPs were already excluded from bans. But they still filled the counts, the timeline and the recent list, and pushed the real rows off the screen. Now they're dropped from every count, and the console says how many it ignored. After the fix the real number for the day was 51.
The first version of that fix had its own bug, and a test caught it. I fetched 20 times the rows I needed and then filtered. With 150 of my own rows sitting newer than the real ones, it came back with nothing. It now pages through until it has enough real rows.
!The console's 24-hour detections tile now counts only real traffic and says how many own-address rows it ignored
The patcher that replied 328 times
The patcher writes to the database and posts to Discord. The Discord helper also writes to the database, on its own connection. The patcher was posting while still holding an uncommitted write. So the helper got database is locked, the run died before it committed, the job stayed pending, and two minutes later it posted "⌛ expired" again. 328 messages in one day, and the loop also blocked two jobs I'd already approved.
The rule now: commit before any notify call. I found the same bug in the edge-ban module and fixed it there too.
A setting that was a root shell
The day after v2 went public I had an independent review done on the code. It found 8 problems, and I fixed all of them with a regression test for each. The worst: the Suricata host was an editable setting in the console, and it was passed to ssh. Something like -oProxyCommand=... would have been root code execution from a settings box. That setting is gone. Settings the console can edit now live in a separate overrides file, and secrets are write-only.
Another was a token on the command line. The trivy server was started with --token in its arguments, which any local user can read with ps. I moved it to a 0600 environment file, and the scanners got it on stdin. Since then trivy's gone altogether, so there's no server and no token left to leak.
!Before and after: trivy token in the process list, then gone, and the server still refusing a request without the token
A slow page on a slow disk
People said the console was slow to open. The server built its data in 0.3 seconds, and the browser drew it in 36 ms. But the first load after a quiet spell took 8.9 seconds. The database is 100 MB on a 5400 rpm disk shared with other guests, and a cold read waited behind everything else. The console now keeps one warm copy, rebuilds it in the background, and drops it the moment you change anything. A load now takes about 0.15 seconds.
Talking to it from an AI agent
warden has a read-only REST API and an MCP server, both behind bearer tokens. Tokens are hashed at rest and can be revoked. My Hermes security agent connects to it and gets 13 tools: summary, detections, top IPs, an IP lookup, bans, vulnerabilities, a CVE lookup, integrity, IDS and so on. So the agent can answer "anything dodgy last night?" from facts in the database instead of guessing.
It's read-only on purpose. An agent can look, but it can't approve anything. Approvals stay with a person.
One gotcha if you put SSO in front of it: let /mcp and /api/v1 straight through. An MCP client has no browser and can't follow a login redirect, so the bearer token is the auth on those two paths. Everything else stays behind the login.
!Settings: decisions waiting for approval, the setup check, API tokens, and copy-paste snippets for connecting an agent
Update, 10 October: trivy's gone
I've taken trivy out. It was the heaviest part of warden: a 161 MB binary, a server process and a 1.4 GB database, and its own release got backdoored this year (CVE-2026-33634). warden already copies each box's package list, so now it checks those lists against OSV.dev directly. On my Proxmox node it found 680 of the 691 CVEs trivy did, both agreed nothing there was fixable, and the odd ones out were down to the two databases updating at different times. It also binned two "actively exploited" trivy alerts for a package one container doesn't even run any more, because it scans what's running rather than the image.
What's missing
Being honest about the gaps:
- "Sign in with…". warden now has its own login (users, viewer/approver/admin roles, optional 2FA, lockout), added today. What it doesn't have yet is signing in with your own identity provider directly; for now you put your SSO in front.
- Chat tools. Approvals only go to Discord right now. Slack, Teams, Telegram and Matrix are next.
- Ban expiry. Bans record an expiry, but nothing lifts them yet, so Cloudflare rules pile up.
- Threat-list blocking. It matches traffic against threat feeds but doesn't turn them into blocks yet. When it does, a script will build the list and you'll approve it. The AI won't pick who gets blocked.
Try it
It's free and open source (AGPL-3.0). The core runs happily in a small LXC. There's a setup wizard that checks the running system rather than just your config file, and a demo data script if you just want to look at the console first.
casareanderson/warden
If you self-host, I'd like to know what you'd want from it. Which of the gaps above would you hit first, or what else is missing? Tell me in the comments and I'll build the top ones.
4 comments
Mentions
@nekoruri ちょうど今そういうの作ってますわ! future-architect/uzomuzo-oss vuls.biz
A maintained package can still ship malware. What a health check can and can't see. --- title: "A maintained package can still ship malware. What a health check can and can't see." published: true description: "A hijacked package still looks maintained, because it is. How uzomuzo now flags malicious versions from OSV, and why it marks a release after a year of silence for 7 days." tags: security, opensource, npm, supplychain --- On September 8, 2025, the maintainer of chalk and debug was phished, and malicious versions of those and other widely used npm packages were published from that account. The maintainer disclosed it within hours, and clean versions were out the same afternoon. chalk had shipped 5.6.0 three weeks earlier. By any maintenance measure, it was a maintained package. uzomuzo, the dependency health checker I maintain, judges dependencies by exactly that kind of measure. Its job is to find dependencies with maintenance or lifecycle concerns. Until v0.6.0, uzomuzo scan pkg:npm/chalk@5.6.1 returned ✅ ok … Active. A hijacked release of a maintained package is the case that kind of check can't see. This post covers the two things we added to narrow that gap, the numbers behind each decision, and what still gets through: 1. Malicious versions: read OSV's malicious-package records and stop the versions they name. 2. Before a record exists: for 7 days, mark a release that comes after a year of silence. 1. Stopping versions that OSV says are malicious The OpenSSF malicious-packages project publishes records with IDs starting with MAL-, served through OSV. For a hijack, a record usually names the specific versions that were malicious; for a package that was malicious from the start, it covers every version. Here's what uzomuzo v0.10.0 shows (output excerpt, October 26, 2026): $ uzomuzo scan pkg:npm/chalk@5.6.1 pkg:npm/chalk@5.6.2 pkg:cargo/rustdecimal@1.23.1 STATUS PURL LIFECYCLE BUILD 🔴 replace pkg:npm/chalk@5.6.1 Active ☠ MAL-2025-46969 — ✅ ok pkg:npm/chalk@5.6.2 Active Moderate 4.4 🔴 replace pkg:cargo/rustdecimal@1.23.1 Review Needed ☠ MAL-2022-1 — Three design choices are visible here. The lifecycle label doesn't change. chalk is still Active, because it is. Whether a project is maintained and whether one of its versions is malicious are separate facts, so the malicious record sits next to the label (☠ MAL-…) instead of replacing it. Other tools that read the label keep working. The match is by version. Chalk's record names 5.6.1, so 5.6.2, the clean release that followed, isn't affected by it. A flag on the whole package would push people away from the fix. (rustdecimal's record covers every version, because that package was malicious from the start.) Any CI gate catches it. If --fail-on names anything at all, a matched malicious version fails the build. A pipeline that only ever wrote --fail-on eol-confirmed will stop on chalk@5.6.1 too: $ uzomuzo scan pkg:npm/chalk@5.6.1 --fail-on eol-confirmed > /dev/null; echo $? 1 Two conditions apply. Without --fail-on, findings alone don't make uzomuzo exit with an error (command errors still do). And if the OSV lookup fails (no network, for example), uzomuzo warns that the check is incomplete; it doesn't fail the build on that alone. No waiting period uzomuzo already waits 14 days before acting on a RustSec "unmaintained" advisory, because those advisories are sometimes withdrawn and filed again. For malicious-package records there is no waiting period. Chalk's record was published about four hours after the malicious version (13:13 → 17:11 UTC, the same day). Waiting a few days before trusting such a record would let the version through during exactly the days it matters. That has a cost, and we accepted it. In June 2024, MAL-2024-2929 named react@1.0.0 and react@35.0.0 as malicious. Those versions don't exist on npm, and the record was withdrawn about six days later as "False positive caused by problematic ingestion". Nobody's build could have broken on that one. But if a record like it named a version people actually use, our rule would fail every gate on that version until the record was withdrawn. The flag clears on the next run after a withdrawal; it doesn't undo a build that already failed. What it misses uzomuzo only acts on records that say, in a field a machine can read, that the code is malicious: a MAL- ID, or a GitHub advisory tagged CWE-506 ("embedded malicious code"). ctx 0.2.6 on PyPI was hijacked in 2022. Its advisories list the affected versions and describe the takeover in prose, but none carries that classification. We considered reading the prose and decided against it: in a CI gate, misreading free text is expensive. 2. Before a record exists A record needs time to appear. We looked at npm records in the OpenSSF data (as of October 8, 2026) that name only some versions of a package, kept packages that had existed for at least a year with at least three normal releases before the malicious one, and treated those as takeovers. That left 1,556 packages. For the 1,515 of them with a usable timestamp, the median time from the first malicious publish to the earliest timestamp in its record was about 6 hours. 89% were within a day, and 98% within 7 days. That timestamp approximates when the malware was found, not when OSV started serving the record. Those figures are flattered by a few huge events. Most of the 1,556 packages belong to bulk account compromises, where one stolen account published to every package it owned and records came quickly (1,418 packages, 99% within 7 days). Outside those waves the tail is longer: of 97 packages, 86% had a record within 7 days, and 8% took more than 30 days. So for hours, sometimes for weeks, a check that only reads records says nothing. Can anything be said in that window? What these packages looked like before the attack In 737 of the 1,556 packages (47%), the malicious version was the first release in more than a year. That number needs three qualifications. - It's mostly a few events. 724 of the 737 come from 13 bulk account compromises — the Shai-Hulud waves, the September 2025 chalk/debug phishing, and others. Outside those waves, only 13 of 103 packages had been quiet for a year. - It doesn't show that attackers prefer quiet packages. On the dates of those waves, between about a quarter and a half (27–53%) of the same packages that hadn't been hit yet had also been quiet for a year. That fits bulk attacks publishing from accounts that already held many quiet packages; it says nothing about which packages attackers prefer across npm. - It compares different units. 47% is per hijacked package. Among the normal releases of the same packages, about 0.5% came after a year of silence (counting every release), or 6.8% if you look only at the last normal release before the attack. What the numbers support is narrow: a release after a year of silence is uncommon among normal releases and common among these malicious ones. That makes it a reason to look at a release, not evidence of compromise. The marker So for npm packages, uzomuzo now marks a stable release that comes after at least a year without releases. Here's a benign release that triggered it. This is an excerpt from a v0.8.0 scan captured on October 15, 2026; the marker expired on October 18, so running the command today won't show it. $ uzomuzo scan pkg:npm/dateformat@5.0.4 STATUS PURL LIFECYCLE BUILD ✅ ok pkg:npm/dateformat@5.0.4 Active ⚠ 1695d silence Moderate 5.0 The detailed view explains it: ⚠ Release after silence: This version came out 2026-10-11, after 1695 days with no release. Takeovers of npm packages that had gone quiet looked like this, and so does a maintainer who ships again after a break. No OSV malicious-package record names it yet. Before using it, check who published it (npm view <package>@<version> _npmUser). This marker clears on 2026-10-18. The verdict stays ok. We did what the message says: npm view dateformat@5.0.4 _npmUser shows the same account that published 5.0.3 four years earlier, and 5.0.5 followed on October 24. That's what a project coming back after a break looks like. The publisher check is a starting point, not proof. In a takeover the account name stays the same, and a legitimate handover changes it. Look at the release itself and at what the maintainers have said, too. The marker covers the first release after the silence, plus anything published within a day of it, since a follow-up the next day comes from the same silence. dateformat@5.0.3, the release before the silence, has no marker. Why 7 days The marker is a bridge until a record can take over. Once an OSV record names the version, the first mechanism stops it, whatever its age. !Timeline of September 8, 2025: chalk@5.6.1 and ansi-styles@6.2.2 were published maliciously within a minute. chalk had released 22 days earlier, so nothing flagged it until its MAL record about 4 hours later. ansi-styles had been silent for 1,061 days, so the release-after-silence marker would have covered it from the first minute until the record took over. Two packages from the same attack, with today's rules applied to the published timestamps. The marker only helps the one that had been quiet. So the question is: for takeovers that started with a release after a year of silence, how long did the record take? The percentages above count packages, so a bulk wave of hundreds of packages counts hundreds of times. For choosing a marker period the right unit is the incident, so we counted again: incidents whose first malicious version came after a year of silence, a bulk wave counted once, timed from the first malicious publish to when the record entered the OpenSSF data. 27 incidents qualified: 20 that touched one release line (such as 5.x), and 7 that touched two or more at once. An incident qualified if at least one of its packages matched, and the earliest publish and the earliest record can belong to different packages. So these counts describe timing per incident, not a record for every affected version. | Marker kept for | One-line incidents with a record by then (of 20) | Multi-line incidents with a record by then (of 7) | |---|---|---| | 1 day | 11 | 6 | | 3 days | 13 | 7 | | 7 days | 16 | 7 | | 14 days | 17 | 7 | | 365 days | 19 | 7 | One one-line incident has no import time; it stays in the count but never counts as recorded. The other three that took longer than 7 days took about 12.6, 57 and 310 days. Going from 7 to 14 days would have added one incident. A year would have added three, and kept every ordinary return from a break flagged for a year. So 7 days is a policy whose window held a record for 16 of the 20 one-line incidents and all 7 multi-line ones we've seen so far. It isn't proof that 7 days will be enough next time, and when the marker expires, that says nothing about whether the release is safe. This incident count is a separate selection from the package counts above. It was made outside the uzomuzo repository: the design record publishes the results and limitations, but not the full selection. Why it doesn't fail CI by default On the lockfiles of VS Code and the npm CLI, 186 of about 6,100 and 146 of about 2,600 stable releases in the year to October 2026 would have carried the marker. A team that adopts and scans every release within its first week could have run into all of them. None of them is known to be malicious. So by default the marker only shows up in the output. To fail CI on it: uzomuzo scan --sbom bom.json --show-transitive --fail-on dormant-release One exception: when a quiet package returns with releases on two or more lines at once (as node-ipc did in May 2026, with three malicious versions in 55 seconds), an Active or Legacy-Safe label also becomes Review Needed for those 7 days, so an existing --fail-on review-needed gate fails too. What it can't catch - Attacks on recently active packages. chalk itself had released three weeks earlier, so there was no silence to mark. A release cooldown can delay adopting such a version, and a matching record identifies it once one exists; neither guarantees it's caught in time. (Several other packages published from the same account that day had been quiet for years, and would have carried the marker.) - Patient attackers. An attacker who ends the silence with a harmless release, then publishes the malicious one more than a day later, walks past the marker. - Takeovers that never get a record. Some older incidents have only a GitHub advisory tagged as malicious code, which uzomuzo also reads. Some have nothing. Turn on a release cooldown too The marker can't help with the attack this post opened with. What would have helped is simply not taking a brand-new release. chalk@5.6.1 was gone from npm within about 2 hours 40 minutes, so a team that waited even one day before adopting new releases would never have installed it. So, separately from uzomuzo, we recommend a release cooldown in whatever updates your dependencies. It doesn't detect anything; it just gives the ecosystem time to notice. Dependabot (.github/dependabot.yml): version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" cooldown: default-days: 3 Renovate (renovate.json): { "minimumReleaseAge": "3 days" } pnpm (pnpm-workspace.yaml, in minutes; pnpm 11 waits one day by default): minimumReleaseAge: 4320 Two caveats. Dependabot's cooldown applies to version updates, not security updates, so a fix for a known vulnerability still arrives right away; that's usually what you want. And a cooldown is a delay, not a guarantee: event-stream's malicious version went unnoticed for about 72 days. The two work together. A cooldown holds back every new release for a few days. The marker picks out the small share of releases that end a long silence and asks you to look at them. The MAL record stops the version once someone has identified it. In v0.10.0 we also added a quieter notice for any selected version that is 7 days old or less, in any ecosystem, when uzomuzo can find its publish time. It's always in the JSON output, and appears in the detailed view unless the release-after-silence marker or a malicious record already covers the version. It doesn't change the verdict or fail CI. It tells you that you're early, before records have had time to arrive. Try it go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@v0.10.0 # or download a release binary export GITHUB_TOKEN=... # optional; adds commit history and repository signals # One package uzomuzo scan pkg:npm/chalk@5.6.1 # A whole project, via a CycloneDX SBOM trivy fs . --format cyclonedx | uzomuzo scan --sbom - --show-transitive --fail-on malicious Include --show-transitive. Without it, uzomuzo skips the dependencies your SBOM marks as transitive, and chalk and debug usually arrive as transitive ones. What gets checked also depends on what your SBOM contains. The malicious-version check needs v0.6.0 or later, and the release-after-silence marker v0.7.0 or later. The design records behind this post are ADR-0027 and ADR-0028. --- Earlier posts on uzomuzo: "Your dependencies are 48% unmaintained — and SCA tools can't see it" and "Your CI runs other people's code. Is anyone still maintaining it?" Kota Kanbe — Creator of vuls, an open-source vulnerability scanner, and maintainer of uzomuzo.
We cut our scanner binary by 68% — and removing packages one by one wasn't how --- title: "We cut our scanner binary by 68% — and removing packages one by one wasn't how" published: true description: "Two weeks of replacing dependencies one by one barely changed our dependency list. Removing one integration layer cut the scanner binary from 106.6 MB to 34.1 MB. Here's how, and how we tested it." tags: security, opensource, go, devops cover_image: --- Finding unmaintained dependencies is the easy part. Removing them is where it gets hard. This is the story of how we slimmed down vuls, the open-source vulnerability scanner I created — what didn't work, what did, and the tooling we built after doing it all by hand. The easy win: 15 minutes mitchellh/go-homedir has been archived for years. Vuls used it in a couple of lines in two source files: - import "github.com/mitchellh/go-homedir" - dir, err := homedir.Dir() + dir, err := os.UserHomeDir() Fifteen minutes, start to finish. The standard library has done this since Go 1.12. Trivy used the same package, so we sent them a similar fix. The Trivy maintainers reviewed and merged it within three weeks. We felt good. Let's keep going. The part where almost nothing happened We kept going. Over two weeks we replaced 9 direct dependencies with standard-library code or a few lines of our own. go.mod went from 60 direct dependencies to 51. Then we looked at what had actually left the build. go.sum went from 1,220 lines to about 1,210. Only 5 modules disappeared completely. Most of the packages we had stopped calling were still required by other modules, so they stayed in the dependency list anyway. Adding a dependency takes one line. Removing one can take real work and barely change what you ship. Just like a real diet. What worked: removing a whole layer So we stopped picking off packages one by one and looked at which layer they came in through. Vuls uses Trivy's parsers to read lockfiles (package-lock.json, go.mod, Cargo.lock, and so on). We called them through fanal, Trivy's analysis framework. fanal is built for everything Trivy scans — container images, disk images, filesystems, infrastructure-as-code. That breadth is exactly what Trivy needs. Our scanner only needed the part that reads lockfiles, but the way we had wired fanal in registered 25+ analyzers through blank imports, and with them the infrastructure-as-code and misconfiguration packages that Trivy needs and we never called. We changed Vuls to call Trivy's lockfile parsers directly. Much less code compiled in, and — as we checked below — the same results on almost every project we tested. (Vuls still depends on Trivy — we use its parsers, just not through fanal.) The result (vuls PR #2476): | Metric | Before | After | Change | |---|---|---|---| | Scanner binary | 106.6 MB | 34.1 MB | -68% | | Scanner build time | 7.8 s | 3.2 s | -59% | How we measured: scanner-only builds (-tags scanner) with our release build settings, CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go build -a -trimpath -ldflags "-s -w". The build times are with a warm build cache. To be precise about what shrank: this is the scanner binary. The full vuls binary, which also includes the vulnerability detection and reporting code, went from 139.1 MB to 138.5 MB after this change and a follow-up one. That code still uses Trivy's vulnerability database layer, so most of the weight stays. !Left: after vuls removed its direct import of go-homedir, nine other modules, including trivy and go-getter, still required it, so go.sum stayed at 1,220 lines. Right: when vuls stopped going through fanal and called Trivy's lockfile parsers directly, what only fanal had pulled in left the build: 2,345 packages became 737, and the scanner binary went from 106.6 MB to 34.1 MB. Packages in the scanner build counted with go list -tags scanner -deps ./cmd/scanner before and after PR #2476. Don't pick leaves one by one. Find the branch they hang from. How we tested it The change rewrote how the whole scanner package reads lockfiles. Here's how we checked it. Layer 1: Golden tests. JSON output for 35 lockfile types, compared for an exact match. Layer 2: A/B comparison on real projects. We collected real lockfiles from 129 open-source projects across 13 ecosystems. Then: old code + lockfile → result A new code + lockfile → result B diff(A, B) No mocks. No hand-written expected values. Just: "does the new code produce the same results as the old code?" 127 of 129 were identical. The other 2 differed only in the pnpm dev flag, which our old wrapper code had been losing along the way. Calling Trivy's pnpm parser directly gave us the parser's correct output. Building the comparison set taught us something too. On the first pass, many fixtures produced zero packages — requirements.txt files with no pinned versions, a nearly empty yarn.lock, a root pom.xml with only <modules>. "Identical" on an empty result proves nothing, so we replaced those fixtures with ones that actually exercised the parsers. This lives in vuls as make compare-lockfile. It didn't catch everything: code review and testing on Windows still found a case the comparison had missed (.exe files weren't detected as executables), which we fixed before merging. The tool we built afterwards: uzomuzo diet We did all of the above by hand, in March 2026. Looking back, the expensive part wasn't writing code. It was deciding where to start — and our first choice was wrong. So in April we added a diet command to uzomuzo, our dependency lifecycle scanner. It ranks your direct dependencies as candidates for removal: 1. Dependency graph — from a CycloneDX SBOM: what would disappear along with each direct dependency? 2. Source coupling — tree-sitter reads your code: how many files import it, how many call sites? 3. Health — maintenance status (Active, Stalled, Archived, ...), from the same signals uzomuzo scan uses 4. Score — high impact, high risk, and easy to remove rises to the top scan and diet are rule-based. No LLM is involved. Their results reflect the dependency and maintenance data available when you run them. One caveat up front: diet is experimental. An audit against hand-checked expected values found cases where a dependency that is in use is reported as unused or trivial — for example when it's imported from a skipped directory or in an import style the analyzer doesn't recognize yet. The CLI prints a notice saying so after every run, and the Accuracy section of the docs lists what to check. Treat its output as a list of places to look, not a list of things to delete. Here's an excerpt from a run on Terraform (commit 9ec49e0, October 13, 2026; SBOM generated with cyclonedx-gomod; run on October 26, 2026): $ uzomuzo diet --sbom terraform-bom.json --source ./terraform RANK SCORE EFFORT PURL REMOVES IMPORTS CALLS STATUS 3 0.13 trivial github.com/mitchellh/gox 1 1 1 Archived 5 0.10 hard github.com/mitchellh/colorstring 0 22 79 Archived 6 0.10 moderate github.com/mitchellh/go-homedir 0 9 11 Archived ... 72 0.00 hard github.com/hashicorp/hcl/v2 0 352 6764 Active (Edited for width: four of the 77 direct dependencies, PURLs shortened to module paths, and the REMAINS column omitted. Ranks 1, 2 and 4 are not shown: 1 and 4 are Terraform's own nested internal/backend/remote-state/* modules, which the SBOM lists as direct dependencies, and 2 is an active OpenTelemetry package. The full run also reported zero "quick wins".) The ranking is a starting point for investigation, not a verdict: - gox is archived and has one import and one call site, so it gets a trivial effort label. That's exactly the kind of label to verify: check that one call site, what it does, and what replaces it. - go-homedir is the same package we removed from vuls in 15 minutes. Here it's imported in 9 files, so it's rated moderate. Removing the direct use wouldn't drop it from Terraform's dependency list either — another module still requires it. - hcl/v2 is at rank 72: 352 importing files, 6,764 call sites, and actively maintained. Don't start here. It works for Go, Python, JavaScript/TypeScript, and Java. The last mile: Claude Code skills Ranking is automated. Actually removing a dependency — tracing what data flows through it, writing the replacement, getting the change accepted — still takes judgment. uzomuzo ships three Claude Code skills for that part: /diet-assess-risk (how risky is it to keep?), /diet-evaluate-removal (is it worth the effort?), and /diet-remove (draft the change or the upstream report). They use an LLM, so review what they propose before you use it. The rules in /diet-remove come from our own mistakes. When we found that Next.js still depended on @vercel/kv, which Vercel has deprecated on npm in favor of Upstash Redis, we tried three channels by hand: - PR → we closed it ourselves after CI failed. It's a large pnpm-workspaces monorepo, and we couldn't reproduce its CI locally. - Issue → the repository's bot closed it within seconds, pointing to the bug report template. The issue tracker is for bugs with a reproduction, and ours wasn't one. - Discussion → the channel the repository's templates point to. It's still open with no reply yet. So the skill now defaults to drafting an issue rather than a PR for projects you don't own, checks whether the repository accepts blank issues, and falls back to a Discussion when it doesn't. Those rules are written from that manual experience; we haven't yet published an end-to-end run of the skill itself. And getting the channel right doesn't guarantee a response. Of our four upstream reports in April, two have been fixed and two are still waiting. Whether to fix something is the maintainers' call. Start your diet go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@latest CGO_ENABLED=1 go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo-diet@latest # needs a C compiler export GITHUB_TOKEN=... # read-only is enough; gives uzomuzo repository signals such as archived status The output in this post was produced with v0.10.0. 1. Run uzomuzo scan --file go.mod (or pipe in an SBOM for other ecosystems). See what's unmaintained. 2. Generate a CycloneDX SBOM — for example trivy fs . --format cyclonedx -o bom.json, or for Go, cyclonedx-gomod mod -json -output bom.json — and run uzomuzo diet --sbom bom.json --source .. 3. Pick a dependency whose use and replacement you understand. Remove it, then measure: binary size, dependency list, build time. If nothing moved, look for the layer it came in through. And don't give up on your diet. --- Earlier posts: "Your dependencies are 48% unmaintained — and SCA tools can't see it" and "Your CI runs other people's code. Is anyone still maintaining it?" Source and docs: future-architect/uzomuzo-oss (Apache 2.0) Kota Kanbe — Creator of vuls, an open-source vulnerability scanner.
Your CI runs other people's code. Is anyone still maintaining it? --- title: "Your CI runs other people's code. Is anyone still maintaining it?" published: true description: "Workflow scanners check how you use GitHub Actions. Few people check whether each Action is still maintained. What we found in Grafana's workflows — and how they fixed it." tags: security, opensource, github, devops cover_image: --- Every third-party uses: line in a GitHub Actions workflow runs someone else's code in your job. Depending on the workflow, that code can reach your GITHUB_TOKEN, the secrets passed to that step, and your checked-out source. In March 2025, the widely used tj-actions/changed-files Action was compromised: its tags were pointed at a malicious commit that printed CI secrets into workflow logs (CISA alert, CVE-2025-30066). That project was actively maintained — a maintenance check would not have prevented it — and a fixed release followed. That last part is the point of this post. When something goes wrong in an Action that is still maintained, a fix comes out and you upgrade. When the Action is no longer maintained, there is no next release. The question workflow scanners don't ask There are good tools for securing your workflows: | Tool | What it checks | |---|---| | OpenSSF Scorecard | Your repo's practices: dangerous workflow patterns, token permissions, pinned dependencies | | zizmor | Your workflow files: injection risks, pull_request_target misuse, and more | | actionlint | Your workflow syntax and expressions | All three look at your workflow. Scorecard does have a Maintained check, and it gives an archived repository the lowest score — but only for the repository you run it on. Few teams run Scorecard against each of the 30-odd Actions their workflows reference. Pinning Actions by commit SHA is good practice. It stops a tag from being silently repointed. But it doesn't tell you whether anyone will ship a fix for the code you pinned. !Three layers of code run in your job: your workflow file, the Actions its uses: lines reference, and Actions called inside composite Actions. Scorecard, zizmor and actionlint read the first layer. uzomuzo with --include-actions checks the repository behind each referenced Action, such as the archived tibdex/github-app-token in Grafana's release-build.yml; --show-transitive adds the third layer. You can fix the second layer by changing a uses: line; the third needs the parent Action's maintainer. uzomuzo does that inventory for you: it reads your workflows, lists every Action they reference, and reports the maintenance status of each. It complements the tools above — it doesn't replace them. What we found in Grafana (April 2026) Grafana is one of the most popular open-source projects on GitHub. In April 2026 we scanned the Actions referenced in its workflows. One of them was archived: tibdex/github-app-token. - The repository was archived in July 2025 (its last push is July 7, 2025). Its README says the Action is deprecated and points to GitHub's official actions/create-github-app-token — the maintainer did exactly the right thing. - Grafana used it in 14 places across 13 workflow files, including release-build.yml and release-npm.yml. - It declares runs.using: node20. GitHub removed Node 20 from Actions runners on September 23, 2026, and an archived Action can't be updated to Node 24. Anyone still using it after that date has a broken workflow. An Action runs in your job, with access to its workspace, not in an isolated sandbox. Depending on the workflow, it can reach GITHUB_TOKEN (with whatever permissions the job grants), the secrets passed to it, and the checked-out source. Release jobs often hold sensitive credentials. That doesn't mean anything was exposed here — it means an archived Action in a release job deserves a look. What happened next We filed an issue on April 5, 2026. Grafana had already begun moving to actions/create-github-app-token in one workflow in October 2025. A community member opened a PR for the remaining files three days after our issue. A maintainer closed it, explaining that the team had a new recommended approach using their own internal workflow. We don't know whether our issue influenced their timing. On September 15, 2026, the issue was closed as completed: "This has been done, we no longer use that action :)" — a week before Node 20 was removed. When we scanned Grafana's workflows again on October 26, 2026, tibdex/github-app-token no longer appeared, and none of the 32 Actions was archived or got a replace verdict. Reporting doesn't always lead to a fix. Of the four upstream reports we filed in April, two have been resolved and two have had no response yet. Whether and how to fix something is the maintainers' call. What a scanner can do is make sure the question gets asked. Scan your own Actions Install go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@latest Or download a binary from GitHub Releases. You need v0.8.0 or later for --fail-on archived and the · archived marker. The examples below assume GITHUB_TOKEN is set: scanning a repository's Actions reads workflow files through the GitHub API, and the token also gives uzomuzo the repository signals (archived, commit history) it needs. Check a repository export GITHUB_TOKEN=... # a read-only token is enough # Actions referenced directly in your workflows uzomuzo scan https://github.com/your-org/your-repo --include-actions # Also include Actions used inside composite Actions uzomuzo scan https://github.com/your-org/your-repo --include-actions --show-transitive A composite Action can itself uses: other Actions. Those run in your job too. The difference is who can fix them: you can swap a direct Action today, but a transitive one needs the parent Action's maintainer to act. You can also scan a single Action or workflow file: uzomuzo scan https://github.com/tibdex/github-app-token uzomuzo scan --file .github/workflows/ci.yml What an archived Action looks like $ uzomuzo scan https://github.com/tibdex/github-app-token https://github.com/actions/github-script STATUS PURL LIFECYCLE BUILD 🔴 replace https://github.com/tibdex/github-app-token Stalled · archived — ⚠️ caution https://github.com/actions/github-script Stalled — Both Actions are Stalled: neither has had much recent activity. Only one is archived, and that's the one marked · archived and given a replace verdict. The lifecycle label stays Stalled rather than EOL-Confirmed on purpose. uzomuzo uses EOL-Confirmed only for an explicit end-of-life signal, such as an npm deprecation or a Packagist "abandoned" flag. Archiving alone isn't one: some archived repositories are packages that moved to a monorepo and keep shipping releases from there. So the archive is shown as a separate fact next to the label instead. (tibdex's README does say "This action is deprecated", but uzomuzo deliberately doesn't treat README prose as a signal — free text is easy to misread.) If you read the earlier post in this series: its table showed archived repositories as EOL-Confirmed. Current versions show them as above. Gate your CI uzomuzo scan https://github.com/your-org/your-repo --include-actions --fail-on archived --fail-on archived fails the build when any referenced Action's repository is archived — and, like every --fail-on value, when a version is known to be malicious. It doesn't fire on Actions that are merely quiet. That matters: --fail-on stalled would also catch archived Actions, but in our October scan of Grafana it would have failed on four Actions that are simply less active, including actions/github-script. --fail-on eol-confirmed alone wouldn't fail on an archived Action at all. Start with a report-only run, look at what comes back, then turn the gate on. Run it on a schedule The uzomuzo repository has a monthly workflow that generates an SBOM with aquasecurity/trivy-action, runs uzomuzo scan --sbom, and opens an issue with the results — SCA and lifecycle checks side by side. That workflow covers package dependencies, not Actions. To cover Actions as well, add a step that runs uzomuzo scan https://github.com/$GITHUB_REPOSITORY --include-actions --fail-on archived with GITHUB_TOKEN set. Two layers, two questions Scorecard, zizmor, and actionlint answer: "Is my workflow configured safely?" uzomuzo answers: "Is the code my workflow runs still maintained?" A safely configured workflow can still depend on an Action that will never get another release. That isn't a known vulnerability. It's something to review before it becomes a broken build — or worse. --- Earlier post: "Your dependencies are 48% unmaintained — and SCA tools can't see it" Source and docs: future-architect/uzomuzo-oss (Apache 2.0) Kota Kanbe — Creator of vuls, an open-source vulnerability scanner.
Homelab security on 512 MB: warden scores my logs and asks before it bans anything I wanted one place that tells me what is going on in my homelab: who is poking at it from outside, which boxes have known-exploited CVEs, what needs patching, and whether anything new turned up on the network. And I wanted it on a small box, without putting an agent on every machine. The obvious tools are good, and I'll say that up front. Wazuh does far more than this, but it wants an agent per host and its all-in-one install asks for 4 vCPU and 8 GB. CrowdSec is brilliant at reading logs and banning, but it bans on its own, and behind a Cloudflare tunnel that matters more than you'd think. So I built a smaller thing that glues existing scanners together, and I've just launched it. It's called warden, it's on GitHub under AGPL-3.0, and this is how it works and what went wrong while I built it. Repo: casareanderson/warden !The warden console Overview on demo data: headline numbers, detection breakdowns, a 30-day timeline and the top-scoring addresses What it is (and isn't) warden is plain Python and one SQLite file. Collectors run on systemd timers and write to the database. The console, a REST API and an MCP server read that file and nothing else. It reaches other machines over SSH, so there's nothing to install on them. What it does: - Log detection. It reads the reverse proxy log (NPMplus/nginx), the Authelia log and the cloudflared tunnel log. It scores things like /.env and /.git/config probes, path traversal, SQL injection, scanner user agents and login failures. It proposes bans per IP and per /24. - Vulnerabilities. It checks host packages and what's actually inside running Docker containers against osv.dev. Anything on CISA's known-exploited list goes to the top, then it ranks the rest by EPSS (how likely it is to get exploited). This used to be trivy, see the update below. - Patching. It writes a patch plan per box (apt, or docker compose pull && up -d for containers). It also finds Proxmox LXC containers by itself. - Hardening and drift. Lynis scores each box, and a file integrity check flags changes to things like sshd_config, cron and systemd units. - Network. Suricata for IDS, matching against threat feeds (FireHOL level 1, Feodo, OpenPhish), plus new-device alerts on the LAN. - Edge. It snapshots the Cloudflare side: public hostnames, WAF rules, which names sit behind SSO. What it isn't: it isn't EDR in the industry sense. There's no agent and no process telemetry. The vulnerability data comes from OSV.dev, and the host checks and network alerts from Lynis and Suricata, not from me. warden is the bit in the middle that joins it all up, scores it, and asks you what to do. How it compares Every piece of this exists somewhere else, often done better. So here's where it sits, from each project's own docs: | Tool | Good at | How warden differs | |---|---|---| | Wazuh | Full SIEM: vulns, file integrity, active response | Agent on every host, all-in-one install wants 4 vCPU / 8 GB. warden is agentless, tiny, and does less | | CrowdSec | Log parsing, community blocklists | Its bouncers block on their own. warden asks first, and is built for a Cloudflare tunnel. They run fine together | | PatchMon | Patching with approvals, Proxmox LXC | Agent per host plus Postgres and Redis. warden patches over SSH and does detection too | | Vuls | Agentless vuln scanning over SSH | A scanner. warden does the scanning with no local vuln database, and adds patch plans and approvals | | Security Onion | Network monitoring, Suricata, hunting | A full platform with far bigger hardware needs. warden just reads your Suricata alerts | | Fail2ban | Bans from log patterns | Single-purpose and automatic. Behind a tunnel its firewall bans hit the tunnel, not the attacker | So the gap warden fills is the combination: agentless, one small box, edge bans from the logs you already have, and nothing changes until you say yes. It runs on 1 vCPU and 512 MB for the core. I measured the console at 29 MB resident on my box. Vulnerability scanning used to add roughly 1 GB of RAM and 2 GB of disk, mostly trivy's database. That's gone now: the new scanner peaked at 117 MB scanning every container on my NAS, and it keeps a small cache instead of a 1.4 GB database. !How warden fits together: collectors on timers write to one SQLite file; the console, REST API and MCP server only read it; bans and patches go to the owner for approval first Rule one: it never acts on its own Out of the box warden is detect-only. It records what it would ban and changes nothing. When it does want to act, it asks. A proposed edge ban or a patch plan turns up in Discord with ✅ and ❌, and nothing happens until I tap one. The same decisions show in the console. Two reasons for this. The first is the tunnel. My public services sit behind a Cloudflare tunnel. Behind a tunnel, the network-layer source of every request is cloudflared or the Cloudflare edge. The attacker's address only exists in the HTTP layer. So a local firewall ban keyed on the attacker never matches a packet. And banning what does show up at layer 3 means banning Cloudflare, which takes the whole estate offline. That's why there's no iptables code in warden at all. Bans go to Cloudflare's IP Access Rules at the edge, before the tunnel. The second is that automatic bans need a detector you trust completely, and I don't trust mine that much. The rest of this post is why. The bugs that shaped it Event time has to come from the log line The first time warden read the Authelia log, it read the whole backlog at once and stamped every event with the time it was ingested. That put weeks of July history inside the "last 60 minutes" ban window. 19 of 19 events landed in the window. With bans switched on, that would have banned people for things they did in July. The fix is a timestamp extractor per source that reads the time from the line itself. After that, 0 old events landed in the window. A related one: a fixed --since 10m lookback under a 5-minute timer counts every event twice. That quietly pushes scores towards a ban. It's now a real watermark per source, plus a dedup table. A per-IP threshold misses a spread-out attacker Seven addresses in one /24 each scored between 3 and 6, under the per-IP threshold of 12. Nothing fired. Now scores roll up by /24 as well. A range ban needs a higher total (20) and at least 3 different addresses. That's stricter on purpose, because blocking a /24 is a much blunter instrument. It also refuses any range that contains an allowlisted address, like my own. (Those seven turned out to be old OIDC client-auth failures, not a live brute force. Check the dates before calling something an attack.) My own IP was the loudest attacker This one was this morning. 1,526 of the last 1,577 events came from my own WAN address. An Uptime Kuma monitor was hitting a URL that 404s once a minute, and every 404 scored. My own IPs were already excluded from bans. But they still filled the counts, the timeline and the recent list, and pushed the real rows off the screen. Now they're dropped from every count, and the console says how many it ignored. After the fix the real number for the day was 51. The first version of that fix had its own bug, and a test caught it. I fetched 20 times the rows I needed and then filtered. With 150 of my own rows sitting newer than the real ones, it came back with nothing. It now pages through until it has enough real rows. !The console's 24-hour detections tile now counts only real traffic and says how many own-address rows it ignored The patcher that replied 328 times The patcher writes to the database and posts to Discord. The Discord helper also writes to the database, on its own connection. The patcher was posting while still holding an uncommitted write. So the helper got database is locked, the run died before it committed, the job stayed pending, and two minutes later it posted "⌛ expired" again. 328 messages in one day, and the loop also blocked two jobs I'd already approved. The rule now: commit before any notify call. I found the same bug in the edge-ban module and fixed it there too. A setting that was a root shell The day after v2 went public I had an independent review done on the code. It found 8 problems, and I fixed all of them with a regression test for each. The worst: the Suricata host was an editable setting in the console, and it was passed to ssh. Something like -oProxyCommand=... would have been root code execution from a settings box. That setting is gone. Settings the console can edit now live in a separate overrides file, and secrets are write-only. Another was a token on the command line. The trivy server was started with --token in its arguments, which any local user can read with ps. I moved it to a 0600 environment file, and the scanners got it on stdin. Since then trivy's gone altogether, so there's no server and no token left to leak. !Before and after: trivy token in the process list, then gone, and the server still refusing a request without the token A slow page on a slow disk People said the console was slow to open. The server built its data in 0.3 seconds, and the browser drew it in 36 ms. But the first load after a quiet spell took 8.9 seconds. The database is 100 MB on a 5400 rpm disk shared with other guests, and a cold read waited behind everything else. The console now keeps one warm copy, rebuilds it in the background, and drops it the moment you change anything. A load now takes about 0.15 seconds. Talking to it from an AI agent warden has a read-only REST API and an MCP server, both behind bearer tokens. Tokens are hashed at rest and can be revoked. My Hermes security agent connects to it and gets 13 tools: summary, detections, top IPs, an IP lookup, bans, vulnerabilities, a CVE lookup, integrity, IDS and so on. So the agent can answer "anything dodgy last night?" from facts in the database instead of guessing. It's read-only on purpose. An agent can look, but it can't approve anything. Approvals stay with a person. One gotcha if you put SSO in front of it: let /mcp and /api/v1 straight through. An MCP client has no browser and can't follow a login redirect, so the bearer token is the auth on those two paths. Everything else stays behind the login. !Settings: decisions waiting for approval, the setup check, API tokens, and copy-paste snippets for connecting an agent Update, 10 October: trivy's gone I've taken trivy out. It was the heaviest part of warden: a 161 MB binary, a server process and a 1.4 GB database, and its own release got backdoored this year (CVE-2026-33634). warden already copies each box's package list, so now it checks those lists against OSV.dev directly. On my Proxmox node it found 680 of the 691 CVEs trivy did, both agreed nothing there was fixable, and the odd ones out were down to the two databases updating at different times. It also binned two "actively exploited" trivy alerts for a package one container doesn't even run any more, because it scans what's running rather than the image. What's missing Being honest about the gaps: - "Sign in with…". warden now has its own login (users, viewer/approver/admin roles, optional 2FA, lockout), added today. What it doesn't have yet is signing in with your own identity provider directly; for now you put your SSO in front. - Chat tools. Approvals only go to Discord right now. Slack, Teams, Telegram and Matrix are next. - Ban expiry. Bans record an expiry, but nothing lifts them yet, so Cloudflare rules pile up. - Threat-list blocking. It matches traffic against threat feeds but doesn't turn them into blocks yet. When it does, a script will build the list and you'll approve it. The AI won't pick who gets blocked. Try it It's free and open source (AGPL-3.0). The core runs happily in a small LXC. There's a setup wizard that checks the running system rather than just your config file, and a demo data script if you just want to look at the console first. casareanderson/warden If you self-host, I'd like to know what you'd want from it. Which of the gaps above would you hit first, or what else is missing? Tell me in the comments and I'll build the top ones.