Severity describes a vulnerability in the abstract: how bad it would be if somebody exploited it. It says nothing about whether anybody is. TRUSTIVAN adds two published signals to every vulnerability finding, and says where each came from and how old it is.

The risk score

Every finding carries a risk score from 0 to 100 and the complete breakdown behind it. The dashboard shows the breakdown as Why this priority on the finding page; the API returns it as risk on every finding (model: "risk-v2"). There is no learned weight and no hidden input: you can recompute any score from its breakdown.
Worked examples: an unexploited critical (CVSS 9.8, EPSS 2%) with a fix scores 45; a high (CVSS 7.5) in the KEV catalogue scores 65; Log4Shell running on a critical asset scores 100; that same high, if it only reaches the software through a development dependency, scores 23 instead of 38 — still visible, ranked below anything on a real runtime path. An exploited high outranks an unexploited critical, which is the point. Each factor records its source and the date of its data (the KEV catalogue version, the EPSS model and score date, when the runtime edge was last observed). Internet exposure is shown as “not recorded”: no collector records it yet, so it is not an input. Unknown is not no. With no KEV catalogue loaded the known-exploited factor reads “unknown” and risk.known_exploited is null, never false.

Filtering by threat

GET /api/v1/findings takes known_exploited=true|false (three states — the absent one does not filter) and min_epss=<0-1>. Both read the STORED assessment; neither triggers a live KEV or EPSS lookup. A finding never scored, or scored where EPSS is off, has no value and never matches min_epss — absence of a score is not evidence it is low.

Reachability: dependency path

Severity and threat describe the advisory. They say nothing about whether your build actually ships the vulnerable code. TrustIvan adds the one reachability signal that generalises across ecosystems without adding a new collector: whether the scanner’s own manifest parse marks the package a development or test dependency (devDependency in npm, and the equivalent concept where the ecosystem has one). A vulnerability in jest scores lower than the identical CVE in express, because only one of them ships. This is deliberately narrower than full call-graph reachability (“is the vulnerable symbol imported”), which no scanner here performs across ecosystems. Direct-vs-transitive dependency depth is recorded in the component graph (GET /assets/{id}/dependents) but is not yet a scoring input — see Roadmap for what is tracked and not yet built.

Suppression and resolution

The score describes the finding in its context and does not change when you triage it. Suppressing or resolving a finding takes it out of the risk order instead: risk_priority — the key order=risk sorts by — is the score for an open, reopened or triaged finding, and 0 for a suppressed or resolved one. The accepted assessment stays readable, and a policy can still see it.

When scores update

The risk pass re-scores an asset’s findings after every scan of it, and every finding after each feed refresh (daily by default). Changing an asset’s criticality re-scores its findings immediately. A new runs edge is picked up by the next pass. risk_scored_at is when the assessment last changed.

SLA clocks

An organization can set a remediation target, in days, per severity: PUT /api/v1/settings/sla/{severity} (critical, high, medium or low). No severity has a target until you set one — TrustIvan does not invent an industry-default SLA and grade you against a policy you never wrote. Manage the policy from Policies in the dashboard, or the API directly. Once a target is set, every finding of that severity carries: All three fields are absent — not false, not zero — on a finding whose severity has no configured target. An unconfigured SLA is not the same fact as “on time”.

The two feeds

A vulnerability matches a feed by its CVE ID or by any of its aliases, so a finding reported under a GitHub advisory ID still matches the CVE it names.

How the feeds stay current

The worker fetches each feed on a schedule (daily by default, RISK_FEED_REFRESH_HOURS), stores it, and re-applies it to findings already stored — you do not need to rescan an asset for a newly catalogued vulnerability to be flagged on it. When a finding’s known-exploited status changes this way, the change is written to the finding’s timeline with the catalogue version it came from.

When a feed cannot be fetched

A failed fetch, an error page served with a 200, or a document with nothing in it never replaces the stored feed. Replacing it with an empty one would mark the whole estate not-exploited with the authority of a loaded catalogue. Instead:
  • the previous data stays in force, and the error is recorded;
  • the fetch is retried within the hour;
  • once no refresh has succeeded for 72 hours, or once the document itself is older than 14 days (KEV) or 72 hours (EPSS), the data is flagged stale. Stale data is still used — a week-old exploitation catalogue is far better evidence than none — but it is never presented as fresh.
GET /api/v1/system/status reports each feed under exploits (KEV) and epss: its source, version, publication date, when it last loaded, whether it is stale, and the last error. A feed that has never loaded is reported as not configured, and its signal as unknown — never as “not exploited”.

Air-gapped deployments

Set KEV_SOURCE=file and EPSS_SOURCE=file with KEV_PATH / EPSS_PATH pointing at documents you place on the worker’s filesystem. The worker re-reads them on every refresh, so replacing the file is how an offline site updates, and an old file turns stale on the same rules as a failed fetch. See Environment variables.