I called it a vulnerability. GitHub called it a quality issue.

It started the way most of my bugs start: I was looking at something completely unrelated, noticed a detail that didn't add up, and couldn't make myself look away from it.

In this case, the detail was a dependency in my own repo's dependency graph that I had never written, imported, or installed. Forty-eight hours later I had a nine-test reproduction matrix, a full SPDX export comparison, a HackerOne account I made specifically for this, and a closure notice that very politely told me none of it counted.

This is the story of how that happened, in roughly the order it happened, including the parts where I was clearly enjoying myself more than the severity of the bug justified.


The dependency that wasn't supposed to exist

I was reviewing a project's dependency graph - just routine housekeeping, checking that something had updated - and saw a package name I didn't recognize. Not a typosquat. Not malware. Just a name, sitting there, claimed as a direct dependency, with no corresponding line in any manifest I'd written.

My first thought wasn't this could be a security issue. It was closer to why is this here - the same instinct that makes you reread a sentence because one word feels off. I went looking for the source.

It turned out to be a .txt file. A plain one. Sitting at the root of the repo with a single unrelated line of text in it.

[!NOTE] At this point I had two options: delete the file and move on with my life, or find out exactly how far this goes. If you've read any of my other posts, you already know which one happened.


Building something I could actually point to

A bug you found by accident in your own repo is a story. A bug you can reproduce on demand in a clean, disposable repo is a report. So I built one.

deptest/
├── guava.txt
└── pyproject.toml

pyproject.toml had a normal, minimal Python project definition. guava.txt had exactly one line:

guavasAreTasty

Naming the fake dependency after my own GitHub handle felt obligatory rather than clever - if I was going to spend two days on this, the test package may as well be guavasaretasty. Within a minute of pushing, the dependency graph picked it up:

guavasaretasty
Direct
pip · pyproject.toml

Not "found in guava.txt." Attributed to pyproject.toml - the actual manifest. A made-up word in an unrelated text file had been folded into the project's declared dependencies as if I'd typed it there myself.

Renaming the file to drop the .txt extension made the entry vanish on the next scan. So the trigger wasn't content-based detection going sideways - it was almost entirely keyed on the file extension. A .txt file at root was apparently as good as a requirements file, as far as Dependabot was concerned.


Filed it. Forgot about it for negative four hours.

I opened a public issue on dependabot-core with the minimal reproduction - file, content, screenshot, expected vs. observed behavior. The kind of clean, boring bug report that's supposed to take a triager thirty seconds to understand.

It got labeled and assigned within about four hours. Genuinely faster turnaround than most of the actual feature requests I've seen sitting in that tracker for months. There's something almost funny about a one-line text file getting more responsive engineering attention than a real feature would.

I should have stopped there. The bug was documented, triaged, and someone was looking at it. A reasonable person closes the laptop at this point.


"I should probably check how far this goes"

Instead I built a test matrix. Nine variations, each changing exactly one thing, each with its own screenshot and exported SBOM snapshot, because at some point past midnight I had fully transitioned from "guy who found a weird bug" to "unpaid QA department for a feature nobody asked me to test."

# What changed Result
1 Empty pyproject.toml Still injected
2 Multiple lines in the .txt Each line became its own ghost package
3 Mixed real and fake names Both appear; only real ones are clickable
4 Multiple .txt files Each injects independently
5 Multiple files, mixed real/fake Same pattern, scales with file count
6 Real deps in manifest + fake .txt Coexist cleanly, no suppression
7 .txt nested one directory deep Still injected
8 Nested + real deps present Both appear correctly
9 .txt files across 10 nested levels Only injects 1 directory deep: root/sub/a.txt works; root/sub/sub/a.txt doesn't

timeline diagram

By test six I had a folder structure that looked like it belonged to someone with a budget and a team, not someone doing this on a laptop for free out of pure stubbornness.


The moment it stopped being funny

I exported the SBOM and put the real dependency next to the fake one.

