DedSec Application Security · 2026-10-14
Picture a pull request that rewords three error messages. The security gate blocks it with two new high-severity findings: a SQL injection in a reporting module the author has never opened, and a credential in a deployment script. The author checks the diff, confirms neither file is in it, and asks how to get past the gate. Somebody shows them the override. By the end of the quarter, the override is how half the team merges.
Both findings may be real. What the gate got wrong was attribution. The earlier post on catching the vulnerability in the pull request argued that a gate should block on what a change introduces, not on what the repository already contained. This post covers how "introduces" is actually computed, and every concrete way that computation goes wrong.
A static analyser looks at a snapshot of code and reports what is wrong with it. It has no idea which change put each problem there. So a gate that blocks on introductions has to work them out:
Each step can fail. The head scan can cover a different commit than you think. The baseline can come from the wrong commit, different rules, a different configuration, or a scan that never finished. And matching means deciding when two findings from separate scans are "the same finding".
The errors go in two directions. A spurious block pins someone else's finding on this change. It costs an afternoon, and often enough it costs you the gate, because engineers learn to route around it. A missed introduction subtracts away a finding the change really did add. Nothing visible goes wrong that day, which is why it is dangerous.
There are three common answers.
The tip of the target branch. Scan main as it stands when the pull request is checked. Simple, but main keeps moving after the branch is created, so the baseline includes work the branch does not have.
The merge-base. This is the best common ancestor of the branch and the target, which is what git merge-base reports. In a simple history it is the commit the branch was created from. It is also the reference the platforms use to show the change itself. The files-changed view of a GitHub pull request is a three-dot diff: the comparison from the merge-base to the head of the branch, the same one git diff main...feature produces. GitLab's merge request diff is computed against the merge-base by default too. A gate that measures introductions against anything else is judging a different change from the one under review.
A cached scan of main. The most recent stored result for the target branch, often from a nightly or post-merge run. It is cheap, but it can be any age, older or newer than the merge-base, and nothing in the result says which.
The merge-base also moves. If the author merges main into the branch to pick up recent work, the merge-base becomes the main commit that was merged in, and the same happens after a rebase. A baseline chosen once, when the pull request opened, goes stale at that moment.
The head side has a subtlety too. On the pull_request event, GitHub Actions checks out a synthetic merge commit by default: the branch merged into the base branch as of when GitHub last computed the test merge, not the branch head. GitLab's merged results pipelines work on a similar merged commit. Scanning the merge result is legitimate, but it changes which baseline is correct. A merge result contains everything on main up to the base commit it was built on, so it has to be compared with that commit, its first parent, which may already be behind the current tip. A head commit has to be compared with the merge-base. Pairing a merge-result scan with a merge-base baseline, or a head-commit scan with a tip baseline, is the easiest way to get the subtraction wrong.
Suppose this history:
And suppose these findings:
So B has P, Y and Q. C has P, Q and X. The branch head F has P, Y, Q and N. The merge result of F onto C has P, Q, X and N. Now subtract:
Missed introductions come from the same mismatches running the other way.
A regression hidden by an old baseline. Suppose the branch had been created from C, after the fix. While resolving a conflict, the author restores the old version of the function and brings Y back. A cached baseline from A or B still contains Y, so the reintroduced Y finds a match and is subtracted out: the regression passes as pre-existing. Against the correct baseline C, Y is new. In a lifecycle that has a reopened state, it is a fixed finding coming back, which is about the most useful thing a gate can report.
A new finding absorbed by a newer baseline. F, branched from B, adds a second call to the same unsafe query helper inside the function where C later introduced X, a function that already existed at B. If the gate uses tip C as the baseline and matches findings coarsely enough to treat the two as one, which is the subject of the next section, X absorbs N's twin and the gate says nothing.
The subtraction needs a matching rule, usually a fingerprint computed from each finding. Every scheme trades the same two errors against each other: split one finding into two and you get a spurious block; merge two findings into one and you get a missed introduction.
File and line. This breaks as soon as anything shifts. Add an import at the top of a file and every finding below it moves down a line, fails to match, and shows up as new. It is rarely used alone, but it often survives as the fallback when a richer fingerprint cannot be computed.
A hash of a normalised code snippet. Take the flagged lines, strip whitespace and comments, perhaps replace local identifiers with placeholders, and hash the result. That survives line shifts and most reformatting. It breaks when a formatter rewraps a statement and the snippet is a fixed number of lines. It breaks on a variable rename unless identifiers are normalised away, and normalising them raises the chance of collisions. And it collides on duplicated code: two identical unsafe lines in one file hash the same, so if matching is by presence rather than by count, adding a third copy matches an existing one and is never reported. Adding an occurrence index to tell copies apart brings back order sensitivity, because inserting a copy above the others renumbers all of them.
Rule ID, file and enclosing function. This survives edits anywhere else in the file and is cheap to compute. It breaks when the function is renamed or the file is moved, so both changes turn every finding involved into a new one. It is also coarse. Every instance of a rule in one function shares a fingerprint, so a second, genuinely new instance in a function that already has one is absorbed. That is exactly what hid N's twin above.
AST context. Fingerprint the path of syntax nodes around the finding: enclosing class, function, statement kind, call target. This is immune to formatting and tolerant of edits nearby. It still breaks on structural refactors.
Then there are refactors. Extract the vulnerable query into a new helper, and the finding now sits in a different function, perhaps a different file, with a different snippet context. Under every scheme above it looks new, and the author of a behaviour-preserving refactor gets blamed for a finding that is years old. For taint findings, a fingerprint built from the source and the sink, rather than from where the report is anchored, survives more of these moves. Running git's rename detection to map old paths to new before matching handles moved files. The honest outcome for code that only moved is "moved", not "new", and it is worth asking whether your gate has that state.
Suppose the baseline was scanned a week ago with rule pack version 1, and the pull request is scanned today with version 2. Unchanged code can now produce findings that are new to the gate:
The first pull request after an upgrade gets blocked for the repository's whole backlog under the new rules. In reverse, a rule removed or narrowed in v2 makes its findings vanish from the head scan, and they read as fixed. The fix is to scan both sides with the same engine and rule versions, or to rescan the baseline whenever either changes.
Configuration drifts the same way:
There is a sharper version: where does the gate read its configuration from? If it reads it from the head of the pull request, a change that widens an exclude glob or lowers a threshold hides its own findings too. As general practice, take the gate's configuration from the target branch, and review changes to that configuration as changes to a security control.
Suppressions are a second subtraction with the same identity problem.
Keyed to a location that moved. A suppression recorded against a file and line, or a file path, stops matching when the code moves, and the finding it covered reappears as new. Inline ignore comments travel with the code, but a formatter that moves the comment off the flagged line can quietly break the link.
Scoped broadly enough to swallow something new. A suppression for a whole rule, a directory glob, or a coarse fingerprint like rule plus file plus function absorbs any future finding that matches it. A new injection in a function that already carries an accepted-risk suppression for the same rule is invisible on arrival.
Added in the same change. An inline ignore that a pull request adds to its own new code is a decision the author made alone. A gate that honours it silently can be switched off one line at a time.
A suppression should carry a reason, an owner and an expiry, be scoped to one finding, and be visible as a change when a pull request adds one.
The post on silent scanner failure covered how a scan that crashed, timed out or produced unparseable output can be recorded as zero findings. When that scan is the baseline, the failure turns into a gate error with one of two symptoms.
Treated as an empty baseline. Nothing is subtracted, so every open pull request is blocked at once for the repository's entire backlog. Someone adds a bypass to unblock a release, and the bypass outlives the incident.
Treated as no gate. The gate sees no baseline, cannot compute introductions, and passes. Nothing blocks, nothing alerts, and the check stays green.
Partial failure is worse, because the result looks normal. If one analyzer failed on the baseline side, all of that analyzer's findings on the head look new. If one failed on the head side, all of its baseline findings look fixed, and a lifecycle that trusts the result will mark them so. A missing result is not evidence of a fix.
The right behaviour is a third state: the gate reports an error, names the analyzer and commit that failed, and asks for a rescan. It neither passes nor blocks on fabricated introductions.
Many gates also limit their work to the changed files, to save time or to keep the report focused. Either limit misses introductions that reach outside the diff.
Picture a helper in file X, the function sanitize_account_id, which used to convert its argument to an integer and now, after a pull request, only strips whitespace. In file Y, which the pull request does not touch, a reporting handler takes an account identifier from the request, passes it through sanitize_account_id, and puts the result into a query. Before the change that path was safe. After it, the query in Y is injectable.
A scan that analyses only X sees a function returning a string and no sink, so it finds nothing. A scan that analyses everything but reports only findings on changed lines finds the injection, notes that it is in Y, and filters it out. Measured against a correct merge-base baseline, the finding really is new, and its location is outside the diff. The same shape shows up when a change removes an authorisation middleware, adds a new caller that feeds request data into an existing function, or switches off template auto-escaping in a settings file.
The general practice is to limit the report, not the analysis. Analyse enough of the program for interprocedural paths to complete, attribute findings by fingerprint against the baseline rather than by line position, and when a new finding sits outside the diff, show the step in its trace that passes through the changed code. That step tells the author why their change is responsible.
As general practice, whatever tools you run:
AppSecD gates pull requests and commits by policy, integrates with GitHub and GitLab, and triggers scans on webhooks, scheduled runs, CI, ZIP upload or manually. Its findings land in one deduplicated finding model and are deduplicated across scans, moving through new, reopened and fixed states. Its interprocedural taint analysis traces source to sink across function boundaries. And its lifecycle supports maker-checker approval, so marking a finding fixed or false positive can require a second person to confirm it.
None of that answers the questions below on its own, and they are fair to put to any gate:
A gate that can answer those is measuring the change in front of it. One that cannot is measuring the distance between two snapshots, and the engineer it blocks will work that out before you do.