The IDE security scan was clean, so why did the CI gate fail? What the editor, the pre-commit hook and the pull-request gate can each see

DedSec Application Security ·

Picture a developer on a Thursday afternoon. The security extension in their editor shows nothing on the files they changed. The pre-commit hook ran and printed a column of green "Passed" lines. They push, open a pull request, and the gate blocks it: a high-severity SQL injection, reported in a repository helper they did not open, let alone edit. Their first conclusion is that the gate is broken. Their second, if it happens twice, is that the local tools are pointless. Neither is usually true.

In this hypothetical, all three checks worked as designed, and no two were asked the same question. The editor analysed the file in front of it. The hook analysed what was staged. The gate analysed the whole repository at a commit against a baseline, under a policy the developer had never read, with a newer rule pack than the extension had cached.

This post is for whoever owns the security tooling across those three places and keeps getting asked why they disagree. It ends with a checklist for making a local result and a CI result reconcilable, which is a weaker and more achievable goal than making them identical.

It builds on earlier posts rather than repeating them: scan passed is not scan ran for the ways a green check can mean less than it says, the stale-baseline gate post for baseline selection in depth, suppression at scale and finding identity for what a suppression is keyed to, secrets in git history for why a deleted credential is not gone, and reachability for how taint analysis follows data across functions.

Sources are the Git reference documentation for githooks, git-commit, git-push and git-config, and the "Git Hooks" chapter of Pro Git, all on git-scm.com; the pre-commit framework documentation at pre-commit.com; the Visual Studio Code documentation page "Workspace Trust" at code.visualstudio.com; and the GitHub Docs page "Branches", section "Comparing branches in pull requests", at docs.github.com, all retrieved on 27 October 2026. Everything else is general practice or a hypothetical, and no number in this post is a measurement of any team or tool, ours included.

Four places a check can run, and what each one is shown

The disagreement starts before any rule runs. Each place is handed a different set of bytes.

The editor sees whatever the extension chooses to analyse: the active file, the open files, or the workspace, depending on tool and settings. It may analyse the unsaved buffer, which is neither what is on disk nor what is staged. It also sees files that will never be committed, such as a .env file listed in .gitignore. A secret rule that fires there is right about the machine and irrelevant to the repository.

A pre-commit hook sees the commit being built. The pre-commit framework's documentation says it "only runs on the staged contents of files by temporarily stashing the unstaged changes while running hooks", because running on unstaged changes "can lead to both false-positives and false-negatives". Its quick-start also notes that it will "usually" only run on the changed files during git hooks, which is why it suggests pre-commit run --all-files when adding a new hook. So by default the hook judges the staged version of the changed files.

Partial staging opens a gap between the two. A developer stages a hunk with git add -p, then fixes a vulnerable query in it but never stages the fix. The editor, reading the working file, is clean. The hook, reading staged content, should flag it if the rule is enabled locally. If the hook is not installed, nothing does until CI.

The CI job on a pull request sees the repository at a commit, plus generated files and dependencies if the job builds and installs them. Which commit is a choice. The stale-baseline post notes that on the pull_request event, GitHub Actions checks out a synthetic merge commit by default, so CI can analyse code that exists neither on the laptop nor on the branch: the change combined with whatever landed on the target since. A finding that appears only in that combination is real, and invisible locally by construction.

A full-history scan sees every commit reachable in the clone, which matters most for secrets. A key deleted before it was ever committed never entered the repository, and no scan will find it. A key committed on Monday and deleted on Tuesday is in Monday's commit, in every full clone. The editor and the hook judge Tuesday's state and are clean; a history scan in CI finds Monday's. The secrets post covers why that is still an exposure. Here the point is narrower: "not in the file" and "not in the repository" are different claims.

What each place can compute

Being shown the same files is still not enough. Some analysis needs more context than a local check has, and some needs an environment that does not exist on a laptop.

Taint across files

Interprocedural taint analysis follows untrusted data from a source, such as a request parameter, through function calls to a sink, such as a query. When the source is in a controller and the sink in a repository helper, the finding belongs to the pair. An analyser that sees only the helper sees a parameter flowing into a query and has to guess whether that parameter is attacker-controlled.

Sanitisers give the mirror image. If the escaping function lives in a module the editor has not analysed, a single-file check cannot credit it and flags a flow the whole-repository scan correctly clears. The false-positives post covers sanitisers credited to the wrong sink class; here the sanitiser is simply not visible.

A gate that reports only findings on changed lines adds a third answer: it runs the cross-file analysis, finds the injection in the unchanged helper, and filters it out. The stale-baseline post works through that case.

Composition analysis needs the resolved tree

Matching dependencies against advisories needs the versions actually installed, including transitive ones. A manifest that says ^4.17.0 is a range, not a version; the answer lives in the lockfile or a resolved tree. An extension reading the manifest alone can only guess, and without a committed lockfile the laptop and CI can resolve the same range to different versions. Malicious-package checks also need current package data that a cached local copy may lack; the malicious packages post explains why that is a different problem from CVE matching.

