DedSec Application Security ·
Picture a SQL injection finding that was fixed in March, verified, and closed. In October a developer restores an old version of the function while resolving a merge conflict, and the injection is back. The dashboard shows it as new: no history, no link to the March fix, a fresh SLA clock, and a triager who reads the trace from scratch. On the same day, a dozen findings in a different repository flip to fixed, although nobody has touched that code since spring.
Both outcomes come from two decisions every finding store makes on every scan, usually without saying so:
Get identity wrong by splitting, and one problem becomes several, history is lost and triage is repeated. Get it wrong by merging, and a genuinely new instance is absorbed into an old one, inheriting whatever decision was made about it. Get inference wrong, and a finding the scanner simply did not look at is recorded as fixed.
Other posts here cover parts of this. The post on stale pull-request baselines catalogues SAST fingerprint schemes (file and line, snippet hash, enclosing function, syntax-tree context) and how each breaks. The suppression post covers what happens to a suppression when its key stops matching. The secrets post argues for keying a secret finding to the credential rather than its location. This post is about the rest: what tools actually hand you, what the standards say, identity outside source code, and the state machine that sits on top.
Before designing identity, look at the raw material. For this post we ran four open-source scanners locally against a two-file Python fixture in a scratch git repository: a search handler formatting request input into a SQL string, a handler passing request input to subprocess with shell=True, and a settings file containing a GitHub-token-shaped string. Versions were Semgrep 1.172.0, Bandit 1.9.4, Gitleaks 8.30.0 and TruffleHog 3.93.8. These observations are about those command-line tools as installed, not about any product's pipeline, including ours.
Semgrep's rule ID depended on where the rule file lived. With a local rule called subprocess-shell-true, loaded from rules/r.yaml, results carried the ID rules.subprocess-shell-true. Loaded from other/policy/r.yaml, the same rule produced other.policy.subprocess-shell-true. Loaded from the working directory, it was plain subprocess-shell-true. Moving a rules directory renames every rule in it, and any identity that includes the rule ID treats every finding as new and every old one as gone.
Semgrep's fingerprint field was a placeholder. Run without logging in, every SARIF result carried a fingerprints entry named matchBasedId/v1 whose value was the literal string "requires login", and the JSON output's fingerprint field held the same string. The output is valid SARIF; we checked it against the OASIS 2.1.0 schema. But a consumer that takes the fingerprints object at face value gives every result in the run the same identity, and collapses them into one finding.
Bandit and Semgrep described the same call differently. For the subprocess call on line 13, Bandit reported B602 with a col_offset of 4, which is zero-based. Semgrep's SARIF reported a startColumn of 5, which is one-based. Same line, same character, different rule IDs and different column conventions. Bandit also reported B404 on the import of subprocess on line 2, and tagged it with the same CWE, CWE-78, as the shell=True call.
Gitleaks built a location-keyed fingerprint. Its JSON fingerprint for the token was the full commit hash, the path, the rule ID github-pat and the line number, joined by colons. In SARIF it emitted no fingerprints object, and filled partialFingerprints with commit metadata: commitSha, author, email, date and commitMessage.
TruffleHog emitted no fingerprint at all, and named the same token's detector Github rather than github-pat. Its filesystem mode reported the path relative to whatever it was given: src/settings.py when pointed at src from the repository root, settings.py when run from inside src, and the absolute path when given one.
The lesson is not that any of these tools is wrong. It is that identity is not something a scanner delivers. It is something the system storing findings has to build, from parts of varying stability, and the normalisation has to happen first: paths made relative to the repository root, rule IDs mapped through a catalogue that survives renames, column conventions converted, and placeholder values recognised and discarded.
SARIF 2.1.0, the OASIS interchange format, has more machinery for this than most consumers use.
fingerprints. Each value "SHALL be a string that provides a stable identifier for the result", the same for results that are logically identical and different for results that are logically distinct. The specification says this is meant to make a fingerprint resistant to changes that do not affect logical identity, and gives two examples: the location of the root of the source tree, and the line number where the result appears. Property names are versioned strings such as stableResultHash/v2, and when two results carry several versions, a result management system should compare using the latest version available in both.
partialFingerprints. Each value "contributes to" identity, and the specification's Appendix B describes a result management system combining them into a fingerprint of its own. This is the field GitHub code scanning reads.
correlationGuid. An identifier for the equivalence class of logically identical results, which is the natural place for an identity assigned by a result management system rather than a tool.
baselineState. One of new, unchanged, updated or absent, relative to a baseline run. The definition of absent is worth reading closely: the result "was detected in the baseline run but was not detected in the current run". Absent is not fixed. The specification's vocabulary is honest about what a scan can observe; it is lifecycles that turn absent into fixed.
deprecatedIds. A rule descriptor can list the IDs by which it was known in previous versions of the tool. The specification's note describes exactly the upgrade problem: a rule that is split into several more specific rules, and a result management system that has to match results across the versions. It is worth asking whether the tools you run populate this field and whether your ingest reads it, because it is the standard mechanism for surviving a rule rename.
GitHub's documentation for SARIF uploads states the rules plainly. Code scanning uses partialFingerprints to decide when two results are logically identical. The ruleId has to be the same across analyses. And file paths have to be consistent: "If the filepaths differ for the same result, each time there is a new analysis a new alert will be created, and the old one will be closed."
If an uploaded file has no fingerprint data, the upload-sarif action tries to compute partialFingerprints from the source files. Upload through the REST API without them and, in the documentation's words, "users may see duplicate alerts".
The value the action computes is primaryLocationLineHash, and its source in the codeql-action repository (src/fingerprints.ts) shows how it is built. For each line, it takes a rolling hash over the next 100 characters starting at the beginning of that line, skipping spaces and tabs and normalising carriage returns to line feeds, so the window runs on into the following lines. The hash is suffixed with a colon and a counter that numbers identical hashes in order of appearance in the file. A result gets the hash of its primary location's start line.
The consequences follow directly from that construction:
GitHub also notes that one alert can show different statuses from different analysis configurations. Its documentation warns that unless every configuration runs regularly, an alert can be fixed in one configuration and not another, and that statuses can go out of date for infrequent or stale configurations. Its remedy is to re-run those configurations or delete them from the branch. Any system that accepts results from more than one pipeline has the same problem, whether or not it gives it a name.
Code findings at least have a file and a line. Other disciplines have to choose their anchor, and each natural choice fails somewhere.
Dependencies. The post on advisory matching argues for keying a dependency finding by the vulnerability after alias grouping, the package, and the location where it is declared. The common mistake is putting the installed version in the key. Upgrade from one vulnerable patch release to another still-vulnerable one, and the old finding closes as fixed while a new one opens with no history. The right outcome is one finding, still open, with the version recorded as a changed attribute. Moving a lockfile to a new directory is the dependency version of a file rename.
Web and API endpoints. A dynamic finding is naturally keyed by method, endpoint, parameter, where the parameter lives (query, body, header, cookie) and the check that fired. The endpoint is where it breaks. A path with an object ID in it, /orders/1841 on one scan and /orders/1907 on the next, has to be templated to a route before matching, or every run produces a new finding for the same handler. Template too aggressively, or collapse URLs that differ only in parameter values, and two handlers that run different code merge into one; the post on DAST coverage notes that a crawler setting can treat /report?type=summary and /report?type=raw as one URL. The payload and the response should be evidence, not identity: responses carry timestamps, nonces and CSRF tokens, and a key that hashes them is a different key every run.
Hosts and services. An external exposure is naturally keyed by asset, port and check, and the asset is the hard part. Keyed to an IP address, an exposure behind a cloud load balancer becomes a new finding every time the address rotates, and the previous one reads as fixed. Keyed to a hostname, two different machines answering for one name, in rotation or by region, merge into a single finding whose evidence contradicts itself. Certificates have the same shape: keyed to a serial number, every renewal is a new certificate and so a new finding, although the question was always about the hostname's posture. The attribution post covers why the asset itself is a judgement rather than a fact, and any identity built on it inherits that judgement.
The general principle is the same in every discipline: key on what a fix would change, and record everything else as evidence. A fix changes the code at a sink, the package that ships, the handler behind a route, the configuration of a service. It does not change an object ID, a load balancer's address or a response timestamp.
When a stored finding is not matched by the current scan, there are at least six explanations, and only one of them is a fix:
A lifecycle that maps "not found" straight to fixed treats all six as the first. The general practice that avoids this is to make closure conditional on coverage: a finding can be inferred fixed only if this scan's own record shows that the file, endpoint or asset was analysed, by the same rule, at a comparable version, and the result was not there. Otherwise its state should be something like "not observed", which neither closes it nor raises an alarm on its own. A partial scan, such as one limited to a pull request's diff, should never close anything outside its scope.
That condition needs data most pipelines throw away: per-analyser status, the list of files or URLs actually analysed, and rule and engine versions recorded with every result. Without it, "fixed" is a guess.
When a finding comes back, there are two ways to record it. As a new finding, it starts with a clean history. As the old finding reopened, it carries everything that was known: when it was first seen, who triaged it, what the fix was and when it was verified, and the fact that it regressed. The second is more useful in every respect, and is only possible if identity survived the gap. A key that broke when the file was edited cannot recognise a regression; it can only report a discovery.
Reopening raises a policy question that is worth deciding on purpose: does the SLA clock resume from the original first-seen date, or restart? Either can be defended. Resuming says the exposure has existed, on and off, since March. Restarting says the team has a fresh obligation from today. What should not happen is that the choice is made implicitly by whichever identity scheme happened to match, so that one regression keeps its history and an identical one in a renamed file starts clean.
A finding that goes found, not found, found across consecutive scans of unchanged code is flapping, and it is usually measuring the scanner rather than the code. Common causes:
Each flap generates a fixed transition and a reopen, and the reopen arrives as noise. Two practices help. Count state transitions per finding, and treat a finding that has flipped several times with no change to the code or asset it sits on as a scanner problem to investigate. And consider requiring more than one covered, clean observation before inferring a fix, or using an intermediate state such as "fixed, pending confirmation". The cost is that a real fix takes an extra scan to be recognised, which is usually a fair price for not reopening it next week.
There are two kinds of fixed. Inferred fixed is the scanner's conclusion: analysed, and not found. With coverage recorded, it is cheap and fairly strong evidence for code findings. Asserted fixed is a person's claim: mitigated by a gateway rule, fixed in a shared library the scanner cannot see, or, for a secret, revoked. The secrets post makes the case that for a credential the asserted kind is the only kind that means anything, because removing the string from the code does not revoke it.
Asserted closures are the ones worth a second person. They are easy to make for the wrong reason, because the finding is in the way of a release, and they are hard to discover were wrong, because the finding disappears. A maker-checker step on assertions, while letting covered inferences close on their own, puts review effort where the evidence is weakest.
Running two engines that overlap is common and reasonable. It makes cross-tool identity a requirement, and the Semgrep and Bandit results above show what it has to handle.
Normalise location before comparing. The same call arrived as column 5 from one tool and offset 4 from another. Matching on file and line, with columns converted to one convention or ignored, and allowing ranges to overlap rather than requiring them to be equal, gets most of the way.
Match on the vulnerability class, not the CWE alone. Bandit's B404 and B602 shared CWE-78, yet one warns that subprocess was imported and the other that a shell was invoked with request input. Merging by CWE within a file would fold the injection into the import warning. A mapped vulnerability class, combined with an overlapping location, is narrower and safer.
Keep every detection, under one finding. The merged finding should hold each engine's result as evidence, with its own rule ID and output. Its identity should not be whichever engine's fingerprint arrived first. If it is, removing that engine from the pipeline marks the finding fixed and opens the other engine's detection as new, which loses the history for a change that had nothing to do with the code.
Do not count agreement as independent confirmation. Two engines that both match on the syntax of a shell=True call are not two independent witnesses. Agreement between a pattern match and a dataflow trace that reaches the same sink says more than agreement between two pattern matches.
For secrets, the rule names differ (github-pat and Github, in the run above) and so do the path conventions, which is why the secrets post recommends keying the credential by a keyed hash of its value, with each occurrence recorded beneath it.
Identity schemes are easier to test than to read about. Take a small repository with a few known findings, scan it, record the finding IDs, and then make one change at a time, rescanning after each. The expected outcomes are below; compare them with what the tool actually does.
Any tool will fail some of these, because every identity scheme trades splitting against merging somewhere. The point is to know which ones it fails, so that a finding marked new or fixed can be read correctly.
AppSecD runs SAST, DAST, SCA, secret detection, IaC, container, Kubernetes and API security against a single finding model, so a vulnerability is deduplicated once and tracked once, no matter which engine found it. Its static analysis uses Semgrep as the primary engine alongside analysers including Bandit, and its secret detection runs TruffleHog and Gitleaks, deduplicated, over the working tree and the full git history. Findings deduplicate across scans and move through new, reopened and fixed. They carry ageing and an SLA, are assigned to an owner, and closing one can require maker-checker approval, so a finding marked fixed or false positive can need a second person to confirm it.
Maphra's findings are likewise deduplicated across scans and tracked through new, reopened and fixed states, with ownership, ageing and SLA, and every finding carries the evidence that produced it.
That is the whole claim. This post does not describe how either product computes finding identity, what conditions close a finding, how a reopened finding's SLA is counted, or how either handles any of the ten tests above. The tool observations earlier were made with the open-source command-line tools, not through AppSecD. Everything else here is general practice, and the questions below are fair to put to any tool, ours included.
A finding store that can answer those is keeping a history. One that cannot is keeping a series of snapshots, and the difference shows up the first time something you fixed comes back as something you have never seen.