August 7, 2026 · 7 min read
We Scanned Ourselves and Got 14 False Positives. The Real Bug Was in the Sanitizer.
On August 7, 2026 we pointed CodeTruss at CodeTruss.
The report came back with health 77, security 50, technical debt 72, architecture 100, and documentation 100. The security 50 was driven by exactly 14 HIGH findings. The same pipeline, on the same day, scored codetruss-cli 89 health and 89 security, and codetruss-plugins 99 health and 100 security.
Then we adjudicated it. Thirteen agents, and every verdict was handed to a different agent whose only job was to refute it.
All 14 HIGH findings were false positives.
And while we were arguing about those 14, we found a real open redirect in production. Live, unauthenticated, and absent from the report. It sat inside the same helper the scan had flagged seven safe uses of.
That last sentence is the post. The rest is how.
The bug it missed
safeInternalPath is the one function this application uses to turn an untrusted post-login destination into a same-origin path. Everything arriving on ?from= or ?next= goes through it.
It validated its input and returned its output, and those were not the same string.
safeInternalPath('/..//evil.com') -> '//evil.com'
The input starts with exactly one slash, so the prefix check passes. Then new URL parses it and collapses the dot segments, and the collapsing happens after the check. What comes back is protocol-relative, which a browser reads as a host. //evil.com is not a path on our site. It is https://evil.com.
Two things fell out of that.
The first is an ordinary open redirect. At one call site the sanitizer was applied once and the result was rendered straight into a <Link href>. A crafted from parameter put an attacker's origin into a link on our own page.
The second is worse in the boring way. safeInternalPath('/..//') returns '//', and new URL('//', base) throws. That call was not inside a try/catch, so /login, /register, /onboarding, and /auth/continue returned a 500 to anyone who requested them with that query value. No account required. Every unauthenticated entry point to the product, broken by five characters.
The fix is one line of principle: validate the output, not the input. Reject anything that does not start with /, and reject anything that starts with //. Verified in production, /login?from=/..// went from 500 to 200.
Now the part I would rather not write. Seven other call sites were safe, and they were safe by accident. On those paths the sanitizer happened to run twice, and the second pass cleaned up what the first pass created. Nobody designed that. If anyone had ever collapsed one of those double-sanitized paths into a single call, which is exactly the kind of tidying that looks like an improvement in review, it would have become the eighth bug.
Go check yours before you finish reading this. If you have a helper that turns an untrusted next, from, redirect, or returnTo parameter into an internal path, call it with /..//evil.com and look at what comes back. If it starts with //, you have an open redirect. Then call it with /..// and see whether the caller survives.
Why our own analyzer could not see it
Sanitizer recognition in the engine is a hardcoded allowlist of function names, with taint analysis that stops at the file boundary.
So when a value passed through something whose name was on the list, the engine marked the flow clean and moved on. It never read the function. Recognizing a sanitizer by name is an assertion that the function is correct, made about code the analysis never looked at. When the sanitizer is the bug, a name-based allowlist is guaranteed to miss it, and it will confidently report the call sites as fine on the way past.
That same design is also one of the three false-positive classes, from the other direction. Any repository that does the right thing and centralizes redirect validation in one helper gets a burst of false HIGH findings on the call sites, because a file-local pass cannot see across the import to the check that already ran. Do the tidy thing, get yelled at.
The 14
Three durable classes, and every one of them hits customer repositories, not just ours.
The size analyzer counted comments as code. It reported packages/analyzer-engine/src/security/js-parse/parser.ts at 2,202 lines of code. The engine's own language classifier, sitting in the same repository, counts 1,995. The threshold that makes that finding HIGH is 2,000. The entire finding lived inside a 207-line gap made of comments. And 2,202 is exactly the number of non-blank lines in that file, which is what the analyzer was actually measuring while printing the words "lines of code". Anyone with wc can take that number apart, which is the correct standard for any number we print.
process.argv was treated as attacker-controlled, unconditionally. Eight of the fourteen came from that single assumption, all of them in our own developer scripts. A command-line argument is untrusted when the process is a service somebody else can invoke. It is not untrusted when the process is a script an engineer is typing into their own shell. Treating those two situations identically produces a HIGH finding every time someone writes a maintenance script, which is an efficient way to teach a team that HIGH means nothing.
The sanitizer allowlist, described above.
Fourteen findings, fourteen wrong. The number to sit with is not fourteen. It is that a HIGH finding is a claim on somebody's afternoon, and the ones you have not earned come out of the next finding that is real.
Our own suppression could be defeated by planting text
This one is separate from the scan, and it is the most serious thing the day produced.
CodeTruss lets you suppress a finding with a codetruss-ignore: marker. Placed on the line above a finding, the marker had to be inside a comment. Placed on the finding's own line, the characters applied wherever they appeared, including inside a string literal.
A minified bundle is one physical line. So is a lot of generated output.
Suppressed findings are dropped before the verdict gate that looks for HIGH and CRITICAL. So a PASS verdict was reachable by editing text, which is precisely the property the source file's own comments say must never hold.
We reproduced it against the released bundles. With the marker planted in a string literal, 0.2.44 returns PASS and 0.2.45 returns FAILED. The control with no marker returns FAILED on both, which is the row that makes the first row mean anything.
0.2.44 also wrote the planted credential verbatim onto the signed receipt. 0.2.45 records [redacted AWS access key].
What shipped
0.2.45 closes the suppression bypass, counts code as code, and adds JSX href and action, plus router.push and location.assign, as open-redirect sinks.
That new rule fires on the exact bug we had already fixed by hand. It is the only part of this day I would call a result. We found the open redirect by reading, and reading does not scale. The class is caught by the tool now.
The site fix landed separately and is verified in production.
The report says 50, and 50 is what it says
After the three false-positive classes are fixed, security on this repository lands at 85. That is the conservative estimate, and it is here as a commitment to ship the fixes, not as a claim about today.
Today the report says 50. That number came off our own pipeline, on our own code, and it is not getting relabeled because we happen to know why it is wrong. A score you edit after you disagree with it is not a score. It gets to move when the analyzer moves.
What to take from this
The bug shape is the useful part, and it has nothing to do with us. validate(input); return normalize(input) looks correct in review, reads correctly out loud, and is wrong the moment normalization can produce a string the validator would have rejected. Dot-segment collapsing is the common case. Percent-decoding, Unicode case folding, and path joining are the others. If a function checks one string and returns a different one, the check is on the wrong string.
Then there is the tooling lesson, which is the one we paid for. A static analyzer that recognizes sanitizers by name will never catch a broken sanitizer. It does the opposite. It looks at every call site feeding a broken function and reports them clean, because the name was on a list.
Ours did exactly that today, on our own code, and then scored us 50 for fourteen unrelated reasons that were all wrong.
Related CodeTruss guides
We Merged 18 Pull Requests in One Night. Our Own Gate Blocked Us First.
Eighteen merges to main in five hours, every commit pushed through the CodeTruss pre-commit gate. It blocked a credential-shaped test fixture, refused untrusted commands in every fresh worktree, surfaced two of its own bugs, and failed three production deploys out loud.
We Caught 6 of 9 Bugs. We Are Publishing the 3 We Missed.
CodeTruss built a corpus of nine bugs AI agents actually write, detected six at the exact line, and swept the new rules across eight repositories for zero false positives. Here is the whole result, including the misses and the rule we had to split before shipping.
Auditing an AI-Built SaaS: The LastSaaS Release Checklist Field Note
An independent public-source scan of jonradoff/lastsaas, a Go SaaS foundation built with Claude Code, shows how repository evidence becomes a fork release checklist — and why every automated security finding was rejected on manual review.
What the Official Next.js SaaS Starter Leaves for Your Release
An independent public-source scan of nextjs/saas-starter shows the release work a deliberately minimal template assigns to every fork: tests, CI, webhook configuration, seed hygiene, and tracking upstream security fixes.
Release Checklist for an AI-Ready SaaS Template: Open SaaS Field Note
A redacted public-source field note on wasp-lang/open-saas shows how CodeTruss turns auth, billing, jobs, email, file upload, CI, and Playwright evidence into a release checklist.
What a Release-Handoff Scan Found in an Open-Source AI Chatbot Template
An independent review of a pinned public repository shows how CodeTruss turns architecture and test signals into a release checklist—and why analyzer output still needs human judgment.
Test your own redirect helper
The CLI is free and local-first. It runs the new open-redirect sinks on your machine, and no source, diff, or receipt leaves it.