From 5f70149b8ffdcce5529d92839bae584b9e1d6266 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:39:56 +0200 Subject: [PATCH] fix: describe baseline sample scans instead of reporting them as failed Managed scans record their comparison outcome when they are finalized, in a new scans.comparison column added by schema 65: compared, baseline_sample, baseline_established, or not_compared. The job scan detail and changes endpoints report comparison_state from that outcome, so a successful scan that ran while the job was learning its baseline is a baseline sample, the sample that completed the baseline says it established it, and neither is later diffed against a baseline that did not exist when it ran. Failed, timed-out and canceled scans, and results kept without a comparison, are not_compared. Only rows recorded before schema 65 keep the current_baseline_legacy fallback. The console describes each state, keeping the failure copy for scans that did not complete. The job guide explains scan comparison states, and the API, database compatibility and security notes cover the new values and schema 65. Fixes #1224 --- SECURITY.md | 5 +- .../docs/getting-started/first-scan.md | 5 +- .../docs/reference/api-compatibility.md | 20 ++ .../docs/reference/database-compatibility.md | 33 ++- .../user-guide/jobs-baselines-incidents.md | 27 +++ internal/app/app.go | 4 +- internal/engine/comparison_outcome_test.go | 99 ++++++++ internal/engine/engine.go | 22 +- internal/engine/engine_test.go | 3 + internal/model/model.go | 35 ++- internal/store/baseline_export.go | 4 +- internal/store/finalize_paused_tenant_test.go | 4 + internal/store/host_history.go | 8 +- internal/store/legacy_history.go | 12 +- internal/store/migration65.go | 19 ++ internal/store/migration65_test.go | 217 ++++++++++++++++++ internal/store/migrations.go | 9 +- internal/store/runtime.go | 8 +- internal/store/runtime_coverage_extra_test.go | 2 +- internal/store/scan_history.go | 20 +- internal/store/store_test.go | 6 +- internal/web/resource_routes_test.go | 2 +- internal/web/scan_comparison_state_test.go | 170 ++++++++++++++ internal/web/scan_handlers.go | 162 ++++++++----- src/api.ts | 6 +- src/pages/JobDetail.actions.test.tsx | 38 +++ src/pages/JobDetail.tsx | 37 ++- src/types.ts | 8 +- 28 files changed, 873 insertions(+), 112 deletions(-) create mode 100644 internal/engine/comparison_outcome_test.go create mode 100644 internal/store/migration65.go create mode 100644 internal/store/migration65_test.go create mode 100644 internal/web/scan_comparison_state_test.go diff --git a/SECURITY.md b/SECURITY.md index 235bdb0b..c2619e93 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -471,7 +471,7 @@ or of the platform is still locked, whichever unit `--tenant` selects, and reports that count as `deployment_locked`, never a URL. The console notification test covers only the unit's own destinations, so a unit's administrators learn nothing about another unit's or the platform's. A -database upgraded to schema 64 must not be opened by an older EdgeWatch +database upgraded to schema 65 must not be opened by an older EdgeWatch binary; downgrade by restoring the complete pre-upgrade `./data` backup before starting the old version. The daemon and the host commands that write to the database, including `backup`, @@ -480,7 +480,8 @@ Schema 63 also marks legacy unsent deliveries with at least eight attempts and a retry scheduled before v0.22.1 as terminal; deliveries still retrying under the newer fifteen-attempt policy remain eligible. Schema 64 adds an index for pruning old restore-quarantine records without rewriting delivery -history. +history. Schema 65 records how each new scan was compared with its job's +baseline; existing scans and their results are not changed. Only the daemon migrates. The host commands that act on business units or accounts (`admin`, `scan`, `status`, `history`, `baseline`, and `notify test`) refuse a schema that the daemon has not upgraded yet, such as a diff --git a/docs/src/content/docs/getting-started/first-scan.md b/docs/src/content/docs/getting-started/first-scan.md index 0fdd60e2..bba63481 100644 --- a/docs/src/content/docs/getting-started/first-scan.md +++ b/docs/src/content/docs/getting-started/first-scan.md @@ -40,7 +40,10 @@ still apply to the job. ## Establish the baseline Run the job and inspect its results. Approve a successful scan as the baseline -once you have verified that it represents the surface you expect. +once you have verified that it represents the surface you expect. Until the +job has collected its baseline samples, the scan detail describes each scan as +a baseline sample instead of a comparison; see +[Scan comparison](/user-guide/jobs-baselines-incidents/#scan-comparison). :::note[Incomplete observations do not change expectations] Failed, canceled, timed-out, or incomplete scans remain available for diff --git a/docs/src/content/docs/reference/api-compatibility.md b/docs/src/content/docs/reference/api-compatibility.md index 2ba5fb7c..054f605f 100644 --- a/docs/src/content/docs/reference/api-compatibility.md +++ b/docs/src/content/docs/reference/api-compatibility.md @@ -38,6 +38,26 @@ are a positive port state or `not-open`, and `key` has the form `address`. Clients that handle change kinds individually should treat an unknown kind as a generic change. +## Scan comparison states + +`GET /api/v1/jobs/{jobID}/scans/{scanID}` and +`GET /api/v1/jobs/{jobID}/scans/{scanID}/changes` describe how the scan was +compared with the job's baseline: + +| `comparison_state` | `comparison_source` | Meaning | +| --- | --- | --- | +| `compared` | `scan_time` | Compared with the baseline that existed when the scan finished. `changes` is the diff stored with the scan, and `baseline_scan_id` names that baseline. | +| `compared` | `current_baseline_legacy` | A scan recorded before v0.26.0 without a scan-time comparison, compared with the current baseline on each request. | +| `baseline_sample` | `none` | The job had no baseline when the scan finished, so nothing was compared. | +| `baseline_established` | `none` | The scan was the sample that completed the baseline; `baseline_scan_id` is the scan's own ID. | +| `not_compared` | `none` | The scan failed, timed out, or was canceled, or its result was kept without a comparison. | + +v0.26.0 adds `baseline_sample` and `baseline_established`. Earlier releases +reported those scans as `not_compared` while the job had no baseline, and +compared them with the current baseline once it had one. The scan objects of +the scan and job scan endpoints also carry the recorded `comparison`; it is +omitted for scans recorded before v0.26.0. + ## Business units v0.20.0 adds business units to every installation. The routes and response diff --git a/docs/src/content/docs/reference/database-compatibility.md b/docs/src/content/docs/reference/database-compatibility.md index 7fd1bae1..e2577269 100644 --- a/docs/src/content/docs/reference/database-compatibility.md +++ b/docs/src/content/docs/reference/database-compatibility.md @@ -5,19 +5,21 @@ description: Understand the current SQLite schema, forward-only migrations, and ## Current schema and rollback -The current schema is version 64. Schema 31 records terminal notification +The current schema is version 65. Schema 31 records terminal notification deliveries and marks rows that had already exhausted the original eight attempts. Schema 63 repairs databases that had already passed schema 31 by marking still-unsent rows with at least eight attempts and a scheduled retry before v0.22.1, when the retry budget increased to fifteen. Retries scheduled on or after that release remain eligible, so upgrading does not replay deliveries that were still retrying. Schema 64 adds an index for pruning old -restore-quarantine records; it does not rewrite delivery history. Database -migrations are forward-only. An older image must not be pointed at a database -already upgraded by a newer image; restore the matching pre-upgrade `./data` -backup if a rollback is required. The daemon and the commands that write to the -database (admin, scan, notify test, baseline approve and reset, and backup) -refuse versions above their supported schema version with the error +restore-quarantine records; it does not rewrite delivery history. Schema 65 +records how each new scan was compared with its job's baseline; it does not +change existing scans. Database migrations are forward-only. An older image +must not be pointed at a database already upgraded by a newer image; restore +the matching pre-upgrade `./data` backup if a rollback is required. The daemon +and the commands that write to the database (admin, scan, notify test, +baseline approve and reset, and backup) refuse versions above their supported +schema version with the error `database schema version N is newer than supported version M`. Back up such a database with the release that upgraded it, or copy `./data` while EdgeWatch is stopped. A daemon that finds another daemon's live lease exits @@ -33,6 +35,23 @@ baseline evidence in restartable batches. Service names and products are prioritized so services on late ports remain searchable even when a host has many positive ports. The rebuild does not change scan results or baselines. +## Schema 65 + +Schema 65, introduced in v0.26.0, adds a `comparison` column to the scans +table. When a scan of a job finishes, it records whether the scan was compared +with the baseline, was a baseline sample, established the baseline, or was not +compared. The scan detail reports that outcome, so a successful baseline sample +is no longer described as a scan that did not complete, and its result no +longer changes after the baseline is established, an incident is accepted, or +the baseline is reset; see +[Scan comparison](/user-guide/jobs-baselines-incidents/#scan-comparison). +Existing scans keep an empty value, because the migration cannot tell an +earlier baseline sample from a scan recorded before scan-time comparisons. They +behave as before: a scan without changes recorded at scan time is compared with +the current baseline. It is a quick in-place change with no background phase. +An older release refuses the upgraded database, so a rollback means restoring +the pre-upgrade `./data` backup. + ## Schema 62 Schema 62, introduced in v0.25.14, adds the work queues that erase a diff --git a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md index 16fd9130..86a88617 100644 --- a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md +++ b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md @@ -99,3 +99,30 @@ Host searches cover partial IP addresses, DNS names, targets, job names, and service names or products. Enter at least 3 and no more than 256 characters; searches stay on the indexed path and service names/products are prioritized within the bounded search document. + +## Scan comparison + +Each scan's detail says how the scan was compared with the job's baseline when +it finished. That description is recorded with the scan, so it stays the same +after the baseline is established, an incident is accepted, or the baseline is +reset. + +- **Baseline sample:** the job was still learning its baseline, so there was + nothing to compare the scan with. A successful scan counts as one of the + job's baseline samples; an incomplete scan does not. Jobs created in the + console learn from two samples by default, so their first scan is a baseline + sample. Each scope change or baseline reset starts a new set of samples. +- **Established the baseline:** the scan was the sample that completed the + baseline. +- **Changes recorded at scan time:** the scan was compared with the baseline + that existed when it finished. Its changes are kept with it and are not + recalculated later. +- **Not compared because it did not complete successfully:** the scan failed, + timed out, or was canceled. +- **Not compared with the baseline:** the scan completed, but EdgeWatch kept + its result without a comparison, for example because the job's security + settings changed while it ran. + +Scans recorded before v0.26.0 have no recorded comparison. When such a scan has +no changes recorded at scan time, its detail compares it with the current +baseline and reports the changes against the current baseline. diff --git a/internal/app/app.go b/internal/app/app.go index 585d7272..bda342ba 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1081,7 +1081,9 @@ func (a *App) runJobWithQueueMarker(ctx context.Context, scope store.TenantScope if destinationErr != nil { // Preserve the completed scan even when notification configuration // cannot be read. Runtime state is deliberately left unchanged, - // matching the pre-transaction behavior. + // matching the pre-transaction behavior, so the scan is recorded as + // not compared. + scan.Comparison = model.ScanComparisonNotCompared saveCtx, saveCancel := context.WithTimeout(persistCtx, scanPersistenceWriterWaitTimeout+persistTimeout) defer saveCancel() if saveErr := system.SaveScan(saveCtx, scan); saveErr != nil { diff --git a/internal/engine/comparison_outcome_test.go b/internal/engine/comparison_outcome_test.go new file mode 100644 index 00000000..5b68b3f9 --- /dev/null +++ b/internal/engine/comparison_outcome_test.go @@ -0,0 +1,99 @@ +package engine + +import ( + "context" + "testing" + "time" + + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/model" + "github.com/crypt0rr/edgewatch/internal/store" + "github.com/crypt0rr/edgewatch/internal/store/storetest" +) + +// Finalizing a managed scan records what the comparison did: the samples +// that a job learns its baseline from, the sample that completes it, a +// comparison with an existing baseline, and a scan that did not complete. +// The recorded outcome does not change when the baseline does. +func TestFinalizeManagedScanRecordsComparisonOutcome(t *testing.T) { + ctx := context.Background() + db, err := store.Open(storetest.FreshPath(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + record, err := defaultTenant(db).CreateJob(ctx, config.NormalizeJob(config.Job{ + Name: "comparison-outcome", Schedule: "0 * * * *", Timezone: "UTC", Targets: []string{"192.0.2.1"}, + TCP: &config.Protocol{Ports: "80,443", Mode: "connect"}, Timing: "balanced", Timeout: config.Duration(time.Minute), + Baseline: config.Baseline{Samples: 2}, Change: config.Change{Confirmations: 1}, + })) + if err != nil { + t.Fatal(err) + } + e := Engine{Store: db} + finished := time.Now().UTC().Add(-time.Hour) + finalize := func(id, status string, snap model.Snapshot) model.Scan { + t.Helper() + finished = finished.Add(time.Minute) + current := scan(id, snap) + current.Status, current.StartedAt, current.FinishedAt = status, finished, finished + if status == "failed" { + current.Error = "scanner stopped" + } + current.JobID, current.JobRevision, current.Job = record.ID, record.Revision, record.Job.Name + current.ConfigHash = record.Job.SecurityHash() + if _, err := e.FinalizeManagedScan(ctx, record.ID, record.Job, ¤t, nil); err != nil { + t.Fatalf("finalize %s: %v", id, err) + } + stored, err := defaultTenant(db).GetScan(ctx, id) + if err != nil { + t.Fatal(err) + } + if stored.Comparison != current.Comparison { + t.Fatalf("stored %s comparison = %q, finalized %q", id, stored.Comparison, current.Comparison) + } + return stored + } + expect := func(id, comparison, baselineScanID string, changes int) { + t.Helper() + stored, err := defaultTenant(db).GetScan(ctx, id) + if err != nil { + t.Fatal(err) + } + if stored.Comparison != comparison || stored.BaselineScanID != baselineScanID || len(stored.Changes) != changes { + t.Fatalf("scan %s = comparison %q, baseline %q, %d changes; want %q, %q, %d", id, stored.Comparison, stored.BaselineScanID, len(stored.Changes), comparison, baselineScanID, changes) + } + } + + finalize("sample-1", "success", snapshot("open")) + expect("sample-1", model.ScanComparisonBaselineSample, "", 0) + finalize("sample-2", "success", snapshot("open")) + expect("sample-2", model.ScanComparisonBaselineEstablished, "sample-2", 0) + expect("sample-1", model.ScanComparisonBaselineSample, "", 0) + + opened := snapshot("open") + opened.Units[0].Ports = append(opened.Units[0].Ports, model.PortState{Port: 80, State: "open"}) + opened.Normalize() + finalize("compared", "success", opened) + expect("compared", model.ScanComparisonCompared, "sample-2", 1) + if stored := finalize("failed", "failed", model.Snapshot{}); stored.Comparison != model.ScanComparisonNotCompared { + t.Fatalf("failed scan comparison = %q", stored.Comparison) + } + + if _, err := defaultTenant(db).ResetRuntime(ctx, record.ID, record.Job.Name); err != nil { + t.Fatal(err) + } + incomplete := snapshot("open") + incomplete.TargetFailures = []model.TargetCoverageFailure{{Target: "192.0.2.1", Reason: "coverage did not complete"}} + if stored := finalize("incomplete-sample", "success", incomplete); stored.Status != "incomplete" { + t.Fatalf("incomplete sample status = %q", stored.Status) + } + expect("incomplete-sample", model.ScanComparisonBaselineSample, "", 0) + finalize("relearn-1", "success", opened) + expect("relearn-1", model.ScanComparisonBaselineSample, "", 0) + + // The reset changed none of the earlier outcomes. + expect("sample-1", model.ScanComparisonBaselineSample, "", 0) + expect("sample-2", model.ScanComparisonBaselineEstablished, "sample-2", 0) + expect("compared", model.ScanComparisonCompared, "sample-2", 1) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index debb5876..c4943204 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -64,7 +64,8 @@ func (e *Engine) FinalizeManagedScanWithOptions(ctx context.Context, jobID strin MarkIncompleteScan(current) } if current.Status == "success" || current.Status == "incomplete" { - if state.Baseline != nil { + hadBaseline := state.Baseline != nil + if hadBaseline { current.BaselineScanID = state.BaselineScanID current.BaselineConfigHash = state.BaselineConfigHash } @@ -86,6 +87,7 @@ func (e *Engine) FinalizeManagedScanWithOptions(ctx context.Context, jobID strin current.BaselineScanID = state.BaselineScanID current.BaselineConfigHash = state.BaselineConfigHash } + current.Comparison = comparisonOutcome(hadBaseline, state, current) if current.Resumable && current.CycleAttempt > 1 { return append(events, model.Event{Type: "scan-recovered", Job: scan.Job, ScanID: scan.ID, Message: "Resumable scan cycle completed after earlier paused attempts", CreatedAt: scan.FinishedAt}), nil } @@ -94,10 +96,28 @@ func (e *Engine) FinalizeManagedScanWithOptions(ctx context.Context, jobID strin // Failed, timed-out, and canceled scans never enter comparison state, // but every terminal non-success outcome is retained as an event so the // configured notification destinations can alert the operator. + current.Comparison = model.ScanComparisonNotCompared return processFailure(state, job.Name, *current) }) } +// comparisonOutcome names what finalizing a successful or incomplete scan +// did. A scan that finished while the job had a baseline was compared with +// it, even when the comparison found nothing. Without one, the scan was a +// baseline sample, or the sample that completed the baseline. Recording the +// outcome keeps a sample's history from being compared later with a +// baseline that did not exist when it ran. +func comparisonOutcome(hadBaseline bool, state *model.JobState, scan *model.Scan) string { + switch { + case hadBaseline: + return model.ScanComparisonCompared + case scan.Status == "success" && state.Baseline != nil && state.BaselineScanID == scan.ID: + return model.ScanComparisonBaselineEstablished + default: + return model.ScanComparisonBaselineSample + } +} + func processSuccess(state *model.JobState, job config.Job, scan model.Scan) ([]model.Event, error) { events, _, err := processSuccessWithChanges(state, job, scan) return events, err diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 19ab2ad6..1a7a3ffe 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1586,6 +1586,9 @@ func TestFinalizeManagedScanRecordsInitialBaselineScanMetadata(t *testing.T) { if stored.BaselineScanID != current.ID || stored.BaselineConfigHash != current.ConfigHash { t.Fatalf("initial baseline metadata = %#v, want scan=%s hash=%s", stored, current.ID, current.ConfigHash) } + if stored.Comparison != model.ScanComparisonBaselineEstablished { + t.Fatalf("initial baseline comparison = %q, want %q", stored.Comparison, model.ScanComparisonBaselineEstablished) + } if exists, err := defaultTenant(db).BaselineHostProjectionExists(ctx, record.ID); err != nil || !exists { t.Fatalf("automatic baseline did not maintain host projection: exists=%v err=%v", exists, err) } diff --git a/internal/model/model.go b/internal/model/model.go index fb7579a9..09f0c462 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -153,6 +153,29 @@ type Snapshot struct { TargetFailures []TargetCoverageFailure `json:"target_failures,omitempty"` } +// Scan comparison outcomes. A managed scan records one when it is finalized, +// so its history keeps the meaning it had when it ran after the baseline is +// established, changed, or reset. +const ( + // ScanComparisonLegacy is the empty value of a scan recorded before the + // outcome was stored. Only such a scan may be compared with the current + // baseline when it has no scan-time comparison. + ScanComparisonLegacy = "" + // ScanComparisonCompared marks a scan compared with the baseline that + // existed when it finished; its changes are the scan-time diff. + ScanComparisonCompared = "compared" + // ScanComparisonBaselineSample marks a scan that finished while the job + // had no baseline, so there was nothing to compare it with. + ScanComparisonBaselineSample = "baseline_sample" + // ScanComparisonBaselineEstablished marks the sample that completed the + // job's baseline. + ScanComparisonBaselineEstablished = "baseline_established" + // ScanComparisonNotCompared marks a scan that did not complete + // successfully, or whose result was kept without being compared, such as + // a result for a security scope that changed while it ran. + ScanComparisonNotCompared = "not_compared" +) + type Scan struct { ID string `json:"id"` JobID string `json:"job_id,omitempty"` @@ -186,10 +209,13 @@ type Scan struct { // BaselineScanID and BaselineConfigHash identify the comparison used when // this scan was processed. Changes is the immutable scan-time diff; list // endpoints intentionally use ScanSummary instead of loading it. - BaselineScanID string `json:"baseline_scan_id,omitempty"` - BaselineConfigHash string `json:"baseline_config_hash,omitempty"` - Changes []Change `json:"changes,omitempty"` - Snapshot Snapshot `json:"snapshot"` + BaselineScanID string `json:"baseline_scan_id,omitempty"` + BaselineConfigHash string `json:"baseline_config_hash,omitempty"` + // Comparison is the ScanComparison outcome recorded at finalization. + // It is empty for a scan recorded before the outcome was stored. + Comparison string `json:"comparison,omitempty"` + Changes []Change `json:"changes,omitempty"` + Snapshot Snapshot `json:"snapshot"` // Interrupted marks a canceled scan that stopped because the daemon // stopped, not because someone canceled it. It decides the scan's event // and is not stored. @@ -229,6 +255,7 @@ type ScanSummary struct { NoProgressTries int `json:"no_progress_attempts,omitempty"` BaselineScanID string `json:"baseline_scan_id,omitempty"` BaselineConfigHash string `json:"baseline_config_hash,omitempty"` + Comparison string `json:"comparison,omitempty"` // TenantID is the tenant that owns the scan. It is set by the store's // tenant-scoped reads and is never part of an API response. TenantID string `json:"-"` diff --git a/internal/store/baseline_export.go b/internal/store/baseline_export.go index 6f8596ae..fd68a7b2 100644 --- a/internal/store/baseline_export.go +++ b/internal/store/baseline_export.go @@ -288,8 +288,8 @@ func getScanSummaryForQuery(ctx context.Context, queryer exportQueryer, tenantID var jobID sql.NullString var revision sql.NullInt64 var resumable int - err := queryer.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash FROM scans WHERE id=? AND tenant_id=?`, id, tenantID). - Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash) + err := queryer.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison FROM scans WHERE id=? AND tenant_id=?`, id, tenantID). + Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison) if err != nil { return v, err } diff --git a/internal/store/finalize_paused_tenant_test.go b/internal/store/finalize_paused_tenant_test.go index 4f879d1b..4957ee3f 100644 --- a/internal/store/finalize_paused_tenant_test.go +++ b/internal/store/finalize_paused_tenant_test.go @@ -107,6 +107,10 @@ func TestFinalizeManagedScanRecordsAScanOfADisabledTenantAsCanceled(t *testing.T if got, err := storedScanOutcome(f.store, scan.ID); err != nil || got != "canceled|"+ScanCanceledByPauseMessage+"|" { t.Fatalf("stored scan = %q, %v; want the canceled scan", got, err) } + var comparison string + if err := f.store.DB.QueryRow(`SELECT comparison FROM scans WHERE id=?`, scan.ID).Scan(&comparison); err != nil || comparison != model.ScanComparisonNotCompared { + t.Fatalf("stored scan comparison = %q, %v; want %q", comparison, err, model.ScanComparisonNotCompared) + } if after := jobRuntimeDigest(t, f.store, f.jobB); after != before { t.Fatalf("the scan changed the disabled tenant's job:\nbefore\n%s\nafter\n%s", before, after) } diff --git a/internal/store/host_history.go b/internal/store/host_history.go index 92232646..2d0d3cc8 100644 --- a/internal/store/host_history.go +++ b/internal/store/host_history.go @@ -90,7 +90,7 @@ func jobScansPageQueries(tenantID, jobID string, limit, offset int) scanPageQuer return scanPageQueries{ countSQL: `SELECT COUNT(*) FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id)`, countArg: []any{tenantID, jobID}, - pageSQL: `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash,s.changes_json,s.snapshot_json FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT ? OFFSET ?`, + pageSQL: `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash,s.comparison,s.changes_json,s.snapshot_json FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT ? OFFSET ?`, pageArg: []any{tenantID, jobID, limit, offset}, } } @@ -282,7 +282,7 @@ func (ts *TenantStore) ListJobScansPage(ctx context.Context, jobID string, limit var snapshot, changesJSON []byte var baselineScanID, baselineConfigHash string var resumable int - if err := rows.Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &changesJSON, &snapshot); err != nil { + if err := rows.Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &v.Comparison, &changesJSON, &snapshot); err != nil { return page, err } v.Resumable = resumable != 0 @@ -321,7 +321,7 @@ func (ts *TenantStore) ListJobScanSummariesPage(ctx context.Context, jobID strin if err := readDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id)`, ts.scope.id, jobID).Scan(&page.Total); err != nil { return page, err } - rows, err := readDB.QueryContext(ctx, `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT ? OFFSET ?`, ts.scope.id, jobID, limit, offset) + rows, err := readDB.QueryContext(ctx, `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash,s.comparison FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT ? OFFSET ?`, ts.scope.id, jobID, limit, offset) if err != nil { return page, err } @@ -332,7 +332,7 @@ func (ts *TenantStore) ListJobScanSummariesPage(ctx context.Context, jobID strin var revision sql.NullInt64 var started, finished string var resumable int - if err := rows.Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash); err != nil { + if err := rows.Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison); err != nil { return page, err } v.Resumable = resumable != 0 diff --git a/internal/store/legacy_history.go b/internal/store/legacy_history.go index fe298a21..036e0c69 100644 --- a/internal/store/legacy_history.go +++ b/internal/store/legacy_history.go @@ -77,8 +77,8 @@ func getScanTx(ctx context.Context, tx *sql.Tx, id string) (model.Scan, error) { var jobID sql.NullString var revision sql.NullInt64 var resumable int - err := tx.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,changes_json,snapshot_json FROM scans WHERE id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id). - Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &changesJSON, &snapshot) + err := tx.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison,changes_json,snapshot_json FROM scans WHERE id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id). + Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &v.Comparison, &changesJSON, &snapshot) if err != nil { return v, err } @@ -121,7 +121,7 @@ func (ts *TenantStore) ListScansPage(ctx context.Context, job string, limit, off limit, offset = normalizePage(limit, offset) var page Page[model.Scan] readDB := ts.store.reader() - query := `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,changes_json,snapshot_json FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` + query := `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison,changes_json,snapshot_json FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` countQuery := `SELECT COUNT(*) FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` args := []any{ts.scope.id} countArgs := []any{ts.scope.id} @@ -149,7 +149,7 @@ func (ts *TenantStore) ListScansPage(ctx context.Context, job string, limit, off var snapshot, changesJSON []byte var baselineScanID, baselineConfigHash string var resumable int - if err := rows.Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &changesJSON, &snapshot); err != nil { + if err := rows.Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &v.Comparison, &changesJSON, &snapshot); err != nil { return page, err } v.Resumable = resumable != 0 @@ -186,7 +186,7 @@ func (ts *TenantStore) ListScanSummariesPage(ctx context.Context, job string, li limit, offset = normalizePage(limit, offset) var page Page[model.ScanSummary] readDB := ts.store.reader() - query := `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` + query := `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` countQuery := `SELECT COUNT(*) FROM scans WHERE tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)` args := []any{ts.scope.id} countArgs := []any{ts.scope.id} @@ -212,7 +212,7 @@ func (ts *TenantStore) ListScanSummariesPage(ctx context.Context, job string, li var revision sql.NullInt64 var started, finished string var resumable int - if err := rows.Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash); err != nil { + if err := rows.Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison); err != nil { return page, err } v.Resumable = resumable != 0 diff --git a/internal/store/migration65.go b/internal/store/migration65.go new file mode 100644 index 00000000..4b726092 --- /dev/null +++ b/internal/store/migration65.go @@ -0,0 +1,19 @@ +package store + +import "database/sql" + +// migration65ConditionalStatements adds the comparison outcome that a managed +// scan records when it is finalized: compared, baseline_sample, +// baseline_established, or not_compared. Existing rows keep the empty value, +// which marks them as recorded before the outcome was stored, so only they +// keep the compatibility comparison with the current baseline. The values are +// validated in Go rather than by a CHECK constraint, because changing such a +// constraint later would require rebuilding the scans table. The column may +// already exist in a test database reconstructed from a newer template. +func migration65ConditionalStatements(tx *sql.Tx) ([]string, error) { + exists, err := migrationColumnExists(tx, "scans", "comparison") + if err != nil || exists { + return nil, err + } + return []string{`ALTER TABLE scans ADD COLUMN comparison TEXT NOT NULL DEFAULT ''`}, nil +} diff --git a/internal/store/migration65_test.go b/internal/store/migration65_test.go new file mode 100644 index 00000000..be678fb1 --- /dev/null +++ b/internal/store/migration65_test.go @@ -0,0 +1,217 @@ +package store + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/crypt0rr/edgewatch/internal/model" +) + +// Schema 65 adds the comparison outcome to scans. A scan recorded before it +// keeps the empty legacy value, so its history keeps the compatibility +// comparison, and a repeated upgrade with the column present is harmless. +func TestMigration65AddsScanComparisonAndKeepsLegacyRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := openTestStore(t) + record, err := defaultTenant(s).CreateJob(ctx, testJob("migration-65")) + if err != nil { + t.Fatal(err) + } + if _, err := s.DB.Exec(`ALTER TABLE scans DROP COLUMN comparison`); err != nil { + t.Fatal(err) + } + stamp := sqliteTimestamp(time.Now()) + for _, row := range []struct{ id, baselineScanID string }{ + {id: "schema-64-sample"}, + {id: "schema-64-compared", baselineScanID: "schema-64-sample"}, + } { + if _, err := s.DB.Exec(`INSERT INTO scans(id,job_id,job_revision,job,started_at,finished_at,status,config_hash,snapshot_json,baseline_scan_id,baseline_config_hash,tenant_id) VALUES(?,?,?,?,?,?,'success',?,'{}',?,?,?)`, + row.id, record.ID, record.Revision, record.Job.Name, stamp, stamp, record.Job.SecurityHash(), row.baselineScanID, "", DefaultTenantID); err != nil { + t.Fatal(err) + } + } + if _, err := s.DB.Exec(`PRAGMA user_version=64`); err != nil { + t.Fatal(err) + } + path := s.Path + if err := s.Close(); err != nil { + t.Fatal(err) + } + + upgraded, err := Open(path) + if err != nil { + t.Fatalf("upgrade from schema 64: %v", err) + } + if version := countRows(t, upgraded.DB, `PRAGMA user_version`); version != schemaVersion { + t.Fatalf("schema version = %d, want %d", version, schemaVersion) + } + var columnType string + var notNull int + var defaultValue sql.NullString + if err := upgraded.DB.QueryRow(`SELECT type,"notnull",dflt_value FROM pragma_table_info('scans') WHERE name='comparison'`).Scan(&columnType, ¬Null, &defaultValue); err != nil { + t.Fatalf("comparison column: %v", err) + } + if columnType != "TEXT" || notNull != 1 || defaultValue.String != "''" { + t.Fatalf("comparison column = %s not null %d default %v; want TEXT NOT NULL DEFAULT ''", columnType, notNull, defaultValue) + } + for _, id := range []string{"schema-64-sample", "schema-64-compared"} { + summary, err := defaultTenant(upgraded).GetScanSummary(ctx, id) + if err != nil || summary.Comparison != model.ScanComparisonLegacy { + t.Fatalf("upgraded scan %s comparison = %q, %v; want the legacy marker", id, summary.Comparison, err) + } + } + + // A test database reconstructed from a newer template already has the + // column, and the upgrade leaves it alone. + if _, err := upgraded.DB.Exec(`PRAGMA user_version=64`); err != nil { + t.Fatal(err) + } + if err := upgraded.Close(); err != nil { + t.Fatal(err) + } + again, err := Open(path) + if err != nil { + t.Fatalf("repeated upgrade from schema 64: %v", err) + } + t.Cleanup(func() { _ = again.Close() }) + if version := countRows(t, again.DB, `PRAGMA user_version`); version != schemaVersion { + t.Fatalf("repeated upgrade schema version = %d, want %d", version, schemaVersion) + } + if columns := countRows(t, again.DB, `SELECT COUNT(*) FROM pragma_table_info('scans') WHERE name='comparison'`); columns != 1 { + t.Fatalf("comparison columns after a repeated upgrade = %d, want 1", columns) + } +} + +// Every read of a tenant's scans returns the recorded comparison outcome, +// and a backup and its restore keep it. +func TestScanComparisonRoundTripsThroughReadsBackupAndRestore(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := openTestStore(t) + record, err := defaultTenant(s).CreateJob(ctx, testJob("comparison-round-trip")) + if err != nil { + t.Fatal(err) + } + base := time.Now().UTC().Add(-time.Hour) + want := map[string]string{} + for i, comparison := range []string{ + model.ScanComparisonLegacy, + model.ScanComparisonBaselineSample, + model.ScanComparisonBaselineEstablished, + model.ScanComparisonCompared, + model.ScanComparisonNotCompared, + } { + id := "comparison-" + comparison + if comparison == model.ScanComparisonLegacy { + id = "comparison-legacy" + } + status := "success" + if comparison == model.ScanComparisonNotCompared { + status = "failed" + } + finished := base.Add(time.Duration(i) * time.Minute) + scan := model.Scan{ID: id, JobID: record.ID, JobRevision: record.Revision, Job: record.Job.Name, StartedAt: finished, FinishedAt: finished, Status: status, ConfigHash: record.Job.SecurityHash(), Comparison: comparison} + if err := s.System().SaveScan(ctx, scan); err != nil { + t.Fatal(err) + } + want[id] = comparison + } + ts := defaultTenant(s) + check := func(source, id, got string) { + t.Helper() + if expected, ok := want[id]; !ok || got != expected { + t.Fatalf("%s scan %s comparison = %q, want %q", source, id, got, expected) + } + } + for id := range want { + scan, err := ts.GetScan(ctx, id) + if err != nil { + t.Fatal(err) + } + check("GetScan", id, scan.Comparison) + summary, err := ts.GetScanSummary(ctx, id) + if err != nil { + t.Fatal(err) + } + check("GetScanSummary", id, summary.Comparison) + compared, _, err := ts.GetScanComparison(ctx, id) + if err != nil { + t.Fatal(err) + } + check("GetScanComparison", id, compared.Comparison) + exported, err := getScanSummaryForQuery(ctx, s.DB, DefaultTenantID, id) + if err != nil { + t.Fatal(err) + } + check("getScanSummaryForQuery", id, exported.Comparison) + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + inTx, err := getScanTx(ctx, tx, id) + _ = tx.Rollback() + if err != nil { + t.Fatal(err) + } + check("getScanTx", id, inTx.Comparison) + } + latest, err := ts.GetLatestSuccessfulJobScanSummary(ctx, record.ID) + if err != nil || latest == nil { + t.Fatalf("latest successful scan = %v, %v", latest, err) + } + check("GetLatestSuccessfulJobScanSummary", latest.ID, latest.Comparison) + jobScans, err := ts.ListJobScansPage(ctx, record.ID, 10, 0) + if err != nil || len(jobScans.Items) != len(want) { + t.Fatalf("job scans = %d, %v", len(jobScans.Items), err) + } + for _, scan := range jobScans.Items { + check("ListJobScansPage", scan.ID, scan.Comparison) + } + jobSummaries, err := ts.ListJobScanSummariesPage(ctx, record.ID, 10, 0) + if err != nil || len(jobSummaries.Items) != len(want) { + t.Fatalf("job scan summaries = %d, %v", len(jobSummaries.Items), err) + } + for _, summary := range jobSummaries.Items { + check("ListJobScanSummariesPage", summary.ID, summary.Comparison) + } + scans, err := ts.ListScansPage(ctx, record.Job.Name, 10, 0) + if err != nil || len(scans.Items) != len(want) { + t.Fatalf("scans = %d, %v", len(scans.Items), err) + } + for _, scan := range scans.Items { + check("ListScansPage", scan.ID, scan.Comparison) + } + summaries, err := ts.ListScanSummariesPage(ctx, record.Job.Name, 10, 0) + if err != nil || len(summaries.Items) != len(want) { + t.Fatalf("scan summaries = %d, %v", len(summaries.Items), err) + } + for _, summary := range summaries.Items { + check("ListScanSummariesPage", summary.ID, summary.Comparison) + } + + dir := t.TempDir() + backup, err := s.Backup(ctx, filepath.Join(dir, "backup.db")) + if err != nil { + t.Fatal(err) + } + destination := freshTestDatabasePath(t) + if _, err := Restore(ctx, backup, destination, RestoreOptions{}); err != nil { + t.Fatalf("restore: %v", err) + } + restored, err := OpenReadOnlyExisting(destination) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = restored.Close() }) + for id := range want { + summary, err := restored.Tenant(DefaultTenantScope()).GetScanSummary(ctx, id) + if err != nil { + t.Fatal(err) + } + check("restored GetScanSummary", id, summary.Comparison) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 63448389..cde7c2f4 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS job_leases ( // schemaVersion is deliberately independent from the configuration version. // The former describes on-disk compatibility; the latter describes YAML. -const schemaVersion = 64 +const schemaVersion = 65 // foreignKeysOffMigrations lists the schema versions that must run through // applyMigrationForeignKeysOff because they rebuild a table that other tables @@ -75,6 +75,9 @@ var conditionalMigrationStatements = map[int]func(*sql.Tx) ([]string, error){ // Schema 61 distinguishes inherited cadence defaults from saved cadence // preferences while migrating legacy every-scan defaults safely. 61: migration61ConditionalStatements, + // Schema 65's comparison column may already exist in a reconstructed + // test DB. + 65: migration65ConditionalStatements, } // newerSchemaError is the refusal for a database that a newer release has @@ -1274,6 +1277,10 @@ ON CONFLICT(table_name) DO UPDATE SET last_rowid=0,processed_rows=0,initialized= // Expired restore quarantine entries by retention timestamp without // rescanning the full quarantine table for each bounded delete batch. 64: migration64Statements(), + // Scans record their comparison outcome at finalization, so baseline + // samples are no longer reported as failed or diffed against a later + // baseline. See migration65.go. + 65: {}, } // Mark the complete startup reconciliation as active, not only the DDL // steps. FTS and other resumable backfills can be the longest part of an diff --git a/internal/store/runtime.go b/internal/store/runtime.go index 1014dc34..a0e4b70a 100644 --- a/internal/store/runtime.go +++ b/internal/store/runtime.go @@ -605,7 +605,8 @@ func migrateLegacyScopeHashTx(ctx context.Context, tx *sql.Tx, jobID, legacyHash // database connection is held, so baseline reset/approval cannot slip between // comparison capture and the state transition. If the job's security scope // changed while the scanner was running, the scan is retained as immutable -// history but the runtime state is left untouched. It is the daemon's writer +// history, recorded as not compared, but the runtime state is left +// untouched. It is the daemon's writer // and reaches a job of any tenant; the scan takes the job's tenant. // // The job's tenant must still be active. A scan that finishes after its @@ -698,6 +699,9 @@ func (ss *SystemStore) FinalizeManagedScanWithOptions(ctx context.Context, scan return nil, err } if job.SecurityHash() != securityHash { + // The result belongs to a superseded security scope, so it is kept + // as history without a comparison. + scan.Comparison = model.ScanComparisonNotCompared if err := saveScanExec(ctx, tx, *scan); err != nil { return nil, err } @@ -723,6 +727,7 @@ func (ss *SystemStore) FinalizeManagedScanWithOptions(ctx context.Context, scan } cycleNotResumable := cycleStatus == "discarded" || cycleStatus == "expired" || cycleEpoch != currentEpoch if cycleNotResumable && (scan.Status == "success" || scan.Status == "incomplete") { + scan.Comparison = model.ScanComparisonNotCompared if err := saveScanExec(ctx, tx, *scan); err != nil { return nil, err } @@ -940,6 +945,7 @@ const ScanCanceledByPauseMessage = "scan canceled: the business unit was paused // refuses its rows. scan is updated to the outcome of the pause. func recordScanOfPausedTenantTx(ctx context.Context, tx *sql.Tx, scan *model.Scan, tenantState string) error { discardCycle := false + scan.Comparison = model.ScanComparisonNotCompared if scan.Status == "success" || scan.Status == "incomplete" { scan.Status = "canceled" scan.Error = ScanCanceledByPauseMessage diff --git a/internal/store/runtime_coverage_extra_test.go b/internal/store/runtime_coverage_extra_test.go index 2230f826..81cd007a 100644 --- a/internal/store/runtime_coverage_extra_test.go +++ b/internal/store/runtime_coverage_extra_test.go @@ -146,7 +146,7 @@ func TestFinalizeManagedScanRejectsStaleCycleAfterSavingHistory(t *testing.T) { t.Fatalf("stale cycle finalization error = %v", err) } stored, err := defaultTenant(s).GetScan(ctx, scan.ID) - if err != nil || stored.Status != "success" { + if err != nil || stored.Status != "success" || stored.Comparison != model.ScanComparisonNotCompared { t.Fatalf("stale scan history = %#v, %v", stored, err) } } diff --git a/internal/store/scan_history.go b/internal/store/scan_history.go index edb33f34..0f2d1cb5 100644 --- a/internal/store/scan_history.go +++ b/internal/store/scan_history.go @@ -78,8 +78,8 @@ func saveScanExec(ctx context.Context, execer contextExecer, scan model.Scan) er return err } // The scan belongs to its job's tenant, read in the same transaction. - _, err = execer.ExecContext(ctx, `INSERT INTO scans(id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,changes_json,snapshot_json,tenant_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,`+jobTenantSQL+`)`, - scan.ID, nullString(scan.JobID), nullInt64(scan.JobRevision), scan.Job, sqliteTimestamp(scan.StartedAt), sqliteTimestamp(scan.FinishedAt), scan.Status, scan.Error, scan.NmapVersion, scan.ScannerEngine, scan.ScannerProfileID, scan.ScannerProfileRevision, scan.NaabuVersion, scan.DiscoveryPorts, scan.ConfirmedPorts, scan.DiscoveryDurationMS, scan.EnrichmentDurationMS, scan.ConfigHash, scan.CycleID, scan.CycleAttempt, scan.CycleStatus, boolInt(scan.Resumable), scan.CompletedProbes, scan.TotalProbes, scan.CompletedUnits, scan.TotalUnits, scan.NoProgressTries, scan.BaselineScanID, scan.BaselineConfigHash, changesJSON, snapshot, scan.JobID) + _, err = execer.ExecContext(ctx, `INSERT INTO scans(id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison,changes_json,snapshot_json,tenant_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,`+jobTenantSQL+`)`, + scan.ID, nullString(scan.JobID), nullInt64(scan.JobRevision), scan.Job, sqliteTimestamp(scan.StartedAt), sqliteTimestamp(scan.FinishedAt), scan.Status, scan.Error, scan.NmapVersion, scan.ScannerEngine, scan.ScannerProfileID, scan.ScannerProfileRevision, scan.NaabuVersion, scan.DiscoveryPorts, scan.ConfirmedPorts, scan.DiscoveryDurationMS, scan.EnrichmentDurationMS, scan.ConfigHash, scan.CycleID, scan.CycleAttempt, scan.CycleStatus, boolInt(scan.Resumable), scan.CompletedProbes, scan.TotalProbes, scan.CompletedUnits, scan.TotalUnits, scan.NoProgressTries, scan.BaselineScanID, scan.BaselineConfigHash, scan.Comparison, changesJSON, snapshot, scan.JobID) if err != nil { return err } @@ -449,8 +449,8 @@ func (ts *TenantStore) GetScan(ctx context.Context, id string) (model.Scan, erro var revision sql.NullInt64 var resumable int readDB := ts.store.reader() - err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,changes_json,snapshot_json FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). - Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &changesJSON, &snapshot) + err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison,changes_json,snapshot_json FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). + Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &baselineScanID, &baselineConfigHash, &v.Comparison, &changesJSON, &snapshot) if errors.Is(err, sql.ErrNoRows) { return model.Scan{}, scanNotFound(id) } @@ -492,8 +492,8 @@ func (ts *TenantStore) GetScanSummary(ctx context.Context, id string) (model.Sca var revision sql.NullInt64 var resumable int readDB := ts.store.reader() - err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). - Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash) + err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). + Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison) if errors.Is(err, sql.ErrNoRows) { return model.ScanSummary{}, scanNotFound(id) } @@ -527,8 +527,8 @@ func (ts *TenantStore) GetLatestSuccessfulJobScanSummary(ctx context.Context, jo var jid sql.NullString var revision sql.NullInt64 var resumable int - err := ts.store.reader().QueryRowContext(ctx, `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND s.status='success' AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT 1`, ts.scope.id, jobID). - Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash) + err := ts.store.reader().QueryRowContext(ctx, `SELECT s.id,s.job_id,s.job_revision,s.job,s.started_at,s.finished_at,s.status,s.error,s.nmap_version,s.scanner_engine,s.scanner_profile_id,s.scanner_profile_revision,s.naabu_version,s.discovery_ports,s.confirmed_ports,s.discovery_duration_ms,s.enrichment_duration_ms,s.config_hash,s.cycle_id,s.cycle_attempt,s.cycle_status,s.resumable,s.completed_probes,s.total_probes,s.completed_units,s.total_units,s.no_progress_attempts,s.baseline_scan_id,s.baseline_config_hash,s.comparison FROM scans s JOIN jobs j ON j.id=s.job_id AND j.tenant_id=? WHERE s.job_id=? AND s.status='success' AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=s.tenant_id AND purge.job_id=s.job_id) ORDER BY s.finished_at DESC,s.id DESC LIMIT 1`, ts.scope.id, jobID). + Scan(&v.ID, &jid, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -564,8 +564,8 @@ func (ts *TenantStore) GetScanComparison(ctx context.Context, id string) (model. var revision sql.NullInt64 var resumable int readDB := ts.store.reader() - err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,changes_json FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). - Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &changesJSON) + err := readDB.QueryRowContext(ctx, `SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,nmap_version,scanner_engine,scanner_profile_id,scanner_profile_revision,naabu_version,discovery_ports,confirmed_ports,discovery_duration_ms,enrichment_duration_ms,config_hash,cycle_id,cycle_attempt,cycle_status,resumable,completed_probes,total_probes,completed_units,total_units,no_progress_attempts,baseline_scan_id,baseline_config_hash,comparison,changes_json FROM scans WHERE id=? AND tenant_id=? AND NOT EXISTS (SELECT 1 FROM job_history_purges AS purge WHERE purge.tenant_id=scans.tenant_id AND purge.job_id=scans.job_id)`, id, ts.scope.id). + Scan(&v.ID, &jobID, &revision, &v.Job, &started, &finished, &v.Status, &v.Error, &v.NmapVersion, &v.ScannerEngine, &v.ScannerProfileID, &v.ScannerProfileRevision, &v.NaabuVersion, &v.DiscoveryPorts, &v.ConfirmedPorts, &v.DiscoveryDurationMS, &v.EnrichmentDurationMS, &v.ConfigHash, &v.CycleID, &v.CycleAttempt, &v.CycleStatus, &resumable, &v.CompletedProbes, &v.TotalProbes, &v.CompletedUnits, &v.TotalUnits, &v.NoProgressTries, &v.BaselineScanID, &v.BaselineConfigHash, &v.Comparison, &changesJSON) if errors.Is(err, sql.ErrNoRows) { return model.ScanSummary{}, nil, scanNotFound(id) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 77e3449a..975a60f4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -928,8 +928,12 @@ func TestFinalizeManagedScanRetainsSupersededScan(t *testing.T) { if !errors.Is(err, ErrJobRevisionChanged) { t.Fatalf("expected superseded revision error, got %v", err) } - if _, err := defaultTenant(s).GetScan(ctx, scan.ID); err != nil { + if stored, err := defaultTenant(s).GetScan(ctx, scan.ID); err != nil { t.Fatalf("superseded scan was not retained: %v", err) + } else if stored.Comparison != model.ScanComparisonNotCompared { + // A legacy marker would let the console diff it against the new + // scope's baseline. + t.Fatalf("superseded scan comparison = %q, want %q", stored.Comparison, model.ScanComparisonNotCompared) } state, err := defaultTenant(s).RuntimeState(ctx, record.ID) if err != nil { diff --git a/internal/web/resource_routes_test.go b/internal/web/resource_routes_test.go index c808247d..92a51add 100644 --- a/internal/web/resource_routes_test.go +++ b/internal/web/resource_routes_test.go @@ -84,7 +84,7 @@ func (l *resourceLookups) observe(query string, args []driver.NamedValue) error if l.failJobs { return errInjectedLookup } - case strings.Contains(query, "baseline_config_hash FROM scans WHERE id=? AND tenant_id=?") && strings.HasPrefix(query, "SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,"): + case strings.Contains(query, "baseline_config_hash,comparison FROM scans WHERE id=? AND tenant_id=?") && strings.HasPrefix(query, "SELECT id,job_id,job_revision,job,started_at,finished_at,status,error,"): l.scanSummaries[id]++ if l.failSummaries { return errInjectedLookup diff --git a/internal/web/scan_comparison_state_test.go b/internal/web/scan_comparison_state_test.go new file mode 100644 index 00000000..e8efc6c0 --- /dev/null +++ b/internal/web/scan_comparison_state_test.go @@ -0,0 +1,170 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/engine" + "github.com/crypt0rr/edgewatch/internal/model" + "github.com/crypt0rr/edgewatch/internal/store" +) + +// scanComparisonResponse is the part of the scan detail and scan changes +// responses that describes the comparison. +type scanComparisonResponse struct { + State string `json:"comparison_state"` + Source string `json:"comparison_source"` + BaselineScanID string `json:"baseline_scan_id"` + Changes []model.Change `json:"changes"` +} + +// A job that learns its baseline from two samples reports each scan's +// comparison as it was when the scan finished (#1224). The first sample is a +// baseline sample, not a failed scan, and stays one after the baseline is +// established, an incident is accepted, and the baseline is reset; the +// second sample established the baseline; a later scan was compared at scan +// time; and a failed scan was not compared. Only a scan recorded before the +// outcome was stored is still compared with the current baseline. The scan +// detail and its changes report the same comparison. +func TestJobScanComparisonStateIsRecordedAtScanTime(t *testing.T) { + t.Parallel() + ctx := context.Background() + server, db, admin := newUsersTestServer(t) + ts := defaultTenantStore(server) + job := config.NormalizeJob(config.Job{ + Name: "comparison-state", Schedule: "0 * * * *", Timezone: "UTC", + Targets: []string{"192.0.2.10"}, TCP: &config.Protocol{Ports: "80,443", Mode: "connect"}, + Timing: "balanced", Timeout: config.Duration(time.Minute), + Baseline: config.Baseline{Samples: 2}, Change: config.Change{Confirmations: 1}, + }) + record, err := defaultTenant(db).CreateJob(ctx, job) + if err != nil { + t.Fatal(err) + } + snapshotWith := func(ports ...int) model.Snapshot { + unit := model.Unit{Target: "192.0.2.10", Protocol: "tcp", Addresses: []string{"192.0.2.10"}} + for _, port := range ports { + unit.Ports = append(unit.Ports, model.PortState{Port: port, State: "open", Evidence: []string{"192.0.2.10"}}) + } + snapshot := model.Snapshot{Scopes: []model.Scope{{Target: "192.0.2.10", Protocol: "tcp", Ports: "80,443"}}, Units: []model.Unit{unit}} + snapshot.Normalize() + return snapshot + } + finished := time.Now().UTC().Add(-time.Hour) + newScan := func(id, status string, snapshot model.Snapshot) model.Scan { + finished = finished.Add(time.Minute) + scan := model.Scan{ID: id, JobID: record.ID, JobRevision: record.Revision, Job: job.Name, StartedAt: finished, FinishedAt: finished, Status: status, ConfigHash: record.Job.SecurityHash(), Snapshot: snapshot} + if status == "failed" { + scan.Error = "scanner stopped" + } + return scan + } + finalizer := &engine.Engine{Store: db} + finalize := func(id, status string, snapshot model.Snapshot) []model.Event { + t.Helper() + scan := newScan(id, status, snapshot) + events, err := finalizer.FinalizeManagedScan(ctx, record.ID, record.Job, &scan, nil) + if err != nil { + t.Fatalf("finalize %s: %v", id, err) + } + return events + } + get := func(path string) scanComparisonResponse { + t.Helper() + response := httptest.NewRecorder() + server.jobRoute(response, scanHandlerRequest(http.MethodGet, "/scan?limit=50", ""), admin, ts, record.ID+"/scans/"+path) + if response.Code != http.StatusOK { + t.Fatalf("%s = %d: %s", path, response.Code, response.Body.String()) + } + var decoded scanComparisonResponse + if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + return decoded + } + expect := func(when, id, state, source, baselineScanID string, changes int) { + t.Helper() + for _, path := range []string{id, id + "/changes"} { + got := get(path) + if got.State != state || got.Source != source || got.BaselineScanID != baselineScanID || len(got.Changes) != changes { + t.Fatalf("%s: %s = state %q, source %q, baseline %q, %d changes %v; want %q, %q, %q, %d", when, path, got.State, got.Source, got.BaselineScanID, len(got.Changes), got.Changes, state, source, baselineScanID, changes) + } + } + } + + finalize("sample-1", "success", snapshotWith(80)) + expect("while learning", "sample-1", model.ScanComparisonBaselineSample, "none", "", 0) + + if events := finalize("sample-2", "success", snapshotWith(80)); len(events) != 1 || events[0].Type != "baseline-complete" { + t.Fatalf("completing sample events = %#v", events) + } + expect("after the baseline is established", "sample-2", model.ScanComparisonBaselineEstablished, "none", "sample-2", 0) + expect("after the baseline is established", "sample-1", model.ScanComparisonBaselineSample, "none", "", 0) + + finalize("opened", "success", snapshotWith(80, 443)) + expect("after a change", "opened", model.ScanComparisonCompared, "scan_time", "sample-2", 1) + key := get("opened").Changes[0].Key + if _, err := defaultTenant(db).AcceptIncidentWithAudit(ctx, record.ID, job.Name, key, store.AuditEntry{}); err != nil { + t.Fatalf("accept %s: %v", key, err) + } + expect("after accepting the incident", "sample-1", model.ScanComparisonBaselineSample, "none", "", 0) + expect("after accepting the incident", "opened", model.ScanComparisonCompared, "scan_time", "sample-2", 1) + + // A row recorded before the outcome was stored, with no scan-time + // comparison, is still compared with the current baseline. + legacy := newScan("legacy", "success", snapshotWith(80)) + if err := db.System().SaveScan(ctx, legacy); err != nil { + t.Fatal(err) + } + expect("for a legacy row", "legacy", model.ScanComparisonCompared, "current_baseline_legacy", "", 1) + + finalize("failed", "failed", model.Snapshot{}) + expect("for a failed scan", "failed", model.ScanComparisonNotCompared, "none", "", 0) + + if _, err := defaultTenant(db).ResetRuntime(ctx, record.ID, job.Name); err != nil { + t.Fatal(err) + } + expect("after a baseline reset", "sample-1", model.ScanComparisonBaselineSample, "none", "", 0) + expect("after a baseline reset", "sample-2", model.ScanComparisonBaselineEstablished, "none", "sample-2", 0) + expect("after a baseline reset", "opened", model.ScanComparisonCompared, "scan_time", "sample-2", 1) + expect("for a legacy row without a baseline", "legacy", model.ScanComparisonNotCompared, "none", "", 0) + finalize("relearn-1", "success", snapshotWith(80, 443)) + expect("while relearning", "relearn-1", model.ScanComparisonBaselineSample, "none", "", 0) +} + +// The recorded outcome decides the reported comparison of a successful or +// incomplete scan. A scan that did not complete is never compared, and a +// value this release does not know is reported as not compared instead of +// falling back to the current baseline. +func TestResolveScanComparison(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + summary model.ScanSummary + want scanComparisonView + }{ + {name: "compared", summary: model.ScanSummary{Status: "success", Comparison: model.ScanComparisonCompared}, want: scanComparisonView{state: "compared", source: "scan_time", scanTime: true}}, + {name: "incomplete compared", summary: model.ScanSummary{Status: "incomplete", Comparison: model.ScanComparisonCompared, BaselineScanID: "baseline"}, want: scanComparisonView{state: "compared", source: "scan_time", scanTime: true}}, + {name: "baseline sample", summary: model.ScanSummary{Status: "success", Comparison: model.ScanComparisonBaselineSample}, want: scanComparisonView{state: "baseline_sample", source: "none"}}, + {name: "incomplete baseline sample", summary: model.ScanSummary{Status: "incomplete", Comparison: model.ScanComparisonBaselineSample}, want: scanComparisonView{state: "baseline_sample", source: "none"}}, + {name: "baseline established", summary: model.ScanSummary{Status: "success", Comparison: model.ScanComparisonBaselineEstablished, BaselineScanID: "scan"}, want: scanComparisonView{state: "baseline_established", source: "none"}}, + {name: "kept without a comparison", summary: model.ScanSummary{Status: "success", Comparison: model.ScanComparisonNotCompared}, want: scanComparisonView{state: "not_compared", source: "none"}}, + {name: "failed", summary: model.ScanSummary{Status: "failed", Comparison: model.ScanComparisonCompared, BaselineScanID: "baseline"}, want: scanComparisonView{state: "not_compared", source: "none"}}, + {name: "legacy canceled", summary: model.ScanSummary{Status: "canceled"}, want: scanComparisonView{state: "not_compared", source: "none"}}, + {name: "legacy with a scan-time comparison", summary: model.ScanSummary{Status: "success", BaselineConfigHash: "hash"}, want: scanComparisonView{state: "compared", source: "scan_time", scanTime: true}}, + {name: "legacy without a scan-time comparison", summary: model.ScanSummary{Status: "success"}, want: scanComparisonView{state: "not_compared", source: "none", legacy: true}}, + {name: "unknown outcome", summary: model.ScanSummary{Status: "success", Comparison: "future_outcome"}, want: scanComparisonView{state: "not_compared", source: "none"}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := resolveScanComparison(test.summary); got != test.want { + t.Fatalf("resolveScanComparison(%+v) = %+v, want %+v", test.summary, got, test.want) + } + }) + } +} diff --git a/internal/web/scan_handlers.go b/internal/web/scan_handlers.go index ae727400..4f80c512 100644 --- a/internal/web/scan_handlers.go +++ b/internal/web/scan_handlers.go @@ -670,6 +670,63 @@ func (s *Server) jobScans(w http.ResponseWriter, r *http.Request, ts *store.Tena writeJSON(w, 200, map[string]any{"scans": page.Items, "pagination": paginationJSON(offset, limit, page.Total)}) } +// scanComparisonView is how the job scan endpoints report a scan's +// comparison: its comparison_state and comparison_source, and where its +// changes come from. +type scanComparisonView struct { + state string + source string + // scanTime reads the change list stored with the scan. + scanTime bool + // legacy compares the scan with the job's current baseline, when there + // is one. Only rows recorded before scans stored their outcome use it. + legacy bool +} + +// resolveScanComparison reads the comparison outcome that was recorded when +// the scan was finalized. A baseline sample, and the sample that established +// the baseline, keep that meaning after the baseline is established, +// changed, or reset. Only a row without a recorded outcome or a scan-time +// comparison falls back to the current baseline, as releases before schema +// 65 did. +func resolveScanComparison(summary model.ScanSummary) scanComparisonView { + notCompared := scanComparisonView{state: model.ScanComparisonNotCompared, source: "none"} + if summary.Status != "success" && summary.Status != "incomplete" { + return notCompared + } + switch summary.Comparison { + case model.ScanComparisonCompared: + return scanComparisonView{state: model.ScanComparisonCompared, source: "scan_time", scanTime: true} + case model.ScanComparisonBaselineSample, model.ScanComparisonBaselineEstablished: + return scanComparisonView{state: summary.Comparison, source: "none"} + case model.ScanComparisonLegacy: + if summary.BaselineScanID != "" || summary.BaselineConfigHash != "" { + return scanComparisonView{state: model.ScanComparisonCompared, source: "scan_time", scanTime: true} + } + notCompared.legacy = true + return notCompared + default: + return notCompared + } +} + +// legacyScanChanges compares a legacy scan with the job's current baseline. +// It reports false when the job has no baseline, which leaves the scan not +// compared. +func legacyScanChanges(ctx context.Context, ts *store.TenantStore, jobID string, summary model.ScanSummary) ([]model.Change, bool, error) { + state, err := ts.RuntimeState(ctx, jobID) + if err != nil || state.Baseline == nil { + return nil, false, err + } + // Only this compatibility path needs the full snapshot. Managed scans + // carry their immutable comparison in changes_json. + scan, err := ts.GetScan(ctx, summary.ID) + if err != nil { + return nil, false, err + } + return engine.Diff(*state.Baseline, scan.Snapshot, state.BaselineConfigHash != summary.ConfigHash), true, nil +} + func (s *Server) jobScan(w http.ResponseWriter, r *http.Request, ts *store.TenantStore, record store.JobRecord, summary model.ScanSummary) { id, scanID := record.ID, summary.ID offset, ok := requestOffset(w, r) @@ -677,48 +734,33 @@ func (s *Server) jobScan(w http.ResponseWriter, r *http.Request, ts *store.Tenan return } limit := queryLimit(r) - comparisonState := "not_compared" - value := map[string]any{"scan": summary, "changes": []model.Change{}, "changes_pagination": paginationJSON(offset, limit, 0), "comparison_source": "none", "comparison_state": comparisonState} - var state model.JobState - var stateErr error - comparable := summary.Status == "success" || summary.Status == "incomplete" - needsCurrentBaseline := comparable && summary.BaselineScanID == "" && summary.BaselineConfigHash == "" - if needsCurrentBaseline { - state, stateErr = ts.RuntimeState(r.Context(), id) - if stateErr != nil { - s.writeInternalError(w, r, "store", stateErr) + view := resolveScanComparison(summary) + value := map[string]any{"scan": summary, "changes": []model.Change{}, "changes_pagination": paginationJSON(offset, limit, 0), "comparison_source": view.source, "comparison_state": view.state} + switch { + case view.scanTime: + page, pageErr := ts.ListScanChangesPage(r.Context(), scanID, limit, offset) + if pageErr != nil { + s.writeInternalError(w, r, "store", pageErr) return } - } - if comparable { - if summary.BaselineScanID != "" || summary.BaselineConfigHash != "" { - page, pageErr := ts.ListScanChangesPage(r.Context(), scanID, limit, offset) - if pageErr != nil { - s.writeInternalError(w, r, "store", pageErr) - return - } - items := page.Items - if items == nil { - items = []model.Change{} - } - value["changes"], value["changes_pagination"] = items, paginationJSON(offset, limit, page.Total) - value["comparison_source"] = "scan_time" - value["comparison_state"] = "compared" - value["baseline_scan_id"] = summary.BaselineScanID - } else if state.Baseline != nil { - // Legacy scans from before the immutable comparison columns were - // introduced retain the previous current-baseline behavior. - // Only this compatibility path needs the full snapshot. Managed scans - // always carry their immutable comparison in changes_json. - scan, scanErr := ts.GetScan(r.Context(), scanID) - if scanErr != nil { - s.writeInternalError(w, r, "store", scanErr) - return - } - changes := engine.Diff(*state.Baseline, scan.Snapshot, state.BaselineConfigHash != summary.ConfigHash) + items := page.Items + if items == nil { + items = []model.Change{} + } + value["changes"], value["changes_pagination"] = items, paginationJSON(offset, limit, page.Total) + value["baseline_scan_id"] = summary.BaselineScanID + case view.state == model.ScanComparisonBaselineEstablished: + value["baseline_scan_id"] = summary.BaselineScanID + case view.legacy: + changes, compared, legacyErr := legacyScanChanges(r.Context(), ts, id, summary) + if legacyErr != nil { + s.writeInternalError(w, r, "store", legacyErr) + return + } + if compared { value["changes"], value["changes_pagination"] = pageSlice(changes, offset, limit) value["comparison_source"] = "current_baseline_legacy" - value["comparison_state"] = "compared" + value["comparison_state"] = model.ScanComparisonCompared } } value["current_security_hash"] = record.Job.SecurityHash() @@ -752,34 +794,30 @@ func (s *Server) jobScanChanges(w http.ResponseWriter, r *http.Request, ts *stor limit := queryLimit(r) changes := []model.Change{} var total int - comparisonSource := "none" - comparisonState := "not_compared" - if summary.Status == "success" || summary.Status == "incomplete" { - if summary.BaselineScanID != "" || summary.BaselineConfigHash != "" { - page, pageErr := ts.ListScanChangesPage(r.Context(), scanID, limit, offset) - if pageErr != nil { - s.writeInternalError(w, r, "store", pageErr) - return - } - changes, total = page.Items, page.Total - comparisonSource = "scan_time" - comparisonState = "compared" - } else if state, stateErr := ts.RuntimeState(r.Context(), id); stateErr == nil && state.Baseline != nil { - scan, scanErr := ts.GetScan(r.Context(), scanID) - if scanErr != nil { - s.writeInternalError(w, r, "store", scanErr) - return - } - changes = engine.Diff(*state.Baseline, scan.Snapshot, state.BaselineConfigHash != summary.ConfigHash) - comparisonSource = "current_baseline_legacy" - comparisonState = "compared" - } else if stateErr != nil { - s.writeInternalError(w, r, "store", stateErr) + view := resolveScanComparison(summary) + comparisonSource, comparisonState := view.source, view.state + switch { + case view.scanTime: + page, pageErr := ts.ListScanChangesPage(r.Context(), scanID, limit, offset) + if pageErr != nil { + s.writeInternalError(w, r, "store", pageErr) return } + changes, total = page.Items, page.Total + case view.legacy: + legacy, compared, legacyErr := legacyScanChanges(r.Context(), ts, id, summary) + if legacyErr != nil { + s.writeInternalError(w, r, "store", legacyErr) + return + } + if compared { + changes = legacy + comparisonSource = "current_baseline_legacy" + comparisonState = model.ScanComparisonCompared + } } items, page := changes, paginationJSON(offset, limit, total) - if comparisonSource != "scan_time" { + if !view.scanTime { items, page = pageSlice(changes, offset, limit) } if items == nil { diff --git a/src/api.ts b/src/api.ts index ed32088c..2ad89e3e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import type { ActiveScan, ActivityEvent, BaselineHostsResponse, Change, GlobalHostsResponse, HostDetailResponse, Incident, Job, JobForm, Pagination, PendingChange, QueuedRun, RdapResult, Scan, ScanSummary, Unit, NaabuOptions } from './types' +import type { ActiveScan, ActivityEvent, BaselineHostsResponse, Change, GlobalHostsResponse, HostDetailResponse, Incident, Job, JobForm, Pagination, PendingChange, QueuedRun, RdapResult, Scan, ScanComparison, ScanSummary, Unit, NaabuOptions } from './types' import { setDisplayTimeZone } from './format' export type NotificationDestination = { @@ -252,9 +252,9 @@ export async function getScan(scanId: string): Promise { } export const getScanSummary = (scanId: string) => api<{ scan: ScanSummary }>(`/scans/${encodeURIComponent(scanId)}/summary`) export const historicalScanHosts = (scanId: string, filters: HostFilters = {}) => api<{ job_id?: string; job: string; scan: ScanSummary; data_quality: string; hosts: import('./types').HostSummary[]; pagination: Pagination }>(`/scans/${encodeURIComponent(scanId)}/hosts?${hostQuery(filters)}`, filters.signal ? { signal: filters.signal } : undefined) -export const scanDetail = (jobId: string, scanId: string, offset = 0, limit = 50) => api<{ scan: Scan; changes: Change[]; changes_pagination: Pagination; current_security_hash: string; comparison_source?: string; comparison_state?: 'compared' | 'not_compared' | string; baseline_scan_id?: string }>(`/jobs/${jobId}/scans/${scanId}?limit=${limit}&offset=${offset}`) +export const scanDetail = (jobId: string, scanId: string, offset = 0, limit = 50) => api<{ scan: Scan; changes: Change[]; changes_pagination: Pagination; current_security_hash: string; comparison_source?: 'scan_time' | 'current_baseline_legacy' | 'none' | string; comparison_state?: ScanComparison | string; baseline_scan_id?: string }>(`/jobs/${jobId}/scans/${scanId}?limit=${limit}&offset=${offset}`) export const scanResults = (jobId: string, scanId: string, offset = 0, limit = 50) => api<{ results: Unit[]; pagination: Pagination }>(`/jobs/${jobId}/scans/${scanId}/results?limit=${limit}&offset=${offset}`) -export const scanChanges = (jobId: string, scanId: string, offset = 0, limit = 50) => api<{ changes: Change[]; pagination: Pagination }>(`/jobs/${jobId}/scans/${scanId}/changes?limit=${limit}&offset=${offset}`) +export const scanChanges = (jobId: string, scanId: string, offset = 0, limit = 50) => api<{ changes: Change[]; pagination: Pagination; comparison_source?: 'scan_time' | 'current_baseline_legacy' | 'none' | string; comparison_state?: ScanComparison | string; baseline_scan_id?: string }>(`/jobs/${jobId}/scans/${scanId}/changes?limit=${limit}&offset=${offset}`) export const listScans = (offset = 0, limit = 20) => api<{ scans: ScanSummary[]; pagination: Pagination }>(`/scans?limit=${limit}&offset=${offset}`) export const listHosts = (filters: HostFilters = {}) => api(`/hosts?${hostQuery(filters)}`, filters.signal ? { signal: filters.signal } : undefined) export const activeScans = () => api<{ scans: ActiveScan[]; queued_runs?: QueuedRun[] }>('/scans/active') diff --git a/src/pages/JobDetail.actions.test.tsx b/src/pages/JobDetail.actions.test.tsx index 3e5ac64d..288f3b26 100644 --- a/src/pages/JobDetail.actions.test.tsx +++ b/src/pages/JobDetail.actions.test.tsx @@ -404,6 +404,44 @@ describe('job detail actions', () => { expect(screen.queryByRole('button', { name: 'Use as baseline' })).not.toBeInTheDocument() }) + const comparisonCopy = { + sample: 'This scan was a baseline sample; no comparison was performed.', + incompleteSample: 'This scan ran while the baseline was being learned; no comparison was performed. Incomplete scans do not count as baseline samples.', + established: 'This scan established the baseline.', + kept: 'This scan was not compared with the baseline.', + failed: 'This scan was not compared because it did not complete successfully.', + scanTime: '0 changes recorded at scan time.', + legacy: '0 changes against the current baseline.', + } + it.each([ + { name: 'a successful baseline sample', status: 'success', state: 'baseline_sample', source: 'none', copy: comparisonCopy.sample, empty: 'No comparison was performed for this scan.' }, + { name: 'an incomplete scan while the baseline is learned', status: 'incomplete', state: 'baseline_sample', source: 'none', copy: comparisonCopy.incompleteSample, empty: 'No comparison was performed for this scan.' }, + { name: 'the sample that established the baseline', status: 'success', state: 'baseline_established', source: 'none', copy: comparisonCopy.established, empty: 'No comparison was performed for this scan.' }, + { name: 'a completed scan kept without a comparison', status: 'success', state: 'not_compared', source: 'none', copy: comparisonCopy.kept, empty: 'No comparison was performed for this scan.' }, + { name: 'a failed scan', status: 'failed', state: 'not_compared', source: 'none', copy: comparisonCopy.failed, empty: 'No comparison was performed for this scan.' }, + { name: 'a scan compared at scan time', status: 'success', state: 'compared', source: 'scan_time', copy: comparisonCopy.scanTime, empty: 'No changes detected.' }, + { name: 'a legacy scan compared with the current baseline', status: 'success', state: 'compared', source: 'current_baseline_legacy', copy: comparisonCopy.legacy, empty: 'No changes detected.' }, + ])('describes the comparison of $name', async ({ status, state, source, copy, empty }) => { + const selected = { ...scan, status } + vi.mocked(jobScans).mockResolvedValue({ scans: [selected], pagination: page } as never) + vi.mocked(scanDetail).mockResolvedValue({ + scan: selected, + changes: [], + changes_pagination: { ...page, total: 0 }, + comparison_state: state, + comparison_source: source, + current_security_hash: 'scope', + } as never) + renderPage() + + fireEvent.click(await screen.findByRole('button', { name: /scan-1/i })) + expect(await screen.findByText(copy)).toBeInTheDocument() + expect(screen.getByText(empty)).toBeInTheDocument() + for (const other of Object.values(comparisonCopy).filter((text) => text !== copy)) { + expect(screen.queryByText(other)).not.toBeInTheDocument() + } + }) + it('restores an archived job and permanently deletes it with the guarded name', async () => { vi.mocked(getJob).mockResolvedValue({ ...job, archived: true, enabled: false } as never) renderPage() diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index 3734d151..f7dd156f 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -484,7 +484,7 @@ export function JobDetail() {