{
  "name": "setuptools",
  "externalRefs": [{
    "referenceType": "purl",
    "referenceLocator": "pkg:pypi/setuptools"
  }]
},
{
  "name": "guavasaretasty",
  "externalRefs": [{
    "referenceType": "purl",
    "referenceLocator": "pkg:pypi/guavasaretasty"
  }]
}

Identical shape. Identical fields. Identical DEPENDS_ON relationship back to the repo. The only difference between a real PyPI package and a word I made up in a text file was whether clicking it resolved to anything.

dependency graph showing the fake package attributed to pyproject.toml

This is the part where the bug stopped being a curiosity and started being a sentence with the word "compliance" in it. SBOM exports get used to satisfy regulatory requirements - the document is supposed to be something an auditor can trust without independently re-deriving it. If the document itself can't distinguish a dependency from a stray sentence in a README.txt, that trust doesn't really hold, and none of it requires anyone malicious. Plenty of ordinary repos have a CHANGELOG.txt sitting at root for reasons that have nothing to do with Python packages.


Making an account just to tell someone

Step one of filing a private report, it turns out, is creating a HackerOne account. I did not have one. I have never needed one. It felt slightly absurd to sign up for a security research platform specifically to report a bug I found by being nosy about my own dependency graph.

Then came the weakness type dropdown - a single-select field with what felt like several hundred CWE options, none of which were obviously my bug.

A non-exhaustive list of things my bug was not
  • Array Index Underflow
  • Classic Buffer Overflow
  • Buffer Under-read
  • CRLF Injection
  • Reusing a Nonce, Key Pair in Encryption
  • Stack Overflow
  • Integer Underflow
  • Heap Overflow
  • Double Free
  • NULL Pointer Dereference

I eventually landed on Resource Injection by a process of elimination rather than confidence. Closest available fit for "untrusted text is being used to identify a resource that gets treated as real."

The high

The report auto-scored Severity: High (8.3). I will be honest about how this felt - for about a day I genuinely thought this might go somewhere. High severity, on a finding I made while bored, on a platform with hundreds of millions of repositories. The math in my head was already running.


The verdict

Sixteen hours later, GitHub's security team closed the report.

Thanks for the submission! We have reviewed your report and determined that it does not present a security risk... The behavior you've identified is a software quality issue with Dependabot's Python file detection heuristic, not a security vulnerability... Best regards and happy hacking!

"Happy hacking" as a sign-off on a report that just got told it doesn't count is, I think, the platonic ideal of corporate cheerfulness.

Their argument is fair, as far as it goes: exploiting this deliberately requires write access, and someone with write access already has stronger options than planting fake dependency names. What it doesn't really address is the passive case - a repo with a normal TODO.txt, no attacker anywhere in the picture, quietly producing a corrupted SBOM as a side effect of just existing. That's not a security vulnerability by their definition. It's also not nothing.


Asking, and the silence that followed

I asked, politely, whether I could write publicly about the finding - full detail, partial, or wait for a fix. Six days of silence later, I checked the program's own published rules and found the answer had been sitting there the entire time: researchers are free to publish write-ups, full stop, with the only ask being to hold off if it would put users at risk before a patch ships. Since GitHub had already decided this wasn't a security risk in the first place, that caveat barely applied to begin with.

So: this post.


What I actually walked away with

No bounty. No CVE. A report sitting in HackerOne's system labeled Informative, which is the platform's polite way of saying interesting, but not our problem.

What I do have is a public issue that's still open, a reproduction repo that still works exactly as described, and proof to myself that when something doesn't add up, pulling on it for two straight days is a thing I'm capable of doing - methodically, not just curiously.

The bug is still there. Somewhere, right now, a repo with an innocuous LICENSE.txt might have a dependency in its SBOM that was never installed, never imported, and never real. Nobody attacked it. It just happened, the same way it happened to me, because I was bored enough on a Tuesday to ask why a string of text had a PyPI page.

A full archive of the test matrix - SBOM exports and screenshots for each variation - is available here.