Most of our other write-ups scan ordinary code where nobody knows in advance what is wrong. This one is different on purpose. OWASP Juice Shop is the reference deliberately-insecure web application, and its vulnerabilities are documented: a published list of planted bugs across injection, broken access control, XSS, broken authentication and more. That makes it the one target where you can do something you can't do on a private repo — score the scanner against ground truth. If we claim a SQL injection in the login route, you can open the file and the official challenge list and check.
We pointed redmirror scan at the server-side API — the routes/ directory, 61 request handlers where most of the planted server-side bugs live. Twenty-two minutes and $3.12 later, here is what came back.
| Category | Count |
|---|---|
| Confirmed security defects | 8 |
| Needs further analysis (candidates) | 100 |
| Investigated (not a security issue) | 3 |
A candidate is called Confirmed only when the deciding code path was read and the defect follows from the code as written. Everything else is shown, not hidden: candidates that need context we couldn't see from the file stay in a “needs analysis” list, and candidates a careful reading proves safe are investigated and ruled out with the reason. Severity is never inflated. On a target this dense, the eight confirmed are the high-confidence core; the wider hundred candidates are where the recall lives, each labelled with what it would take to confirm it.
High routes/login.ts:34 · CWE-89
The signature bug of Juice Shop, and the scan lands on it. The email field is interpolated straight into a raw SQL string:
// routes/login.ts models.sequelize.query( `SELECT * FROM Users WHERE email = '${req.body.email || ''}' AND password = '${security.hash(req.body.password || '')}' AND deletedAt IS NULL`, { model: UserModel, plain: true })
Impact. The password half is hashed, but the email value is unprotected. An attacker submitting ' OR '1'='1'-- as the email breaks out of the string and authenticates as the first user in the table — the classic full authentication bypass.
High routes/fileUpload.ts:31 · CWE-22
The interesting confirmations aren't the obvious ones — they're the guards that look right and aren't. Uploaded-archive entries are extracted with a confinement check that uses the wrong string test:
const absolutePath = path.resolve('uploads/complaints/' + fileName) if (absolutePath.includes(path.resolve('.'))) { // includes(), not startsWith() await pipeline(entry.stream(), fs.createWriteStream('uploads/complaints/' + fileName)) }
Root cause. The check asks whether the application root appears anywhere in the resolved path, not whether the file stays under the upload directory. A zip entry named ../../ftp/legal.md resolves inside the app root, passes the check, and is written outside uploads/complaints/. Fix: startsWith(path.resolve('uploads/complaints') + path.sep).
High routes/imageCaptcha.ts:52 · CWE-840
A one-character logic bug — || where the author meant &&:
if (!captchas[0] || req.body.answer === captchas[0].answer) { next() // proceeds when NO captcha exists at all }
Impact. A request that never requested a CAPTCHA has no stored record, so !captchas[0] is true and the check is skipped entirely. The gate meant to stop automation waves it straight through.
../ filtering.This is the part you can only do on a benchmark. Of Juice Shop's ~11 vulnerability categories that a source review of the API can reach (the rest — leaked-file hunts, out-of-date dependencies, obscure UI tricks — are not code defects a route review would find), the scan surfaced findings in nine:
| Category | Reached by the scan? | Example |
|---|---|---|
| Injection | Confirmed | login SQL injection; NoSQL in reviews/orders |
| Broken Access Control | Confirmed | forged continue code; unauthenticated delivery change |
| Improper Input Validation | Confirmed | zip-slip file write; YAML path traversal |
| Insecure Deserialization | Confirmed | yaml.load on user input |
| Broken Anti-Automation | Confirmed | the CAPTCHA bypass |
| Unvalidated Redirects | Candidate | open redirect in the redirect route |
| Broken Authentication | Candidate | weak JWT verification |
| Cryptographic Issues | Candidate | the same JWT weakness |
| Cross-Site Scripting | Candidate | unencoded values reaching a response |
| XML External Entities | Missed | the XXE lives outside the scanned routes |
Nine of ten reached, five of them as confirmed defects. The one miss is honest: Juice Shop's XXE lives in an upload handler outside the routes/ directory we scoped, so it was never in scope — scan lib/ too and it comes into range. We'd rather tell you that than quietly drop the denominator.
Below the eight confirmations sits a longer list: an external value reaching a database query, a file path, a redirect or a response without a visible sanitizer on the way. Many are the other half of the injection and access-control challenges. They aren't promoted to “confirmed” because confirming each one needs a fact the single file doesn't show — whether that input is actually attacker-reachable, or neutralised in a helper defined elsewhere. So they're presented as leads with exactly that question attached, not as a wall of red. It's the difference between “here are eight things that are wrong” and “here are a hundred things to be scared of.”
Three dollars, one directory, and the scanner walked onto most of a benchmark that exists specifically to be hard to fully catch. A small package runs a few cents; you always see the estimate before you spend anything. See the measured cost table for more real runs, or the vulnerabilities we've reported in real OSS.
Point RedMirror at your code and get the same thing back: confirmed defects with a reproducible path, an honest “investigated and ruled out” list, and a bill metered to the token. Check the cost first with redmirror estimate: local, free, no tokens spent.