The finding model
The two layers — raw and curated — the evidence record, and why nothing reaches a client unreviewed.
RawFinding what a HARNESS emits. Permissive, passthrough, refuses curated fields.
↓ normaliseFinding() at the engine boundary
Finding raw, immutable, one per detection per run. The audit trail.
↓ analyst review — a human, always
PublishedFinding curated, client-visible, keyed by dedupeKey across all runs
of an Assessment. Analyst-editable.The separation is what makes the hard invariant enforceable: nothing reaches a client before an analyst reviews it. A raw record has no review status at all — it cannot be "approved", because approval is a property of the curated layer.
Source: verifi-engine-contract/src/finding.ts.
Both layers at once
carried items skip the gate, and that is not an auto-approval — it is a finding a person already ruled on in an earlier run, whose published record gains nothing but a fresh lastTestedVersion. Publishing refuses the entire batch if any new or reopened item is undecided. The six review statuses are pending_review, open, false_positive, accepted_risk, remediated and reopened — deliberately none meaning “auto-approved” or “published because review timed out”.The emit layer — zRawFinding
What a harness produces, before the engine normalises it. Published in the contract since
1.4.0, and the type of ctx.emitFinding.
export const zRawFinding = z.object({
findingKey: z.string().min(1).optional(), // the stable cross-run identity
title: z.string().min(1), // the only required field besides severity
description: z.string().optional(),
severity: zRawSeverity, // a level, or { label } + anything else
impact: z.string().optional(),
remediation: z.string().optional(),
cwe: z.union([z.string(), z.array(z.string())]).optional(),
cves: z.array(z.string()).optional(), // ⚠️ PLURAL
affected: z.array(zRawAffected).optional(), // [{ url?, parameter?: string | null }]
sourceTool: z.string().optional(),
source: z.string().optional(),
category: z.string().optional(),
confidence: zConfidence.optional(),
}).passthrough()
.refine(/* no reviewStatus | publishedAt | reviewedBy | clientVisible */);Three deliberate asymmetries with the stored zFinding below:
zRawFinding (emit) | zFinding (stored) | |
|---|---|---|
| unknown keys | carried (.passthrough()) | rejected (.strict()) |
| CVEs | cves: string[] | cve?: string — the first one |
| CWE | string | string[] | cwe?: string — the first one |
.passthrough() on the emit layer is not laxness. .strict() there would reject payloads
the engine happily stores, making the schema a stricter authority than the system it
describes — a different way to be wrong, not a safer one. .strict() on the stored layer
is what stops a reviewStatus reaching the immutable record.
Full author-facing detail, including what the normaliser does with each field: Findings and events.
The raw layer
export const zFinding = z.object({
id: z.string().min(1),
runId: z.string().min(1),
binding: zRunBinding,
/** Stable identity across runs — see computeDedupeKey. */
dedupeKey: z.string().min(1),
title: z.string().min(1),
severity: zSeverity,
confidence: zConfidence,
category: z.string().min(1),
description: z.string().min(1),
impact: z.string().optional(),
remediation: z.string().optional(),
cwe: z.string().optional(),
cve: z.string().optional(),
cvss: z.object({ score: z.number().min(0).max(10), vector: z.string() }).optional(),
location: zFindingLocation.optional(),
reproSteps: z.array(z.string()).default([]),
references: z.array(z.string()).default([]),
evidenceIds: z.array(z.string()).default([]),
detectedAt: zISO,
}).strict(); // .strict() so a reviewStatus cannot be smuggled onto the immutable layerWritten the moment it is produced, never at finalize. Finding persistence and run outcome are independent.
Note cwe and cve are singular strings here, while what a harness emits uses
cwe?: string[] | string and cves?: string[]. The engine collapses arrays to their first
element at the boundary. See Findings and events.
reproSteps, references and evidenceIds are always [] on the normalisation path —
there is currently no way for a harness to populate them.
zFindingLocation
export const zFindingLocation = z.object({
url: z.string().url().optional(),
host: z.string().optional(),
/** Kept explicitly. v1 stripped ports and rescanned :8443 as :443. */
port: z.number().int().positive().optional(),
param: z.string().optional(),
method: z.string().optional(),
}).strict();url must be a valid URL, so a bare hostname fails. The engine prefixes a
non-http-prefixed affected[0].url with https:// and also copies the original to
host, which is why emitting { url: 'api.example.com' } works — but emitting a valid URL
yourself is clearer.
There is no path from a harness to port, method, or cvss through the current
normalisation. See Known gaps.
computeDedupeKey
export function computeDedupeKey(assetId: string, location: string, category: string): string {
return createHash('sha256')
.update(`${assetId} ${location} ${category.toLowerCase()}`)
.digest('hex');
}Stable across runs, which is what lets a retest attach to an existing PublishedFinding
rather than creating a duplicate. Category is lowercased so a casing difference cannot split
one cluster into two — the same class of bug as v1's separate "Critical" and "critical"
severity buckets.
zEvidence
export const zEvidenceKind = z.enum(['http_transcript', 'screenshot', 'pcap', 'tool_output', 'log']);
export const zEvidence = z.object({
id: z.string().min(1),
runId: z.string().min(1),
kind: zEvidenceKind,
/** Content-addressed, so immutability is verifiable rather than asserted. */
sha256: z.string().regex(/^[0-9a-f]{64}$/, 'must be a sha256 hex digest'),
sizeBytes: z.number().int().nonnegative(),
storageRef: z.string().min(1),
/** Redaction is applied once at the pipeline boundary, not per call site. */
redacted: z.boolean(),
capturedAt: zISO,
}).strict();The curated layer
export const zReviewStatus = z.enum([
'pending_review', 'open', 'false_positive', 'accepted_risk', 'remediated', 'reopened',
]);There is deliberately no state meaning "auto-approved" or "published on timeout". The hard invariant admits no escape hatch, so the enum admits no such value.
export const zPublishedFinding = z.object({
id: z.string().min(1),
assessmentId: z.string().min(1),
dedupeKey: z.string().min(1),
reviewStatus: zReviewStatus,
/** Every raw observation that matched this key. Never empty — derived, not invented. */
rawFindingRefs: z.array(z.string().min(1)).min(1),
// curated, analyst-editable, client-facing
title: z.string().min(1),
severity: zSeverity,
confidence: zConfidence,
category: z.string().min(1),
description: z.string().min(1),
impact: z.string().optional(),
remediation: z.string().optional(),
references: z.array(z.string()).default([]),
location: zFindingLocation.optional(),
// client-facing lifecycle
introducedVersion: z.number().int().min(1),
lastTestedVersion: z.number().int().min(1),
// audit lifecycle
firstSeenRunId: z.string().min(1),
lastSeenRunId: z.string().min(1),
publishedAt: zISO.optional(),
reviewedBy: z.string().optional(),
auditLog: z.array(zReviewEvent).default([]),
}).strict()
.refine((f) => f.lastTestedVersion >= f.introducedVersion, …);rawFindingRefs has .min(1): a published record is derived, not invented. There is no
way to create one that no run produced.
Both lifecycle keyings are required because they answer different questions:
| Keying | Question it answers |
|---|---|
introducedVersion / lastTestedVersion | Client-facing. What the report says; what "reopened in v3" means. |
firstSeenRunId / lastSeenRunId | Audit. Which exact execution produced the evidence. |
Dropping either loses a question the platform needs answerable.
export const zReviewEvent = z.object({
at: zISO,
by: z.string().min(1),
action: z.enum(['approve', 'edit', 'reject', 'defer', 'reopen', 'accept_risk', 'mark_remediated']),
note: z.string().optional(),
}).strict();What this means for a harness author
You never write to the curated layer, and attempting to is an error rather than a silent drop. Practically:
- Emit observations, not verdicts.
confidence: 'firm'means evidenced;confidence: 'confirmed'should mean a human or an exploit confirmed it. The ASM harness usesfirmfor everything its deterministic rubric produces, with the comment "evidenced, not human-confirmed". - Do not soften severity to avoid noise. An analyst downgrades; you report.
- Write
description,impactandremediationproperly. The analyst edits the curated copy, but they start from yours, and a bare tool string is not a starting point. - Keep
dedupeKeystable. It is the join between your raw record and the curated one an analyst has already reviewed. Changing it makes a known, accepted-risk finding reappear as new.