A finding is the current state of one issue on one asset. It is the object every decision in TRUSTIVAN attaches to, which makes its identity the load- bearing part.

The problem identity solves

Run any scanner twice and you get two reports. Nothing in either one connects a row in the second to a row in the first, so a decision recorded against last night’s report has nowhere to live tonight. A user who marks CVE-2024-1234 in openssl on service-a as “accepted risk, expires 2027-01-01” must still see that decision after tonight’s scan, next month’s scan, and after TRUSTIVAN changes its detection engine. The fingerprint is what supplies that link, and therefore what turns a scanner into a product.

Fingerprints are derived from content

A fingerprint is a versioned hash — tof1: followed by a SHA-256 digest — over the asset identity, the finding kind, and the fields that make the issue what it is. The ecosystem is part of a component’s identity for correctness, not tidiness: crypto exists in npm, RubyGems and PyPI as three unrelated packages, and without the namespace a vulnerability in one would share identity with the others. Fields are length-prefixed before hashing rather than joined with a separator. With a separator, ("ab", "c") and ("a", "bc") produce the same input, so a package named foo:bar could collide with a package foo and a rule bar. Length prefixing makes a collision require a SHA-256 collision.

What is deliberately excluded

Severity, CVSS score, scanner ID, scanner version, database version, scan timestamps, line and column numbers, the installed version and the fixed version are not part of identity. Every one of them changes while the underlying issue does not: That last one is the point of the whole design. Identity is built from facts any engine reports — a CVE ID, a package name, a file path — never from an engine’s internal identifiers. It is what makes the engine genuinely replaceable rather than nominally so. The version prefix exists because the algorithm will eventually need to change. A tof2 can coexist with tof1 in one table, and the migration can be written as “recompute and carry the decisions across” rather than “hope nothing breaks”. Every field that feeds a fingerprint is also stored as its own column, so that recomputation is an UPDATE rather than a re-scan of every asset in every tenant.

The tenant is excluded too

The same CVE, in the same package, on the same asset is the same security issue regardless of who owns the asset. Isolation does not depend on the fingerprint and must not — it is enforced by org_id and scoped queries, as described in Tenancy and isolation. A finding is keyed by (org_id, asset_id, fingerprint); the fingerprint alone is never a lookup key. Mixing the tenant into identity would provide no isolation that scoping does not already provide, while destroying portability.

What a finding carries

Beyond identity and status, a finding carries the fields a backlog is worked from: severity on TRUSTIVAN’s own scale, the advisory identifier, the component and its versions, the location, when it was first seen, and which scan last saw it. Severities are TRUSTIVAN’s, not an engine’s — critical, high, medium, low, none, unknown. An engine’s enum is an implementation detail of an adapter, and a customer’s policy must not need rewriting because an engine renamed a level. first_seen_at is when TRUSTIVAN first observed the finding, and it survives resolution and reopening. It is what age is measured from, because “this has been outstanding for 40 days” is a statement about remediation, not about when the advisory was published.

Fixability has three values

  • fixable — the advisory names a fixed version.
  • unfixable — the source positively asserts that no fix exists.
  • unknown — TRUSTIVAN cannot tell.
The third one is load-bearing. TRUSTIVAN never guesses: absence of a fixed version in the data is not evidence that no fix exists, so it reports unknown. No source TRUSTIVAN consumes today distinguishes the two, so unfixable is modelled but never produced — a policy written against it is valid the day a source does, and will not fire before then. Collapsing “no fix recorded” into “unfixable” would make a rule of “block only on things we can fix” silently stop blocking on exactly the findings nobody has looked at.

Listing findings

Use order=recent for anything that enumerates. It orders by immutable keys, so the traversal is exhaustive: every finding present when you started and not deleted is visited exactly once. order=severity is the backlog view and sorts by a value that changes, so a re-rated finding can move between pages and be seen twice or missed. Each response reports page.stable so a client can assert this rather than assume it. Findings can be filtered by status, severity, kind and asset_id.

The timeline is evidence

Every change to a finding writes a row to its event log, readable at GET /api/v1/findings/{id}/events. The table is append-only, and that is enforced by a database trigger rather than by a comment. An audit trail the application can silently rewrite is not evidence, which is every use the table exists for. Stated honestly, the guarantee has edges:
  • It stops the application from rewriting history, including by accident — an UPDATE with a missing WHERE, or a well-meaning migration that “fixes” an event type.
  • It does not stop a database superuser, who can drop the trigger. Nothing inside the database can.
  • DELETE is permitted, because deleting a tenant must remain possible and the cascade reaches these rows. Erasing a customer is a deliberate act on a whole tenant; rewriting one event is not.

Who did it is structural

There are four kinds of actor, and each is recorded in its own column rather than in a shared “actor” string: The absence is the record. A suppression that expired is a fact nobody is accountable for, and a column asserting otherwise would be a small permanent lie. Crucially, no audit row ever claims a person did what a pipeline did: a machine principal has no user ID to write, so those columns are NULL and the key’s identity goes in its own column.

The audit coverage that exists today

Stated plainly, because a security product being vague about its own audit coverage is worse than having narrow coverage: The vocabulary those events are written in is shared, so the second audited resource will read against the same fields rather than inventing its own shape. But the table for it does not exist yet, and neither do the entries.

Where to go next