← Writing

Giving a finding a name both systems agree on

Sep 2, 2026 · 4 min read cloud-securityautomationengineering

Here is a problem that shows up every time you connect a security scanner to an issue tracker, and that nobody warns you about because it sounds trivial until you try it.

The scanner has findings. The tracker has tickets. You want a ticket per finding. Neither system knows the other exists, and neither has an identifier the other will accept. So the first real question is not “how do I create a ticket” — it is how do I know whether I already did.

Get that wrong in one direction and you file a duplicate every time the job runs. Get it wrong in the other and you silently swallow a finding that genuinely came back.

The obvious answer is a mapping table, and it rots

The instinct is to store the join: a table of scanner ID to ticket key, written when you create the ticket, read on every subsequent run.

It works, and then it decays. The table is now state you own — it has to be backed up, migrated, and kept consistent with two systems that can both change underneath it. Someone deletes a ticket by hand and the row is a lie. The job runs from a new environment that cannot reach the store, and it cheerfully creates a duplicate of everything. You have added a third system whose job is to remember what the other two already know.

Derive the identity instead

The alternative is to not store anything. Compute an identity from the finding itself, in a way that produces the same answer every run, and put that value on the ticket where the tracker will hold it for you.

I used a UUIDv5 — a hash-based UUID, deterministic by construction — over the scanner’s finding ID plus the timestamp of when it was first detected:

custom_uid = uuid.uuid5(uuid.NAMESPACE_DNS, scanner_id + str(first_seen_date))

That value goes into a custom field on the ticket at creation. On every later run, the job recomputes it from the scanner side and looks for a ticket carrying it. No mapping table, no external state, and nothing to fall out of sync — the identity is a pure function of the finding.

The interesting part is the second half of that key. Why include the first-detected timestamp at all, when the scanner ID is already unique?

Because findings recur, and “this is still broken” and “this broke again” are different events that deserve different handling. A vulnerability gets remediated, the ticket closes, and six weeks later the same CVE reappears on the same image because a base layer regressed. With the scanner ID alone, the new occurrence hashes to the identity of the old, closed ticket — and you either resurrect a closed ticket that has the wrong history on it, or you decide it is already handled and drop it. Folding in first-detected makes the reappearance a genuinely new identity, because to the scanner it is a new detection.

That is the whole trick, and it is worth saying plainly: the identity should encode what makes two things the same thing, not merely what makes them addressable.

Then make it fast enough to run daily

The first working version took about fifteen minutes. The rewrite took it to about thirty seconds.

Both numbers are less interesting than the reason the gap mattered. Fifteen minutes is a job you kick off deliberately and watch. Thirty seconds is a job that runs on a schedule and that nobody thinks about. Those are different categories of thing — one is a script, the other is a control. Nothing about the logic changed in between.

Two things got it there, and neither is clever.

Stop scanning a list to answer a yes-or-no question. The original shape walked the entire set of tickets for every finding, to ask whether a match existed. That is the classic accidentally quadratic pattern, and it is invisible at small volumes. Pulling every ticket’s identity into a set once, up front, turns the membership test into a hash lookup:

if str(custom_uid) in jira_custom_ids:
    ...

The linear scan still happens — but only to retrieve the matched ticket, on the far smaller number of runs where there actually is one. The expensive operation went from “always” to “only when it pays for itself.”

Overlap the waiting. Nearly all of the remaining time was network — paginating findings out of one API, reading and writing tickets in another. These calls are independent of each other, so running them sequentially means the job spends nearly all of its life blocked. A thread pool over the independent work, via concurrent.futures, collapses that. This is the case where threads in Python are unambiguously the right tool: it is I/O-bound, so the GIL is not the constraint.

Neither change required a redesign. The structure of the job is the same. It is just that the two most common operations stopped being the two most expensive ones.

What I would take from this

If you are joining two systems that do not share a key, spend your effort on the identity function before you write a line of the sync logic. It is the decision everything else inherits, it is painful to change once tickets exist carrying the old value, and it is the difference between an integration people trust and one that quietly files duplicates until somebody turns it off.

And when something is slow, check whether you are asking an expensive question repeatedly before you reach for anything more sophisticated. It usually is.