Analysis that needs a build or a deployment

Some analysers work on compiled output, so they need a build that an on-save check does not perform, and generated code may not exist until that build runs. Infrastructure-as-code with remote modules or variables is only fully evaluated once those are resolved; the Terraform post covers scanning files versus scanning a plan.

Dynamic testing cannot happen in the editor at all. It needs the application running, and it finds problems no single file contains: a header a proxy fails to set, a route a framework exposes by default. If a pipeline runs DAST against a preview deployment, a pull request can fail on something no local tool could have reported. The DAST coverage post covers what that misses in turn.

Same tool, different answer: parity drift

Suppose every place is shown the same files and can compute the same analysis. They can still disagree, because "the same scanner" is often not the same configuration.

Things that drift between a laptop and CI:

One drift is correct by design. As the stale-baseline post argues, a gate should read its configuration from the target branch, not the pull request head, or a change that widens an exclude hides its own findings. So a developer who edits the scanner config sees local results under the new config while the gate uses the old one until merge. Say so in the gate's output.

The fix for the rest is dull and effective.

Findings in the file versus findings introduced

Even with identical scope, depth and configuration, the editor and the gate answer different questions. The editor shows findings that exist in the code in front of you; a file with five old findings shows five. A gate that blocked on existing findings would block every pull request in a repository with a backlog, so gates usually block on findings introduced by the change: present in the scan of the head, and not matched to a finding in the scan of a baseline. Three consequences follow.

  1. The editor shows findings the gate ignores. The five old findings are in the baseline. A developer who sees them and then a passing gate concludes the gate is lax; it is answering a different question.
  2. The gate blocks on findings the editor never showed. A finding introduced by your change can be located in a file you did not touch, as in the opening scenario.
  3. The answer depends on the baseline. GitHub's documentation says pull requests show a three-dot diff, which compares the "most recent common commit of both branches (merge base)" with the topic branch, while a two-dot diff uses the "most recent state of the base branch", and notes that with a two-dot comparison "the diff changes when the base branch is updated, even if you haven't made any changes to the topic branch". A gate that baselines against the tip of the target branch can blame your pull request for a finding someone else introduced and someone else fixed. The stale-baseline post works through every combination with a worked history.

Local checks rarely have a baseline at all. To be comparable with the gate, a local tool needs the same logic: the merge-base with the target, and the same identity rules for matching, since an identity keyed to a line number turns every edit above a finding into a fix plus a new finding (the finding identity post covers this). If that is impractical, label the results honestly: "findings in this file" and "findings this change introduces" should not share a colour.

Suppressions that do not travel

Triage decisions are recorded in two kinds of place, and only one of them goes where the code goes.

Inline suppressions are comments in the source, committed with the code, so every place sees them, provided all honour the same syntax and rule identifiers. If a newer rule pack in CI renamed a rule, a suppression of the old name stops matching in CI while still working in the outdated editor.

Server-side decisions are records in a findings platform: false positive, accepted risk until a date, mitigated by a control. They are keyed to a finding identity and live in a database, not the repository. The gate consults them; a local check does not unless its integration fetches them. So the editor shows findings triage already closed, and developers stop reading the editor.

It also runs the other way. A developer adds an inline ignore to silence the editor, and the gate still blocks because its policy does not honour inline suppressions for that rule class, or requires a second approver above a severity. The suppression post describes five kinds of suppression and what each is keyed to. For parity, the question is whether the local tool knows about each kind. Decide which kinds the gate treats as authoritative, make the local integration honour the same set or mark what it cannot check as "may already be triaged", and treat an inline suppression as reviewed code with a reason in the comment.

A git hook is not a control

A git hook is a program in the hooks directory of the local repository. The githooks documentation says that by default this is $GIT_DIR/hooks, "but that can be changed via the core.hooksPath configuration variable", and that "Hooks that don't have the executable bit set are ignored."

Hooks are not part of what you clone. Pro Git says so directly: "client-side hooks are not copied when you clone a repository", and continues, "If your intent with these scripts is to enforce a policy, you'll probably want to do that on the server side". Hook frameworks inherit the limit: the pre-commit documentation says that after cloning, "running pre-commit install should always be the first thing you do". A step a developer must remember is a step some will not take.

Hooks can also be bypassed by documented options:

None of that is a flaw. Hooks serve the person at the keyboard, who sometimes needs to commit work in progress or get past a broken hook. Commits also arrive with no local hook involved: from a web editor, a bot, or a machine where hooks were never installed.

The conclusion is a rule worth writing into the programme's documentation: local checks are feedback; the server-side gate is enforcement. The gate runs where the developer cannot skip it, such as a required status check on a protected branch (the same GitHub page lists that a protected branch can "require status checks" before changes can merge), or a server-side pre-receive hook, which the githooks documentation describes as invoked "when it reacts to git push", with an exit status that "determines the success or failure of the update". Never list a pre-commit hook as a security control in an audit, a policy or a risk register. It is closer to a spell checker: useful, often right, and optional for the person typing.