Scan diff

-

{detail.data.comparison_state === 'not_compared' ? 'This scan was not compared because it did not complete successfully.' : `${detail.data.changes_pagination?.total ?? detail.data.changes?.length ?? 0} ${detail.data.comparison_source === 'scan_time' ? 'changes recorded at scan time.' : 'changes against the current baseline.'}`}

+

{scanComparisonSummary(detail.data)}

{(detail.data.scan.status === 'success' || detail.data.scan.status === 'incomplete') && } @@ -517,7 +517,7 @@ export function JobDetail() {
))}
- ) :
{detail.data.comparison_state === 'not_compared' ? 'No comparison was performed for this scan.' : 'No changes detected.'}
} + ) :
{scanWasCompared(detail.data.comparison_state) ? 'No changes detected.' : 'No comparison was performed for this scan.'}
} {showResults &&

Snapshot results

Loaded on demand; open an effective host for technical evidence.

@@ -801,6 +801,39 @@ function scanBudgetMessage(budget: ScanBudget) { : `About ${estimate} probes exceed the most this job may send (${limit}). Scheduled scans are skipped until its scope is reduced.` } +type ScanComparisonDetail = { + scan: { status: string } + changes?: unknown[] + changes_pagination?: { total: number } + comparison_source?: string + comparison_state?: string +} + +// A scan that finished while its job was learning the baseline was not +// compared with anything, but it did not fail: describe it as a baseline +// sample, and reserve the failure copy for scans that did not complete. +function scanComparisonSummary(detail: ScanComparisonDetail) { + const completed = detail.scan.status === 'success' || detail.scan.status === 'incomplete' + switch (detail.comparison_state) { + case 'baseline_sample': + return detail.scan.status === 'incomplete' + ? 'This scan ran while the baseline was being learned; no comparison was performed. Incomplete scans do not count as baseline samples.' + : 'This scan was a baseline sample; no comparison was performed.' + case 'baseline_established': + return 'This scan established the baseline.' + case 'not_compared': + return completed ? 'This scan was not compared with the baseline.' : 'This scan was not compared because it did not complete successfully.' + default: { + const count = detail.changes_pagination?.total ?? detail.changes?.length ?? 0 + return `${count} ${detail.comparison_source === 'scan_time' ? 'changes recorded at scan time.' : 'changes against the current baseline.'}` + } + } +} + +function scanWasCompared(state?: string) { + return state !== 'not_compared' && state !== 'baseline_sample' && state !== 'baseline_established' +} + function scanSkippedMessage(reason?: string) { switch (reason) { case 'busy': return 'The scan could not start because another scan already owns this job.' diff --git a/src/types.ts b/src/types.ts index 816967e5..8326674d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,8 +13,12 @@ export type Job = { id: string; revision: number; enabled: boolean; archived: bo // scan_budget says whether the job's estimated work fits its unit's probe // budget; a scheduled run that does not fit is skipped before it starts. export type ScanBudget = { exceeded: boolean; estimated_probes?: number; limit?: number; approval_would_fit?: boolean } -export type Scan = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string; snapshot?: { units: Unit[]; scopes: Scope[]; dns?: Record; hosts?: HostObservation[] } } -export type ScanSummary = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string } +// ScanComparison is how a scan was compared with its job's baseline when it +// finished. A scan's `comparison` is absent when it was recorded before +// EdgeWatch stored the outcome. +export type ScanComparison = 'compared' | 'baseline_sample' | 'baseline_established' | 'not_compared' +export type Scan = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string; comparison?: ScanComparison; snapshot?: { units: Unit[]; scopes: Scope[]; dns?: Record; hosts?: HostObservation[] } } +export type ScanSummary = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string; comparison?: ScanComparison } export type ActiveScan = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string estimated_probes?: number; nmap_invocations?: number; estimated_seconds?: number