A Finding Your Agent Can Close: Stable Identity Across Runs, SARIF, GitLab and Jira

A stable finding fingerprint is a deterministic identifier: the SHA-256 of six identity fields. Those are the target, the finding kind, the scan template when there is one, the URL path, the HTTP method, and the parameter name. Hashing them, and leaving out what varies run to run, lets two scans point at one finding.

Key takeaways

  • A finding is only actionable if it keeps the same identity between runs. Without one, the second pipeline run files the tickets again and the prior triage does not survive.
  • NightVision computes it as the SHA-256 of six identity fields joined by a NUL byte, excluding the payload, the evidence, the scan id and the issue id.
  • NUL is the separator because a printable one could appear inside a URL path or parameter name and forge a collision.
  • Two independent producers, the command line tool and the platform backend, stamp the identical value, pinned by golden vectors.
  • The same six fields carry triage forward across scans, and the recipe is versioned, because changing it re-bases every consumer's history.

The second pipeline run files them all again

A platform engineer wires a dynamic scan into CI and exports the results to the tracker. The first run lands: a few dozen findings, a batch of tickets, a morning of triage. Two close as false positives, one is accepted as a risk the team knowingly carries.

The next run goes out against the same application, unchanged. The same batch of tickets appears again. The false positives are back, and the accepted risk sits at the top of the queue with a fresh id and no memory of the conversation that accepted it. Nothing in the second report pointed at the same rows as the first, so somebody turns the export off.

That looks like a tracker problem. It is an identity problem. The tracker, the merge request widget, the code-scanning view and the suppression list all ask one question: is this the same finding I saw last time? None can answer unless the scanner computes an identifier that means the same thing on every run.

The agent case is sharper. A coding agent handed a finding can draft a fix; asked whether it worked, it has to re-test and read the result. Evidence an Agent Can Act On: What a DAST Finding Has to Carry covers what a finding must carry to be acted on once. Identity is what it takes to track one twice.

Four ways to give a finding an identity

The columns are approaches, not products. Every scanner picks one, explicitly or by accident.

Issue id per scanURL plus rule nameContent hash of the evidenceFixed identity fields
Same value on a clean rescanNo, a new row means a new idUsuallyNo, evidence carries per-run idsYes
Survives a changed payload or responseAlready unstableYesNo, the key re-basesYes
Tells two parameters on one endpoint apartYesOnly if the rule name encodes itYesYes
Typical failureEverything refiles every runTwo bugs on one route collideA standing bug looks new each scanCollides when a field is absent

The first is where a pipeline lands without choosing: a database id is right there, unique inside one scan, which is the wrong scope. The third is tempting and precisely wrong: evidence carries per-run timestamps and ids, so hashing it moves the key when nothing about the bug moved. Only the fourth fails in a bounded way, and its bounds are the honest cost of owning a definition of "same finding".

How the fingerprint is computed, and what it leaves out

NightVision computes a per-finding fingerprint as the SHA-256 of six identity fields joined by a NUL byte: the target, the finding kind, the Nuclei template when there is one, the URL path, the HTTP method, and the parameter name. The finding kind is always present, and a missing value in the optional fields normalizes to the empty string the same way in both producers.

The exclusions matter more than the inclusions. The payload and the evidence are left out because they carry per-run timestamps and ids, which makes a key built from them unique inside one scan and unstable between two. The scan id goes for the obvious reason, and so does the issue id, so randomized issue ids do not move the value.

The separator is a design decision, not a detail. NUL is not expected to appear in any of the joined values, so the concatenation is unambiguous, unlike a printable separator a URL path or parameter name could contain and use to forge a collision across a field boundary.

It is a versioned recipe, and it is treated as one. Changing the field set, order, separator or hash is a breaking change to the dedup contract that re-bases every consumer's history, which is why it has to be a coordinated, versioned bump across both producers, guarded by frozen golden test vectors on each side.

Why two producers matter

NightVision emits SARIF from two independent producers, the command line tool in your pipeline and the platform backend behind the report you download. Both stamp the identical fingerprint, pinned byte for byte by cross-repository golden vectors, so a report correlates regardless of which one wrote it.

The six fields are not an invention for the export. They are built on the same six fields the platform already used to carry triage across scans, so dedup lines up with the product's own suppression logic rather than offering a second, disagreeing opinion.

What the identity buys

GitLab. The command line tool's GitLab export writes a GitLab DAST report on schema 15.2.4 keyed on that fingerprint, so a standing finding is deduplicated across pipeline runs rather than re-reported as new. GitLab ingestion and cross-pipeline dedup were exercised live.

SARIF. The value is carried twice: in a NightVision property on the result, and in the SARIF-native partialFingerprints slot consumers already read.

Jira. Filing dedups on the fingerprint. A finding already linked is skipped, and a ticket filed on a prior run whose link never registered is adopted by its fingerprint label rather than filed twice, so a half-failed re-run cannot double the queue.

Triage. A disposition, false positive or won't fix as accepted risk, is keyed on the same six fields, so an accepted risk stays accepted on the next scan and exports as a SARIF suppression with status accepted.