Two corollaries. The gate must not assume the hook ran, so every enforced check runs in CI even if it also runs locally. And the hook must not be stricter than the gate, because a hook that blocks what the gate allows teaches --no-verify, which then skips the useful local checks too.

The developer's machine is not a neutral place

Local analysis is code running with the developer's privileges, often triggered by the contents of a repository. The Visual Studio Code documentation on Workspace Trust describes Restricted Mode as preventing "automatic code execution" by "disabling or limiting the operation of several VS Code features: AI agents, terminal, tasks, debugging, workspace settings, and extensions". On tasks, it notes that because task definitions live in the workspace's .vscode folder, "they are part of the committed source code for a repo, and shared to every user of that repo. If someone would create a malicious task, it could be unknowingly run by anyone who cloned that repository." And it is clear about the limit: "Workspace Trust can't prevent a malicious extension from executing code and ignoring Restricted Mode."

Three consequences for whoever designs the local layer:

Latency and noise decide what survives locally

A local check competes with the developer's attention, and it loses in two predictable ways.

It is slow. A check that makes the developer wait on save or commit tends to get disabled, or bypassed with --no-verify. The cost is not the seconds; it is that the check stops running.

It is noisy. Every editor finding that CI would have filtered as unreachable, baselined or triaged teaches the developer to dismiss the next one, including the real one. It is suppression at scale, only faster, with the developer as triager and no record of the decision.

That gives a placement rule, not a number:

Then tell developers which is which. A short note in the repository, "the editor checks these rule categories; the gate additionally runs these analyses", turns the question in this post's title into a sentence they have already read.

Reconciling one disagreement

When it happens, the order of questions matters, because the cheap ones are quickest to rule out.

  1. Same code? Branch head or merge commit? Was everything seen locally committed and pushed, including partially staged hunks?
  2. Same scope? Is the finding in a file the local tool analysed, or in another file, a dependency, the history or the running application?
  3. Same analysis? Does it need cross-file taint, a resolved dependency tree, a build or a deployment?
  4. Same versions and configuration? Compare the CI log's versions, config hash and excludes with the local tool's.
  5. Introduced or inherited? Against which baseline commit, matched by which identity?
  6. Suppressed on one side? Is there a decision one side honours and the other does not?
  7. Same policy? Does the gate block on this severity and category?

Write down which question resolved it. Over time, the pattern says which gap to close first.

Where AppSecD fits

AppSecD integrates with GitHub and GitLab and gates pull requests and commits by policy, and developers get results inside VS Code, Cursor, Windsurf and JetBrains before they push. Across SAST, DAST, SCA, secrets, IaC, container, Kubernetes and API security, findings are deduplicated into a single finding model, so a vulnerability is tracked once no matter which engine found it.

That is the whole claim. The scopes, placement rules and reconciliation steps in this post are general practice, not a description of how AppSecD's IDE integration or gate works. Nothing here claims how AppSecD keeps IDE and CI rule versions, configuration or suppressions in sync, or that it does; which analysers run in the editor versus in CI; how its gate selects a baseline; or which triage decisions reach the editor. Put the checklist below to us as you would to anyone else.

A checklist for reconcilable local and CI results

  1. Keep scanner rules, excludes, severity thresholds and gate policy in a versioned file in the repository, and have both the local integration and CI read it.
  2. Pin the analyser version and the rule pack version in the repository, and update them in reviewed changes. Pin hook revisions to commit hashes rather than tags.
  3. Make every CI gate run log the analyser version, rule pack version or hash, config hash and its source branch, head and base commits, and files analysed versus skipped.
  4. Make the local integration report the same versions, and treat a mismatch as a bug.
  5. Read the gate's configuration from the target branch, and say so in the gate output when the head branch changes it.
  6. Document which analyses run only in CI: cross-file taint, reachability, full-history secrets, current advisory data, resolved IaC plans, DAST.
  7. Commit lockfiles, so local and CI composition analysis resolve the same dependency versions.
  8. Label local results by question: "findings in this file" versus "findings this change introduces". If the local tool can compute introduced findings, use the same merge-base baseline and identity rules as the gate.
  9. Decide which suppression kinds the gate honours, and make the local tool honour the same set or mark findings it cannot check as possibly triaged.
  10. Treat inline suppressions as reviewed code, with a reason in the comment.
  11. Run every enforced check in CI or on the server, whether or not it also runs locally. Never list a client-side hook as a control.
  12. Keep local hooks no stricter than the gate, so that --no-verify never becomes a habit.
  13. Put only fast, high-precision rules in the local layer, and make the local rule set a subset of the gate's.
  14. Open unfamiliar repositories in restricted mode, review repository-defined tasks and hook configs before trusting them, and inventory security extensions and where they send data.

Back to Thursday afternoon. The gate was right: the change added a call that carried a query-string value into a helper that builds SQL. The editor was also right about the file it was shown. The fix is not a better editor or a gentler gate. It is making each one say which question it answered, at which version, against which baseline, so that the next disagreement takes a minute to explain instead of an afternoon.

More about AppSecD · All posts