Scan-to-scan diff
see what changed between any two executions
Compare two scan executions against the same target on one platform record. The diff endpoint returns new, fixed, and unchanged findings, identifies which scanner modules ran in one execution but not the other, and annotates every recurring finding with its current override status. RBAC-gated, workspace-scoped, and aware of the override register so suppression decisions travel across cycles.
No credit card required. Free plan available forever.
Read what changed between two scans, not the full result set
Every recurring scan against the same target produces three distinct classes of finding: the fresh detections that arrived since the previous execution, the closures that the scanner can no longer detect, and the recurring detections that persist across cycles. Reading those three classes off the raw scan result every time the cadence ticks is the most reliable path to triage fatigue, missed regressions, re-triaging of already-accepted exceptions, and quiet drift in the active backlog. The triage queue should read the change rather than the full result set.
Scan-to-scan diff in SecPortal is a structured endpoint on the workspace operating record. The GET /api/scans/diff endpoint accepts two scan execution identifiers against the same target, walks the modules object on each execution, and returns three buckets keyed by a deterministic composite identity (new_findings, fixed_findings, unchanged_findings). Every entry is annotated with the active override status from the workspace override register so the suppression and acceptance decisions from previous cycles travel with the recurring finding. The endpoint also surfaces module coverage deltas explicitly so a closure caused by a module that did not run on the comparison scan never reads as remediation evidence on the engagement record.
Three structural diff buckets the endpoint returns
Collapsing arrivals, closures, and recurrences into a single change list is the most common path to an unreadable diff. SecPortal returns three distinct arrays so the triager reads each class against its own operational handling.
new_findings
Purpose. Findings present in scan B but absent in scan A. Treat as regressions or fresh detections that arrived after the previous execution against the same target.
What the record carries. Returned as an array of DiffFinding entries with the module key, the composite identity key (module::finding.id), and the full scanner-emitted finding payload. Each entry is annotated with its current override status read from scan_finding_overrides for the (workspace, finding_id, target) lookup.
Triage reading. A new finding annotated with a false_positive override means the previously verified suppression now applies to the recurrence; the triager confirms whether the underlying condition has materially changed before re-running the verification. A new finding without an override means a genuine arrival the team must triage from scratch.
fixed_findings
Purpose. Findings present in scan A but absent in scan B. Treat as closures the scanner can no longer detect against the same target.
What the record carries. Returned with the same composite identity key and module reference as the new findings bucket so closures can be matched against the live record. Override annotations carry through so a previously accepted risk that no longer fires reads as a closure under accepted_risk rather than a silent disappearance.
Triage reading. A fixed finding annotated with an active accepted_risk override means the closure can be reconciled against the exception register; a fixed finding with no override is a clean closure that can advance the engagement record to the verified state. Closures without override context are still safe to mark as such, but the audit reads the trail more cleanly when the override status is recorded alongside.
unchanged_findings
Purpose. Findings present in both scans against the same target. The recurring-detection backbone of the diff: the active backlog the team has not yet acted on, and the exception register the team has accepted.
What the record carries. Returned with the same composite identity key so the recurring identity persists across cycles. Override status carries forward without manual re-application; severity_override findings continue to read against the workspace severity, accepted_risk findings continue to surface on the exception queue, false_positive findings continue to suppress from the active backlog.
Triage reading. A growing unchanged_findings bucket on a target with no closures and no exception register entries is the most reliable on-platform signal of a stalled remediation programme. The recurring-finding count reads against the open-finding state staleness research, the security debt economics, and the SLA breach aging distribution.
The nine fields every diff response carries
The diff endpoint returns one JSON envelope per call. Each field has a specific job in the audit trail and the operational consumption path.
scan_a
The identifier of the older scan execution passed in the scan_a query parameter. Returned in the response so the diff record is self-describing without requiring a second lookup.
scan_b
The identifier of the newer scan execution passed in the scan_b query parameter. Returned in the response alongside scan_a so the comparison direction is unambiguous in the audit trail.
target
The scan target the diff is computed against. Pulled from scan B (the newer execution) and returned on every diff response. The diff endpoint does not compute across mismatched targets; both scans must run against the same target for the comparison to be defensible.
new_findings[]
Array of DiffFinding entries present in scan B but not in scan A. Each entry carries the module key, the composite identity key, the full scanner finding payload, and the override annotation read from the workspace override register.
fixed_findings[]
Array of DiffFinding entries present in scan A but not in scan B. Same shape as new_findings. The diff endpoint does not silently drop fixed findings; closures are surfaced as their own structural class so the engagement record can read them alongside the new arrivals.
unchanged_findings[]
Array of DiffFinding entries present in both scans against the same target. The recurring identity persists across cycles so the override register stays applicable without manual re-application.
modules_only_in_a[]
Array of scanner module keys that ran in scan A but not in scan B. Reads as a coverage delta: a module that ran on the previous execution but did not run on the current one. The diff endpoint surfaces this explicitly so the team can distinguish a fixed finding from a missing-coverage artifact.
modules_only_in_b[]
Array of scanner module keys that ran in scan B but not in scan A. Reads as the inverse coverage delta: a module that ran on the current execution but did not run on the previous one. A new finding from a module that only ran in scan B is not the same as a regression on a module that ran in both.
summary
Three integer counters (new, fixed, unchanged) for the cardinality of the three diff buckets. The summary reads cleanly into a dashboard widget or a notification payload without requiring the consumer to count the arrays.
The composite identity contract the diff depends on
The diff is only as defensible as the identity key the comparison runs against. SecPortal uses a structural composite key so the diff stays stable across cosmetic re-runs and surfaces the right finding under the right recurring identity.
Composite identity key: module::finding.id
Every scanner-emitted finding lands in the diff under the composite key formed by the scanner module key and the finding identifier the module assigned. The same finding identifier from two different modules does not collide; the same finding identifier from the same module across two executions matches deterministically.
Module-aware match (not whole-result naive hash)
The diff does not hash the full scanner result and compare strings. It walks the modules object on scan A and scan B, walks the findings array under each module, and builds two maps keyed by module::finding.id. The match is structural rather than textual, so cosmetic changes to the scanner output (timestamp fields, ordering, run metadata) never produce phantom new findings.
Target-scoped comparison
Both scan A and scan B must reference the same target. The diff endpoint pulls the target from scan B and uses it for the override lookup; comparing scans across mismatched targets does not produce a defensible diff, so this is enforced as an operational expectation rather than an automatic cross-target merge.
Workspace-scoped at the database layer
Both scan executions must belong to the same workspace as the requesting user. The Supabase query joins scan_executions on workspace_id, so a scan from workspace A cannot be diffed against a scan from workspace B even with valid scan identifiers. Row-level security enforces the tenant boundary at the database rather than only in the application layer.
Recurring identity persists across cycles
Because the composite key is deterministic, the same underlying issue retains the same identity on every subsequent scan against the target. The override register keys against the same finding_id and target tuple, so suppression and acceptance decisions travel with the recurring identity without manual re-application.
Four override states the diff annotates every entry with
Every diff entry carries an override field populated from the workspace override register. The four states each carry a distinct triage reading so the recurring identity is paired with the active decision without manual re-application.
No override (override: null)
The finding carries no active override for the (workspace, finding_id, target) tuple. New findings without an override are fresh detections the team must triage. Unchanged findings without an override are the active backlog. Fixed findings without an override are clean closures.
false_positive
The (workspace, finding_id, target) tuple has an active false_positive override. The diff annotates the finding so the triage queue reads the suppression without re-running the verification from scratch. A recurring false_positive on a new_findings entry inherits the previously verified suppression; the team only re-verifies when scanner rule changes warrant it.
accepted_risk
The (workspace, finding_id, target) tuple has an active accepted_risk override. The diff annotates the finding so the exception register reads the recurrence rather than the active backlog. A fixed_finding annotated with accepted_risk is a closure the exception register can retire on review.
severity_override
The (workspace, finding_id, target) tuple has an active severity_override. The diff annotates the finding so the workspace severity reads against the new_severity decision rather than the scanner-emitted severity. Recurring severity overrides do not require re-rating every cycle.
The diff API surface
The diff endpoint lives on the platform API so workspace automations can consume the diff record through the same surface that the UI reads. The endpoint is RBAC-gated through the initiate_scan permission so diff authority is explicit and assignable.
GET /api/scans/diff?scan_a=<id>&scan_b=<id>
Compute the diff between two scan executions for the same target. Returns the three diff buckets (new_findings, fixed_findings, unchanged_findings), the module coverage deltas (modules_only_in_a, modules_only_in_b), the cardinality summary, and per-finding override annotations. Required query parameters: scan_a and scan_b. Returns 400 when either is missing, 401 when no session, 404 when either scan is not found in the requesting workspace, 500 on internal failure. RBAC-gated through the initiate_scan permission.
How the diff lands on the engagement record (five steps)
The diff feature is only as useful as the operating discipline that runs against it. The five steps below describe how a workspace consumes the diff so the result lands on the engagement record rather than in an analyst notebook.
Pick two scan executions against the same target
Choose the baseline scan and the comparison scan. Both executions must belong to the requesting workspace, and both must reference the same target. The diff endpoint pulls the target from scan B and uses it as the canonical target for the override lookup; the comparison is undefined for mismatched targets.
Call GET /api/scans/diff with scan_a and scan_b
Pass the older scan as scan_a and the newer scan as scan_b in the query string. The endpoint returns the three diff buckets, the module coverage deltas, and the summary counters in one JSON payload. RBAC gates the call through the initiate_scan permission so the diff authority is an explicit team capability rather than an implicit privilege.
Read the override annotations on every diff entry
Every entry across new_findings, fixed_findings, and unchanged_findings carries an override field populated from scan_finding_overrides. A null override means no active suppression or acceptance for the (workspace, finding_id, target) tuple. A populated override means the existing decision applies to the recurrence and the triage queue can read the disposition without re-evaluating from scratch.
Reconcile coverage deltas before drawing closure conclusions
Check modules_only_in_a and modules_only_in_b before treating fixed_findings as remediation evidence. A finding that disappears because the module that detected it did not run in scan B is a coverage artifact rather than a closure. The platform surfaces the modules-only lists explicitly so the engagement record never carries a closure that the next analyst cannot defend.
Land the diff on the engagement record for the audit
The diff result reads against the engagement record alongside the underlying scan executions, the override register, and the activity log. The audit reconstructs the closure from the diff (what was fixed), the coverage deltas (which modules ran), the override register (which decisions persist), and the activity log (who triggered the diff and when).
Seven failure modes the structured diff prevents
Reading the new findings count without checking module coverage
A scheduled scan that ran fewer modules than the previous execution produces a fixed_findings bucket that overstates remediation progress and a new_findings bucket that understates fresh detections. SecPortal surfaces modules_only_in_a and modules_only_in_b on every diff so the team distinguishes a clean closure from a coverage artifact before reporting numbers to leadership.
Recurring false positive re-triaged every cycle
When the override register is not consulted at diff time, every recurring false positive lands in the unchanged_findings bucket as raw work. SecPortal looks up active overrides at diff time and annotates each entry so the triage queue reads the suppression alongside the recurrence; the false-positive decision applies once and travels with the finding.
Whole-result string hash producing phantom diffs
A naive diff that hashes the full scanner output and compares the hashes treats every cosmetic change (timestamp metadata, ordering, run identifiers) as a fresh finding. SecPortal walks the modules object and matches on the composite module::finding.id key, so the diff is structural and stable across re-runs that produce the same findings.
Cross-target diff producing nonsense closures
Diffing a scan of staging.example.com against a scan of production.example.com surfaces every finding on either target as either new or fixed. SecPortal pulls the target from scan B and uses it for the override lookup; the comparison is operationally meaningful only against the same target, and the API surface reflects that boundary by returning a single target field on the response.
Closure inferred from missing module rather than from remediation
When a module fails to run on the comparison scan (a credential rotation, a timeout, an upstream rate limit), every finding from that module lands in the fixed_findings bucket. The modules_only_in_a list surfaces the missing module explicitly so the triager can distinguish a coverage failure from a real closure before the engagement record carries an unverifiable claim.
Cross-workspace diff bypassing the tenant boundary
A diff that walks scan tables without scoping to the requesting workspace can leak findings across tenants. SecPortal joins scan_executions on workspace_id and runs both lookups inside Supabase row-level security so a scan from workspace A cannot be diffed against a scan from workspace B even when both identifiers are known.
Anonymous diff authority
A diff endpoint that does not gate on a team permission lets every workspace member compute and act on the comparison without an explicit role. SecPortal gates the diff endpoint on the initiate_scan permission so the diff authority lives with the role that runs scans, and the activity log records who called the endpoint and when.
Five enterprise scenarios the diff feature operates against
Vulnerability management programme weekly cadence read
A vulnerability management team runs weekly external scans against the production estate. Every Monday the team diffs the latest scan against the previous week to see what arrived, what closed, and what persists. The override annotation surfaces the previously accepted risks and the previously suppressed false positives so the recurring backlog reads cleanly against the active work; the module coverage delta surfaces any coverage drop that needs reconciling before the weekly leadership review.
AppSec authenticated scan regression verification
An AppSec team runs authenticated scans before and after a major release. The pre-release scan is the baseline; the post-release scan is the comparison. The fixed_findings bucket reads as remediation evidence the release shipped; the new_findings bucket reads as regressions that landed alongside the release. The override annotations carry the previously accepted risks so the team distinguishes a real regression from a recurring exception that was already documented.
GRC and audit evidence preparation for vulnerability lifecycle
A GRC team preparing for ISO 27001 surveillance and SOC 2 fieldwork needs to demonstrate that the scanner-detected backlog is managed rather than left to accumulate. The diff result over time reads as an audit-defensible evidence chain: every closure is paired with the prior detection, every regression is paired with a triage record, every recurring exception is paired with the override register. The closure narrative reads against ISO 27001 Annex A.8.8, SOC 2 CC7.1 and CC7.2, and NIST SP 800-53 RA-5 and SI-2.
Security engineering scan-to-remediation handoff
A security engineering team uses the diff endpoint to feed the developer queue: every new_findings entry annotated with no override becomes a ticket for the appropriate squad, every unchanged_findings entry that has aged past the SLA becomes an escalation, every fixed_findings entry that has an associated override is reconciled against the exception register. The diff is the operational anchor between the scanner output and the developer workflow without requiring a separate ingestion job.
Continuous monitoring trend with regression alerting
A security operations team operates daily continuous monitoring schedules on the highest-value targets. The diff between successive daily executions reads as the trend signal: a positive new_findings count is a regression alert, a sustained unchanged_findings count is a stalled remediation signal, a coverage delta in modules_only_in_a is a module-failure alert. The team reads the trend off the diff rather than reconstructing it from raw scan outputs.
How audit frameworks read the diff record
Auditors reading the vulnerability programme effectiveness evidence against the common frameworks read three artefacts: the cadence (which scans ran and when), the change (what arrived, what closed, what persisted between executions), and the operating discipline (which recurring findings carried overrides, which were re-triaged, which were retired). The diff result supplies the second artefact directly and annotates it with the third.
| Framework | How the diff record reads as evidence |
|---|---|
| ISO 27001:2022 | Annex A.8.8 Technical Vulnerabilities reads the diff history as the operating evidence that scanner-detected exposure is monitored across cycles. A.5.7 Threat Intelligence reads the override annotations on new_findings as the calibration discipline for recurring detections. A.5.24 Information Security Incident Management reads the regression alerts on new_findings as the trigger evidence for the incident handling discipline. |
| SOC 2 Trust Services Criteria | CC7.1 System Operations Monitoring reads the diff endpoint as the structured monitoring evidence between successive scan cycles. CC7.2 Anomaly Identification reads the regression detection on new_findings as the anomaly evidence. CC4.1 Monitoring of Controls reads the cardinality summary across diff cycles as control monitoring evidence. |
| PCI DSS v4.0 | Requirement 11.3 reads the diff result as the rerun-and-resolve evidence for quarterly vulnerability scanning. 6.3.1 reads the override annotations on the diff entries as the ranking-and-treatment evidence. 6.3.3 reads the unchanged_findings bucket as the open-finding pool that must be remediated within the documented timeframes. |
| NIST SP 800-53 Rev. 5 | RA-5 Vulnerability Monitoring reads the diff result and the override annotations as the documented monitoring evidence. SI-2 Flaw Remediation reads the fixed_findings bucket paired with the prior detections as the remediation closure evidence. CA-7 Continuous Monitoring reads the diff cadence as the continuous monitoring discipline. |
| NIST CSF 2.0 | DE.CM Continuous Monitoring reads the diff as the structured between-cycle evidence. ID.RA Risk Assessment reads the override annotations as the calibration record. RS.AN Response Analysis reads the regression alerts on new_findings as the analysis trigger. |
How scan-to-scan diff fits the rest of the platform
The scheduling discipline that produces the scan executions the diff endpoint compares. Continuous monitoring runs the scans on a recurring cadence; scan-to-scan diff reads the change between executions on the same target.
The override register the diff endpoint annotates against. Every diff entry carries the active override status for the (workspace, finding_id, target) tuple so the triage queue reads the recurrence alongside the existing decision.
The external scanner that produces one of the execution classes the diff compares. The 16 external modules each emit findings under module keys that the diff uses for the composite identity match.
The authenticated scanner that produces another execution class the diff compares. Authenticated scan executions diff against earlier authenticated executions of the same target so behind-login regressions surface on the same record as pre-authentication ones.
The code scanner that produces the third execution class the diff compares. Code scan executions diff against earlier executions of the same connected repository so SAST and SCA regressions surface on the same recurring identity.
The post-remediation verification surface. The diff endpoint feeds the retest verification with the structural closure list (fixed_findings) and the structural recurrence list (unchanged_findings), so the retest reads against the diff rather than re-running the triage from scratch.
The audit trail every diff call feeds. The activity log records the actor, the timestamp, and the diff parameters so the audit can reconstruct who computed the comparison and when.
Scope and honest limits
Scan-to-scan diff is an endpoint that returns the change between two executions on the same target. The list below names what the feature does and does not do, so operators choose the right surface for the work that sits outside its scope.
- The diff endpoint compares two scan executions on the same target; it does not compute aggregate trends across many executions. Aggregate trend reading lives on the dashboard and on the scan history view rather than on the diff endpoint.
- The diff endpoint matches on the composite module::finding.id key; it does not run a semantic similarity match between distinct finding identifiers that describe the same underlying issue. Cross-identifier semantic merging is the discipline that wraps the diff result rather than a property of the endpoint.
- The diff endpoint reads the override register at call time; it does not reconstruct historical override status. A diff computed against past scans reflects the current override state, and the activity log carries the historical override decisions separately.
- The diff endpoint does not synchronise the diff result to Jira, ServiceNow, Linear, Azure DevOps, or any external ticketing system. The diff is a workspace record; downstream automations consume the API result inside their own ingestion pipelines.
- The diff endpoint requires both scans to belong to the requesting workspace. Cross-workspace diffs are not supported; multi-workspace organisations operate workspace-scoped diff cycles and reconcile across the boundary in their own programme reporting.
- The diff endpoint does not deduplicate findings across targets. A finding that appears on staging.example.com and on production.example.com remains two distinct diff entries because the target scope is part of the identity. Cross-target merging is a separate discipline on the engagement record.
Where to read next
For the discipline layer above the API (seven structural event types, deterministic composite identity, coverage signature reading alongside the change classification, SLA clock continuity per event type), see the scanner output diff and change event generation explainer.
For the scheduling discipline that produces the scan executions the diff compares (four cadences, plan-based schedule limits, automatic retry on failure, stale job recovery), see the continuous monitoring feature.
For the override register the diff annotates against (three structured types, target-scoped uniqueness, RBAC-gated mutations, activity-log attribution), see the finding overrides feature.
For the workflow that wraps the diff result with the retest verification (verification source binding, regression handling, the retest decision record), see the retesting workflow.
For the broader scan-baseline-and-trend reading that consumes diff results as the source data for aggregate trend metrics across many executions, see the scan baseline and trend comparison explainer.
For the economic analysis of how the open-finding state grows without a disciplined diff cycle and why a structured diff endpoint is a prerequisite for a healthy programme, see the open finding state staleness economics research.
Underlying platform mechanics worth knowing
RBAC at the API boundary
The diff endpoint requires the initiate_scan permission on the requester's team role through team management so the diff authority is an explicit, observable team capability.
Tenant isolation by row-level security
The diff endpoint joins scan_executions on workspace_id, and Supabase row-level security enforces tenant boundaries at the database. A scan from workspace A cannot be diffed against a scan from workspace B even with valid identifiers.
Activity log as diff history
Every diff call feeds the activity log with the actor and the diff parameters so the audit can reconstruct who computed the comparison and when.
Bulk import diffs read the same surface
Findings ingested through bulk finding import (Nessus, Burp Suite, CSV) land on the engagement record alongside scanner-emitted findings. The diff endpoint operates on scan_executions specifically, while imported findings on the engagement record carry their own lifecycle on the findings list.
Three scan classes, one diff endpoint
The diff endpoint operates uniformly across external, authenticated, and code scan executions because every execution writes its findings into the same result_summary structure under module keys. The composite identity contract holds across scan classes.
Scan A is older, scan B is newer
The comparison direction is convention. Passing the older execution as scan_a and the newer as scan_b yields the expected new_findings/fixed_findings semantics. The endpoint does not reverse the comparison; calling with the parameters swapped transposes the buckets.
Stop reading recurring scans from scratch
Pick two scans on the same target. SecPortal returns the new findings, the fixed findings, the unchanged findings, the module coverage delta, and the active overrides annotated against the diff. The triage queue reads the change rather than the full result set.
No credit card required. Free plan available forever.