The return path. Once the inbound webhook is wired up in Jira against the generated secret and URL, moving a linked issue to done or won't-do updates the finding's resolution, attributed to a dedicated sync actor and audited. The status-to-resolution mapping is configurable per organization; done and won't-do are the defaults. Done also clears the suppression, so the finding resurfaces on rescan instead of hiding behind a stale decision. There is no outbound push back to Jira, so a Jira-sourced change structurally cannot echo back.

What an agent does with it

An agent that fixes a finding has to re-test and read the result, and the reading is where the loop breaks. Without a stable identifier, the claim after a rescan is arithmetic: there are fewer findings than before. With one, it is specific: the finding it set out to fix is gone from this run.

Did Your DAST Scan Actually Test the App? A Passing Check Should Say How Many Requests It Sent argues that a passing check should say how many requests it sent, because a rule that tested nothing passes like one that found nothing. Your Scan Passed Because It Never Logged In argues that a fully authenticated session has to prove itself, because a broken login still returns valid pages. Coverage says the test ran, authentication says it ran in the right place, the fingerprint says this run compares to the last.

That third leg finishes the completion contract in The App Your Agent Built This Morning Has Never Been Scanned: a definition of done ending at "a scan ran" is weaker than one ending at "what this change was meant to close is closed."

None of this shrinks the AppSec team. It removes the part of triage that was never judgment: working out by hand which rows in two reports match. Determinism on the finding, agents on the fix, humans on the merge.

The honest limits

The producers are not yet at parity on rule identity. Rule-per-kind identity with stable rule ids comes from the command line producer. The platform's SARIF still emits one rule per result, with a rule id that is not stable between scans, so a consumer grouping by rule id on platform-produced output regroups every scan. The fingerprint is identical across both, so dedup itself still holds.

Distinct Nuclei findings collapse when the template id is absent. That field normalizes to the empty string, and two different categorized Nuclei findings then compute one fingerprint.

Response-header findings are the inverse problem. They are properly a property of the site, but they are reported per path, so one missing header across a hundred routes reads as a hundred findings that churn between scans.

GitHub code scanning was validated, not exercised. GitHub's code-scanning rendering of the reshaped SARIF has not been exercised against a live repository. The output validates against the SARIF 2.1.0 schema and passes structural tests, so the honest claim is schema conformance, not a rendering result.

Only tickets the export filed sync inbound. Tickets not filed by the Jira export register no link and do not sync inbound, whether they were raised by hand or by another tool, so a transition on one has nothing to resolve back to. Filing is a command, not a button: tickets go out from the NightVision command line tool in CI or a shell, and the export requires the organization to have enabled the Jira integration.

Frequently asked questions

What is a finding fingerprint in a DAST report?

A deterministic identifier for one vulnerability instance that stays the same across scans. It is computed from the six fields that define which instance it is, not from what the scan observed that day.

Why does my DAST scan file duplicate tickets on every pipeline run?

Because the exporter cannot tell the tracker that this finding is the one it filed last time. If the scanner mints a finding id per scan, every run looks like a first run, so the tracker files another issue and the old triage is stranded on the old one.

Why does the separator between the fields matter?

A joined string is unambiguous only if the separator cannot appear inside the values. NUL is not expected to appear in any of the six values, so the joined string stays unambiguous in a way a printable separator would not, since a URL path or parameter name can contain a printable character and forge a collision across a field boundary.

Does a dismissal or a triage decision survive the next scan?

The GitLab DAST report the command line export writes is keyed on that fingerprint, so a standing finding is deduplicated across pipelines and stays one row in GitLab's vulnerability list instead of arriving as a fresh one each run. A disposition of false positive or won't fix as accepted risk is keyed on the same six identity fields, so it applies again on the next scan and is exported as a SARIF suppression with status accepted. Moving a linked Jira issue to done clears that suppression, so the finding resurfaces.

Can a coding agent verify its own fix with this?

It verifies against a named finding rather than a count: the agent records the fingerprint of the finding it set out to fix, re-tests, and checks whether that fingerprint is in the results. The fingerprint does not close that loop by itself. It only lets whoever re-runs the scan, person or agent, see whether that finding is present or gone.

Duplicate tickets are not a tracker failure. They are what happens when a finding has no identity of its own. Pick the fields that define sameness, exclude what changes run to run, hash them identically in every producer, and freeze the recipe. Then a standing finding stays one row, an accepted risk stays accepted, and a fix is confirmed rather than assumed. For where dynamic testing sits in a program, see What You Should Know About Application Security Testing. Watch the second run on your own pipeline: Start Free, or Request a Demo.

Give the finding a name it keeps

Duplicate tickets are not a tracker failure. They are what happens when a finding has no identity of its own. Pick the fields that define sameness, exclude what changes run to run, hash them identically in every producer, and freeze the recipe. Then a standing finding stays one row, an accepted risk stays accepted, and a fix is confirmed rather than assumed. For where dynamic testing sits in a program, see What You Should Know About Application Security Testing. Watch the second run on your own pipeline: Start Free, or Request a Demo.

Related readingFor the broader map of application security testing methods and where runtime testing sits among them, read What You Should Know About Application Security Testing.

Watch the second run, not the first.

Start free and export a scan twice to see whether your findings keep their identity, or book a demo to walk through the SARIF, GitLab and Jira paths.