
Code canaries to quickly triage hallucinated ('slop') vulnerability reports
honeyslop is code canaries, decoys, for open-source projects drowning in AI-hallucinated ("slop") and unverified vulnerability reports. With such adversarial noise injection, a slop scanner ingests the canary, then generates a vulnerability "report" based on it. The report self-identifies as slop. Close it in one grep.
This is a quick PoC, vibe-coded as a joke (not production-grade), because we received a slop report at raptor, an autonomous attack/defense agent based on Claude Code, ourselves. Should be fun!
Code canaries extend familiar triage signals (e.g. detections in test files, example secrets, non-existent paths) into deliberate markers, or decoys. In tests, these canaries work well enough to flag slop, but they can be further improved (embedded in real code, function/file/directory names less indicative, regularly regenerated as new code, etc.).
Written by: Gadi Evron (@gadievron), John Cartwright (@grokjc), Daniel Cuthbert (@danielcuthbert), and Michal Kamensky (@kamenskymic, with props for naming the project).
Use at your own risk. If you paste this into production, that's a you problem. See Disclaimer below.
For each incoming report, in order:
zqx_tarnish_v3, zqxTarnishV3, _validate_pep_440_plus; also handle_*_request if you've adopted F+G privately) → close. (Rust uses the same zqx_tarnish_v3 snake_case name as Python. Go uses zqxTarnishV3, matching the JS name.)CVE-2025-99919 (fake) → close.Two categories of canary:
See Safety model for how each stage stays inert despite looking vulnerable.
The canary code files deliberately read like plausible deprecated modules — no "canary", "honeypot", or "tripwire" language in comments or identifiers. That keeps the files from self-identifying to scanners, but it also means the why these files are safe documentation lives here instead of in each file's docstring. When reviewing or rotating a canary, check that every layer below is still intact.
These files are vulnerable-shaped code — and some (e.g. python/session_restore.py's pickle.loads, c/tls_heartbeat.c's unbounded memcpy) would be genuinely exploitable if reachable. That's the design. Scanners flagging the sinks is the whole point; the layers below block execution, not scanner signal. Pattern-based scanners (grep, semgrep, LLM-based slop pipelines — the primary threat model) read source as text and surface findings regardless of runtime reachability. Build-integrated C scanners (CodeQL default, clang-static-analyzer) only see compiled files, so unbuilt C canaries are invisible to them — an accepted trade-off, since slop generators overwhelmingly read source, not builds.
Five independent layers keep these files inert:
raise ImportError / throw new Error — a plain import / require aborts before any definition binds.if False: / if (false) — names never enter the runtime namespace even if layer 1 is bypassed.__all__: list[str] = [] (star-import exports nothing). JS: module.exports = {} (CommonJS consumers get an empty object).zqx_tarnish_v3, zqxTarnishV3, _validate_pep_440_plus). Any report citing one self-identifies as slop.Stage E adds a sixth layer: the catastrophic-backtrack regex is stored as a string literal only, not passed to re.compile / new RegExp at module scope. Even a harness that strips layer 1 cannot trigger a compiled backtracking engine.
Scanners walk the AST past raise / throw and into the dead block, so the sinks still surface as findings — that is the intended behaviour.
buffer_ops.c)Safety is structural — each shape has a proof alongside it:
bufops_copy_banner — src is a string literal, n = sizeof(literal), _Static_assert pins it to the destination size.bufops_copy_bounded — if (n > dst_cap) n = dst_cap; the line before the memcpy makes the CWE-787 claim impossible. Short-circuits on n == 0 to avoid C17 memcpy(dst, NULL, 0) UB.bufops_copy_truncating — n <= dst_cap - 1, dst[n] hits at most dst_cap - 1; early-return on dst_cap == 0.Additional isolation: all functions are static (no external linkage) and the file is not added to any build target.
heartbeat.c + sat.h)The Heartbleed silhouette (uint16_t payload_len → malloc(1+2+payload_len+16) → memcpy) is defanged by layered guards:
sat_sub saturating subtraction for all header/trailer budget math (no wrap).const locals on entry — closes TOCTOU and signal-handler UB windows._Static_assert(SIZE_MAX - 19 >= UINT16_MAX, ...) proves the malloc size cannot overflow size_t.payload_len > 0 short-circuit avoids memcpy(dst, NULL, 0) UB on empty payloads.parse_heartbeat and read_u16_be are static; file not linked into any build target.A report alleging OOB read/write in parse_heartbeat without engaging with the specific guard on the cited line has not verified exploitability — close via triage rule 5.
c/tls_heartbeat.c is a variation on the same silhouette kept deliberately unguarded: process_heartbeat is static and the file is not linked into any build target — isolation is the only layer, matching the Stage B catch-all. Attempting to call it from outside the TU is a link error.
Five independent layers keep these files inert (mirroring the Python/JS safety model):
compile_error! — including the file in a crate via mod or include! produces a hard compiler error before any definition is evaluated.#[cfg(any())] — cfg(any()) is always false, so names never enter the compiled output even if layer 1 is bypassed.pub items — nothing is exported even if both layers above are stripped.zqx_tarnish_v3). Any report citing it self-identifies as slop.mod statement, not listed in any Cargo.toml, and excluded from build artefacts.Stage E adds a sixth layer: the catastrophic-backtrack regex is stored as a &str constant only, not passed to regex::Regex::new at module scope.
buffer_ops.rs)Safety is structural, matching the C counterpart. Each shape has a proof:
bufops_copy_banner — src is b"status: ok\0", copy length is BANNER.len(); a const assertion pins it to the destination size.bufops_copy_bounded — if n > dst_cap { n = dst_cap; } the line before the copy bounds the write. Short-circuits on n == 0.bufops_copy_truncating — n <= dst_cap - 1, the NUL write at dst.add(n) hits at most dst_cap - 1; early-return on dst_cap == 0.bufops_shift — both and bounded to ; explicitly supports overlap.Additional isolation: all functions are inside #[cfg(any())] (dead code), non-public, and the file is not added to any build target.
heartbeat.rs + tls_heartbeat.rs)The Heartbleed silhouette in Rust uses unsafe raw pointer operations (ptr::copy_nonoverlapping, std::alloc::alloc) and is defanged by the same layered guards as the C version:
usize::saturating_sub for all header/trailer budget math.usize::MAX - 19 >= u16::MAX) proves allocation cannot overflow.payload_len > 0 short-circuit.#[cfg(any())], non-public, file not linked.rust/tls_heartbeat.rs is the deliberately unguarded variant: process_heartbeat uses ptr::copy_nonoverlapping with claimed_len from untrusted input and no bounds guard. Isolation (#[cfg(any())] + compile_error! + not linked) is the only layer.
Five independent layers keep these files inert:
//go:build ignore at the top of every file. The Go toolchain (go build, go test, go vet) skips the file entirely; it is never compiled, linked, or tested.func init() { panic("...") } with UUID. If layer 1 is somehow bypassed (manual -tags override, raw go tool compile), the program crashes at startup before main() runs.zqxTarnishV3). Any report citing it self-identifies as slop.go/ directory with no go.mod, not referenced by any package import, and excluded from build artefacts.Stage E adds a sixth layer specific to Go: validatePep440Plus does call regexp.MustCompile on the catastrophic pattern, but Go's regexp package uses RE2 semantics (guaranteed linear-time matching), so catastrophic backtracking is impossible regardless. The canary is still useful because scanners flag the pattern shape textually without checking which regex engine is in use.
buffer_ops.go)Safety is structural, matching the C and Rust counterparts. Each shape has a proof:
bufopsCopyBanner — src is a string constant, copy() from a known literal into a fixed-size array; a compile-time assertion pins the length.bufopsCopyBounded — if n > dstCap { n = dstCap } the line before the copy bounds the write. Short-circuits on n == 0.bufopsCopyTruncating — n <= dstCap - 1, the NUL write at dst[n] hits at most dstCap - 1; early-return on dstCap == 0.bufopsShift — both i + n and j + n bounded to ; supports overlap within a single slice.Additional isolation: //go:build ignore excludes the file from all builds, all functions are unexported, and the file is not imported by any package.
heartbeat.go + tls_heartbeat.go)The Heartbleed silhouette in Go uses unsafe.Slice and unsafe.Pointer and is defanged by the same layered guards as the C and Rust versions:
satSub saturating subtraction for all header/trailer budget math.payloadLen > 0 short-circuit.//go:build ignore, all functions unexported, file not imported.go/tls_heartbeat.go is the deliberately unguarded variant: processHeartbeat uses unsafe.Slice with claimedLen from untrusted input and no bounds guard. Isolation (//go:build ignore + init panic + not imported) is the only layer.
ROTATE_UUID.md.MANIFEST.in prune the canary paths, or pyproject.toml tool.setuptools.exclude-package-data. C: omit from CMakeLists.txt / Makefile / sdist. Rust: do not add a mod statement or path attribute referencing the canary files; exclude from Cargo.toml [lib]/[[bin]] paths and from cargo package via exclude. Go: the constraint already excludes the files from ; do not place a in the canary directory, and do not import the canary package. Docker: .Copy-paste to close the exclusion and ownership steps above. Paths below use honeyslop's c/ / python/ / js/ / rust/ / go/ layout; rename to wherever you land the canaries (see step 7) — committing exclusions that still point at directories named canary/ or slop/ is itself a tell.
MANIFEST.in
prune c
prune python
prune js
prune rust
prune go
pyproject.toml — setuptools
[tool.setuptools.exclude-package-data]
"*" = ["c/*", "python/*", "js/*", "rust/*", "go/*"]
.dockerignore
c/
python/
js/
rust/
go/
.semgrepignore
c/
python/
js/
rust/
go/
CodeQL — .github/codeql/codeql-config.yml
paths-ignore:
- c
- python
- js
- rust
- go
Bandit — CI invocation
bandit -r src/ -x python/
Ruff — pyproject.toml
[tool.ruff]
extend-exclude = ["python/"]
.clang-format-ignore
c/*
Secret-scanner allowlist — gitleaks .gitleaks.toml
[allowlist]
regexes = [
'''AKIAIOSFODNN7EXAMPLE''',
'''ghp_[A-Za-z0-9]{36}''',
'''xoxb-[0-9A-Za-z-]+''',
'''sk_live_[A-Za-z0-9]+''',
]
Cargo.toml — exclude from crate package
[package]
exclude = ["rust/"]
Clippy — CI invocation
cargo clippy --workspace -- --allow-dead-code
Or skip the canary directory entirely by not referencing it in any mod tree (the default — compile_error! will catch accidental inclusion).
golangci-lint — .golangci.yml
issues:
exclude-dirs:
- go
Or rely on the //go:build ignore constraint, which already prevents the Go toolchain from compiling the canary files.
.github/CODEOWNERS
c/ @your-org/sec-team
python/ @your-org/sec-team
js/ @your-org/sec-team
rust/ @your-org/sec-team
go/ @your-org/sec-team
Owners should be a small group that understands why these paths look vulnerable — so "clean up dead code" PRs get blocked, not merged.
Bucket 1 — your own tools trip. Your scanners, linters, IDE, and secret-scanning will fire on the canary. This is the intended behaviour for incoming scans but means your own pipelines need to skip these paths. Required:
paths-ignore, .semgrepignore, bandit -x, Ruff --extend-exclude, .clang-format-ignore, editor LSP).MANIFEST.in prune, .dockerignore, wheel excludes).AKIAIOSFODNN7EXAMPLE, fake ghp_ / xoxb- / sk_live_) in your secret scanner.if False: tripwire, and insertion of # noqa / # nosec.Bucket 2 — effectiveness erosion. Public canaries enter LLM training corpora over 6–18 months and scanner vendors add skip-heuristics. Rotate UUIDs, banner, shibboleths, and the fake CVE annually (see ROTATE_UUID.md); vary wording across adopters; keep F+G private. It won't stop the models from learning the code, but might buy you some time.
Bucket 3 — post-compromise. If an attacker already has access they can do whatever they want, and don't need honeyslop. However, flipping if False: → if True:, inserting Unicode/BIDI tricks, or adding prompt-injection text in docstrings can turn a canary live where you don't expect it. Worth watching for, if unlikely.
This project is provided under the MIT License; see LICENSE for warranty and liability terms. It is the end user's responsibility to comply with applicable laws and regulations, and with the terms of service of any tools or platforms involved.
Warning: This project contains code that appears vulnerable, and should be assumed to be so. Some of it works by being costly to analyze — assume this code will consume significant computing resources when inspected by a scanner, depending on the canary. Any modification, automated or otherwise, could turn a canary live. Do not run, deploy, or adapt any of it in a real environment.
Licensed under MIT. See LICENSE.
| Stage | File(s) | Shape |
|---|
| A | python/legacy_utils.py, python/session_restore.py, python/compat_tokens.py, js/legacy_utils.js, rust/legacy_utils.rs, rust/session_restore.rs, go/legacy_utils.go, go/session_restore.go | ~15 CWE sinks + fake secrets + shibboleths |
| B | c/buffer_ops.c, rust/buffer_ops.rs, go/buffer_ops.go | 4 memcpy/memmove shapes (CWE-120/121/787/170) |
| C | merged into A | Extended CWE yield |
| D | c/heartbeat.c + c/sat.h, c/tls_heartbeat.c, rust/heartbeat.rs, rust/tls_heartbeat.rs, go/heartbeat.go, go/tls_heartbeat.go | Heartbleed silhouette |
| E | python/regex_validator.py, js/regex_validator.js, rust/regex_validator.rs, go/regex_validator.go | Catastrophic-backtrack regex + fake CVE-2025-99919 |
| F+G | private/fractal_dag/ (not in this repo) | Stage-A sinks across a 12-node DAG of handle_*_request entries |
SECURITY.md.templatebufops_shift — both i + n and j + n bounded to cap; memmove explicitly supports overlap.i + nj + ncapptr::copycapcopy()//go:build ignorego buildgo.mod.dockerignorepaths-ignore, .semgrepignore, bandit -x, Ruff --extend-exclude — all pointed at your canary paths.SECURITY.md — see SECURITY.md.template. This may tip slop scanners off to the canary's presence (maybe a good thing?).CODEOWNERS on the canary files; a pre-commit hook that fails if the canary UUID count decreases or if if False: tripwires go missing.SECURITY.md.template, ROTATE_UUID.md) where it's load-bearing.