No description
Find a file
ekaii beef795037
Some checks failed
test / scan-self (push) Failing after 23s
ci: block-style env (valid YAML)
2026-07-15 12:27:58 +02:00
.forgejo/workflows ci: block-style env (valid YAML) 2026-07-15 12:27:58 +02:00
examples initial: plugin-leak-scanner v0.1.0 — catches the 2026-05-05 EH leak 2026-05-05 20:25:39 +02:00
scanner initial: plugin-leak-scanner v0.1.0 — catches the 2026-05-05 EH leak 2026-05-05 20:25:39 +02:00
tests initial: plugin-leak-scanner v0.1.0 — catches the 2026-05-05 EH leak 2026-05-05 20:25:39 +02:00
.gitignore initial: plugin-leak-scanner v0.1.0 — catches the 2026-05-05 EH leak 2026-05-05 20:25:39 +02:00
LICENSE chore: prepare for public release 2026-07-15 01:31:48 +02:00
pyproject.toml chore: prepare for public release 2026-07-15 01:31:48 +02:00
README.md initial: plugin-leak-scanner v0.1.0 — catches the 2026-05-05 EH leak 2026-05-05 20:25:39 +02:00

plugin-leak-scanner

Static analysis for Netty ByteBuf and JDK DirectByteBuffer leak patterns in Minecraft plugin jars. Built after the 2026-05-05 incident where ExtendedHorizons-3.1.2-dev2 exhausted the Netty direct-buffer pool because a Caffeine.Cache<ChunkKey, CompletableFuture<ByteBuf>> was created without a removalListener — expired entries dropped their ByteBuf on the floor without release(), players got kicked, and the server couldn't accept new connections until restart.

The scanner is intended to be run as a CI gate before any plugin upgrade hits production.

What it scans

Pattern A — Caffeine cache of ByteBuf without removal listener (HIGH)

The exact shape of the EH bug. For every method we walk the bytecode and isolate "builder chains": from Caffeine.newBuilder() to the next Caffeine.build*(). Inside each chain we record whether removalListener or evictionListener was called. We then correlate each chain's astore slot with the class field it eventually gets putfield'd into, and look up that field's generic Signature: attribute. If the value type involves io.netty.buffer.ByteBuf (raw, Optional<ByteBuf>, CompletableFuture<ByteBuf>, etc.) and no listener was attached, we emit a HIGH finding.

Caffeine's package is matched by suffix (/caffeine/cache/Caffeine) so the scanner works against shaded relocations like me/mapacheee/lib/caffeine/cache/Caffeine.

Pattern B — ByteBuf alloc/release imbalance (MEDIUM)

Class-level heuristic. Counts calls to:

  • Allocators: (Pooled|Unpooled)?ByteBufAllocator.{directBuffer,heapBuffer,buffer,ioBuffer,...}, Unpooled.{directBuffer,...}, ByteBufUtil.{wrappedBuffer,...}
  • Releases: ReferenceCountUtil.{release,safeRelease}, ByteBuf.release

If alloc - release > 5 on a single class, we emit a MEDIUM finding. This is intentionally conservative; it's a "look here" pointer, not proof of a bug.

Pattern C / D — skipped for v1

ByteBuffer.allocateDirect() without a reachable Cleaner, and ChannelInboundHandler retaining ByteBuf in fields without an explicit release in channelInactive/handlerRemoved, are both hard to verify without a real data-flow analysis. Listed in scanner/patterns/__init__.py TODO for a future pass.

Usage

plugin-leak-scanner SCAN /path/to/MyPlugin.jar           # JSON to stdout
plugin-leak-scanner SCAN /path/to/MyPlugin.jar --text    # human-readable
plugin-leak-scanner SCAN /path/to/MyPlugin.jar --exit-code-on-high
plugin-leak-scanner BATCH /data/plugins                  # all *.jar

--exit-code-on-high exits with code 2 when any HIGH finding is present; that's what you wire into CI:

# .forgejo/workflows/plugin-gate.yml
- name: scan plugin
  run: python -m scanner SCAN dist/MyPlugin.jar --exit-code-on-high

The JSON report shape:

{
  "jar": "ExtendedHorizons-3.1.2-dev2.jar",
  "scanned_at": "2026-05-05T20:00:00Z",
  "classes_scanned": 145,
  "classes_failed": 0,
  "findings": [
    {
      "severity": "HIGH",
      "pattern": "A",
      "class": "me/mapacheee/extendedhorizons/fakechunks/cache/ChunkBuildCacheService",
      "method": "rebuildCaches",
      "field": "buildEntryCache",
      "build_offset": 87,
      "msg": "Caffeine cache of ByteBuf (field 'buildEntryCache') built without removalListener/evictionListener — see 2026-05-05 ExtendedHorizons incident"
    }
  ],
  "summary": {"high": 1, "medium": 0, "low": 0}
}

Requirements

  • Python 3.10+ (stdlib only — no pip packages)
  • JDK with javap on disk. Override with the JAVAP_BIN env var if it's not at one of the default candidates (/usr/lib/jvm/java-21-openjdk-amd64/bin/javap, /usr/lib/jvm/default-java/bin/javap, or anything resolved by PATH).

Known false positives

  • Caffeine builders that hand the cache to a third party for listener installation. If the builder is returned from the method and the listener is attached by the caller, we'll flag it. Rare in practice.
  • Builder chains that store into an unindexed local slot we can't track. When Caffeine.build() is followed by astore into a slot we couldn't parse from javap output (very rare), and the class has at least one Cache<*, ByteBuf*> field, we drop the severity to MEDIUM with a note rather than assert HIGH.
  • Pattern B noise. Encoders that allocate buffers for downstream ChannelHandlerContext.write() always look like they "leak" because the release happens in Netty's pipeline. Investigate before acting.

Tests

PLUGIN_LEAK_SCANNER_LEAKY_JAR=/tmp/extendedhorizons-leaky-real.jar \
PLUGIN_LEAK_SCANNER_FIXED_JAR=/tmp/extendedhorizons-fixed.jar \
python -m unittest tests.test_known_cases

Both jars are required for the regression suite to assert the scanner still catches the 2026-05-05 case.

Limitations

  • javap-based, no full data-flow. We track astore N → aload N → putfield one hop deep; anything richer (chains across method boundaries, conditional builders) needs ASM. We accept that trade-off to keep the scanner zero-dependency.
  • Per-class disassembly is O(jars × classes). Scanning AnarchyExploitFixes (1533 classes) on a typical workstation takes ~30–40 s.