From de7749a78c479cd0b65fdc8bddd54ec197f335b2 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:23:30 +0200 Subject: [PATCH] feat: show cleared high-cost approvals and record budget-skipped scheduled runs - An operator's scope edit on an approved job lists the cleared high-cost approval in the scope-change confirmation, with whether the new scope exceeds the probe budget, and the save response reports high_cost_approval_cleared. - Job responses include scan_budget: whether the estimated work exceeds the unit's probe budget and whether a high-cost approval would let it run. The job page warns when scheduled scans are skipped for it. - A scheduled run that its probe budget stops before it starts records a scan-budget-exceeded event, delivered to the job's destinations and shown in Activity as Scheduled scan skipped. It is reported once per scope and budget, and again after a scan of the job has run. Fixes #1223 --- .../content/docs/user-guide/notifications.md | 5 +- docs/src/content/docs/user-guide/scanning.md | 11 +- internal/app/app.go | 46 +++++++ internal/app/budget_skip_test.go | 114 ++++++++++++++++++ internal/engine/engine.go | 3 + internal/model/model.go | 8 ++ internal/web/high_cost_visibility_test.go | 109 +++++++++++++++++ internal/web/job_handlers.go | 33 ++++- internal/web/scan_handlers.go | 28 ++++- src/main.test.tsx | 5 + src/main.tsx | 1 + src/pages/Activity.test.tsx | 7 +- src/pages/Activity.tsx | 3 +- src/pages/JobDetail.actions.test.tsx | 20 +++ src/pages/JobDetail.tsx | 13 +- src/pages/JobEditor.behavior.test.tsx | 1 + src/pages/JobEditor.tsx | 2 +- src/types.ts | 5 +- 18 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 internal/app/budget_skip_test.go create mode 100644 internal/web/high_cost_visibility_test.go diff --git a/docs/src/content/docs/user-guide/notifications.md b/docs/src/content/docs/user-guide/notifications.md index ba770db..9b700b6 100644 --- a/docs/src/content/docs/user-guide/notifications.md +++ b/docs/src/content/docs/user-guide/notifications.md @@ -66,8 +66,9 @@ pause uses none of their retries and is not reported as a delivery failure. ## Delivery retries and health -Scan changes, scan failures, cancellations, timeouts, stalled cycles, and -recovery events can all generate notifications. A scan that stops because +Scan changes, scan failures, cancellations, timeouts, stalled cycles, +scheduled runs skipped because they exceed the probe budget, and recovery +events can all generate notifications. A scan that stops because EdgeWatch stopped, for example during an upgrade or restart, is recorded as canceled with the reason "scan interrupted because EdgeWatch stopped" and appears in Activity as **Scan interrupted**, but sends no notification. A diff --git a/docs/src/content/docs/user-guide/scanning.md b/docs/src/content/docs/user-guide/scanning.md index 75354b2..7503978 100644 --- a/docs/src/content/docs/user-guide/scanning.md +++ b/docs/src/content/docs/user-guide/scanning.md @@ -62,7 +62,16 @@ executables. Full-range scans are deliberately bounded by scheduler probe budgets. A scan that runs as a single invocation resolves DNS again when it starts, and the -budget is checked against that resolution before any scanner runs. A broad +budget is checked against that resolution before any scanner runs. + +A job whose estimated work exceeds its unit's budget needs an administrator's +high-cost approval. **Scan now** on such a job is refused with an error. A +scheduled run is skipped before it starts and is reported in Activity as +**Scheduled scan skipped**, with a notification to the job's destinations. It +is reported once for each scope and budget, and again after a scan of the job +has run. The job page shows when a job exceeds its budget and whether an +approval would let it run. An operator's change to a job's targets, ports, or +scanner clears its high-cost approval; the scope-change confirmation says so. A broad scan may be split into resumable address, discovery, enrichment, and UDP work units. A timeout or restart preserves completed work for the configured resume window; partial work cannot change a baseline. The dashboard shows scanner diff --git a/internal/app/app.go b/internal/app/app.go index 3d773e2..585d727 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1769,6 +1769,11 @@ func (a *App) startManagedScheduled(ctx context.Context, scope store.TenantScope a.Logger.Info("scheduled run skipped because the job's tenant is not active", "job", record.Job.Name) return } + var budgetErr *ScanWorkBudgetError + if scan.ID == "" && errors.As(runErr, &budgetErr) { + a.recordScheduledBudgetSkip(ctx, scope, record, budgetErr) + return + } if runErr != nil { a.Logger.Error("scan failed", "job", record.Job.Name, "scan_id", scan.ID, "error", runErr) return @@ -1777,6 +1782,47 @@ func (a *App) startManagedScheduled(ctx context.Context, scope store.TenantScope }) } +// recordScheduledBudgetSkip reports a scheduled run that its probe budget +// stopped before a scan record existed. A manual run reports the refusal to +// the person who started it; nobody sees a scheduled one, so it becomes an +// activity event delivered to the job's destinations. It is reported once for +// each scope and budget, until a scan of the job runs again. +func (a *App) recordScheduledBudgetSkip(ctx context.Context, scope store.TenantScope, record store.JobRecord, budgetErr *ScanWorkBudgetError) { + a.Logger.Warn("scheduled run skipped because it exceeds the probe budget", "job", record.Job.Name, "estimated_probes", budgetErr.Estimate.Probes, "budget", budgetErr.Budget) + var destinations []string + if a.Notifier != nil { + var err error + destinations, err = a.Notifier.Tenant(a.Store.Tenant(scope)).QueueDestinationsForJob(ctx, record.Job) + if err != nil { + // Like the silence watchdog, record nothing rather than an alert + // without its deliveries; the next scheduled run tries again. + a.Logger.Warn("budget skip notification destinations unavailable", "job", record.Job.Name, "error", err) + return + } + } + hash := record.Job.SecurityHash() + key := fmt.Sprintf("%s|%d", hash, budgetErr.Budget) + message := fmt.Sprintf("Scheduled scan skipped: about %d probes exceed the probe budget of %d. An administrator must approve high-cost scans for this job, or its scope must be reduced.", budgetErr.Estimate.Probes, budgetErr.Budget) + if record.Job.AllowHighCost { + message = fmt.Sprintf("Scheduled scan skipped: about %d probes exceed the high-cost limit of %d. Reduce the job's scope.", budgetErr.Estimate.Probes, budgetErr.Budget) + } + events, err := a.Store.System().UpdateRuntimeForScanWithOutbox(ctx, record.ID, hash, destinations, func(state *model.JobState) ([]model.Event, error) { + if state.BudgetSkipAlertKey == key { + return nil, nil + } + state.BudgetSkipAlertKey = key + return []model.Event{{Type: model.EventScanBudgetExceeded, JobID: record.ID, Job: record.Job.Name, Message: message, CreatedAt: time.Now().UTC()}}, nil + }) + if err != nil { + a.Logger.Warn("budget skip could not be recorded", "job", record.Job.Name, "error", err) + return + } + if len(events) > 0 { + a.emitTenantEvents(scope, events) + a.wakeDelivery() + } +} + func (a *App) startTracked(fn func()) bool { a.runMu.Lock() if !a.runAccepting { diff --git a/internal/app/budget_skip_test.go b/internal/app/budget_skip_test.go new file mode 100644 index 0000000..ae81dd0 --- /dev/null +++ b/internal/app/budget_skip_test.go @@ -0,0 +1,114 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "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" +) + +func TestScheduledBudgetSkipIsRecordedOnceUntilAScanRuns(t *testing.T) { + t.Parallel() + ctx := context.Background() + s, err := store.Open(storetest.FreshPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + cfg := &config.Config{ + Version: 1, Database: "test", Retention: config.Duration(24 * time.Hour), + Scheduler: config.Scheduler{MaxConcurrent: 1, MaxProbeCount: 100}, + Web: config.Web{Listen: "127.0.0.1:8080"}, + Notifications: config.Notifications{URLs: []string{"generic://localhost/edgewatch?disabletls=yes&template=json"}}, + } + a, err := New(cfg, s, "missing", slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + a.Scanner = schedulerFake{} + record, err := defaultTenant(s).CreateJob(ctx, config.NormalizeJob(config.Job{ + Name: "over-budget", Schedule: "0 * * * *", Timezone: "UTC", Targets: []string{"192.0.2.1"}, + TCP: &config.Protocol{Ports: "1-1000", Mode: "connect", Engine: config.EngineNmap}, Timing: "balanced", Timeout: config.Duration(time.Minute), + })) + if err != nil { + t.Fatal(err) + } + live := make(chan model.Event, 32) + a.SetEventHandler(func(event model.Event) { live <- event }) + scheduled := func() { + t.Helper() + a.BeginRun(ctx) + a.startManagedScheduled(ctx, store.DefaultTenantScope(), record.ID) + a.StopRun() + } + budgetEvents := func() (events []model.Event, outbox int) { + t.Helper() + rows, err := s.DB.QueryContext(ctx, `SELECT payload_json FROM events ORDER BY id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + t.Fatal(err) + } + var event model.Event + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } + if event.Type == model.EventScanBudgetExceeded { + events = append(events, event) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM outbox WHERE CAST(payload_json AS TEXT) LIKE '%scan-budget-exceeded%'`).Scan(&outbox); err != nil { + t.Fatal(err) + } + return events, outbox + } + + scheduled() + events, outbox := budgetEvents() + if len(events) != 1 || outbox != 1 || events[0].JobID != record.ID { + t.Fatalf("first skip = %+v (outbox %d), want one delivered event", events, outbox) + } + if want := "Scheduled scan skipped: about 1000 probes exceed the probe budget of 100. An administrator must approve high-cost scans for this job, or its scope must be reduced."; events[0].Message != want { + t.Fatalf("skip message = %q, want %q", events[0].Message, want) + } + var sawLive bool + for len(live) > 0 { + if event := <-live; event.Type == model.EventScanBudgetExceeded { + sawLive = true + } + } + if !sawLive { + t.Fatal("the budget skip was not published as a live update") + } + + // The same scope and budget are not reported again. + scheduled() + if events, outbox := budgetEvents(); len(events) != 1 || outbox != 1 { + t.Fatalf("repeated skip = %d events, %d deliveries, want 1 and 1", len(events), outbox) + } + + // Once a scan runs, a later skip is reported again. + a.Config.Scheduler.MaxProbeCount = 0 + if _, _, err := a.RunJobRecord(ctx, record); err != nil { + t.Fatal(err) + } + a.Config.Scheduler.MaxProbeCount = 100 + scheduled() + if events, outbox := budgetEvents(); len(events) != 2 || outbox != 2 { + t.Fatalf("skip after a scan ran = %d events, %d deliveries, want 2 and 2", len(events), outbox) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9c41877..feb0cae 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -57,6 +57,9 @@ func (e *Engine) FinalizeManagedScanWithOptions(ctx context.Context, jobID strin return nil, fmt.Errorf("scan is required") } return e.Store.System().FinalizeManagedScanWithOptions(ctx, scan, jobID, scan.ConfigHash, destinations, options, func(state *model.JobState, current *model.Scan, reminderSettings store.IncidentReminderSettings) ([]model.Event, error) { + // The scan passed its probe budget when it started, so a later + // budget skip is reported again. + state.BudgetSkipAlertKey = "" if current.Status == "success" { MarkIncompleteScan(current) } diff --git a/internal/model/model.go b/internal/model/model.go index ce7ed72..bdb7a04 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -364,6 +364,10 @@ type JobState struct { // emitted for this job. It lives in runtime state so cadence survives // restarts without affecting scan snapshots or baseline hashes. LastIncidentReminderAt *time.Time `json:"last_incident_reminder_at,omitempty"` + // BudgetSkipAlertKey names the scope and probe budget of the last + // scheduled run that the budget stopped before it started. A skip with + // the same key is not reported again; any scan that runs clears it. + BudgetSkipAlertKey string `json:"budget_skip_alert_key,omitempty"` } // QueuedRun describes an accepted manual or scheduled run that has not yet @@ -412,6 +416,10 @@ func EventDelivered(eventType string) bool { return eventType != EventScanInterrupted } +// EventScanBudgetExceeded records a scheduled run that its probe budget +// stopped before it started. +const EventScanBudgetExceeded = "scan-budget-exceeded" + // EventPayloadLimit is the maximum serialized size of a durable event or // notification outbox payload. Change details remain available on the scan // history endpoint; oversized alert events carry a bounded summary instead. diff --git a/internal/web/high_cost_visibility_test.go b/internal/web/high_cost_visibility_test.go new file mode 100644 index 0000000..63bbc3e --- /dev/null +++ b/internal/web/high_cost_visibility_test.go @@ -0,0 +1,109 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/store" +) + +func TestOperatorScopeEditShowsTheClearedHighCostApproval(t *testing.T) { + t.Parallel() + ctx := context.Background() + server, db, admin := newUsersTestServer(t) + server.App.Config.Scheduler.MaxProbeCount = 100 + operator := admin + operator.Role = store.RoleOperator + job := config.NormalizeJob(config.Job{ + Name: "approved-broad", Schedule: "0 * * * *", Timezone: "UTC", Targets: []string{"192.0.2.1"}, + TCP: &config.Protocol{Ports: "1-1000", Mode: "connect", Engine: config.EngineNmap}, AllowHighCost: true, + }) + record, err := defaultTenant(db).CreateJob(ctx, job) + if err != nil { + t.Fatal(err) + } + serve := func(session store.Session, method, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/api/v1/jobs/"+record.ID, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + server.jobRoute(rec, req, session, defaultTenantStore(server), record.ID) + return rec + } + type response struct { + Job struct { + AllowHighCost bool `json:"allow_high_cost"` + } `json:"job"` + ScanBudget *struct { + Exceeded bool `json:"exceeded"` + EstimatedProbes int64 `json:"estimated_probes"` + Limit int64 `json:"limit"` + ApprovalWouldFit bool `json:"approval_would_fit"` + } `json:"scan_budget"` + Cleared bool `json:"high_cost_approval_cleared"` + } + decode := func(rec *httptest.ResponseRecorder) response { + t.Helper() + var value response + if err := json.Unmarshal(rec.Body.Bytes(), &value); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + return value + } + + approved := serve(admin, http.MethodGet, "") + if got := decode(approved); approved.Code != http.StatusOK || got.ScanBudget == nil || got.ScanBudget.Exceeded { + t.Fatalf("approved job = %d %s, want a budget that fits", approved.Code, approved.Body.String()) + } + + edit := fromConfig(record.Job) + edit.Revision = record.Revision + edit.TCP.Ports = "1-999" + body, err := json.Marshal(edit) + if err != nil { + t.Fatal(err) + } + prompt := serve(operator, http.MethodPut, string(body)) + if prompt.Code != http.StatusConflict || !strings.Contains(prompt.Body.String(), "high-cost approval: cleared; an administrator must approve the new scope again (about 999 probes exceed the budget of 100") { + t.Fatalf("operator scope edit = %d %s, want the cleared approval in the confirmation", prompt.Code, prompt.Body.String()) + } + + edit.ConfirmRebaseline = true + body, err = json.Marshal(edit) + if err != nil { + t.Fatal(err) + } + saved := serve(operator, http.MethodPut, string(body)) + got := decode(saved) + if saved.Code != http.StatusOK || !got.Cleared || got.Job.AllowHighCost { + t.Fatalf("confirmed operator edit = %d %s, want the approval reported as cleared", saved.Code, saved.Body.String()) + } + if got.ScanBudget == nil || !got.ScanBudget.Exceeded || got.ScanBudget.EstimatedProbes != 999 || got.ScanBudget.Limit != 100 || !got.ScanBudget.ApprovalWouldFit { + t.Fatalf("budget after the edit = %+v", got.ScanBudget) + } + + // A routine edit that keeps the scope keeps the approval and says nothing. + reloaded, err := defaultTenant(db).GetJob(ctx, record.ID) + if err != nil { + t.Fatal(err) + } + if reloaded.Job.AllowHighCost { + t.Fatal("the approval survived the operator's scope change") + } + routine := fromConfig(reloaded.Job) + routine.Revision = reloaded.Revision + routine.Schedule = "30 * * * *" + body, err = json.Marshal(routine) + if err != nil { + t.Fatal(err) + } + if rec := serve(operator, http.MethodPut, string(body)); rec.Code != http.StatusOK || decode(rec).Cleared { + t.Fatalf("routine edit = %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/job_handlers.go b/internal/web/job_handlers.go index 38aabab..8fc0a5d 100644 --- a/internal/web/job_handlers.go +++ b/internal/web/job_handlers.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/crypt0rr/edgewatch/internal/app" "github.com/crypt0rr/edgewatch/internal/auth" "github.com/crypt0rr/edgewatch/internal/config" "github.com/crypt0rr/edgewatch/internal/model" @@ -170,7 +171,37 @@ func jobJSONFromStateSummary(record store.JobRecord, summary store.RuntimeStateS func (s *Server) jobJSONWithCycle(ctx context.Context, ts *store.TenantStore, record store.JobRecord, state model.JobState) map[string]any { value := s.addNotificationRouting(ctx, s.tenantNotifier(ts), jobJSON(record, state)) - return s.addJobCycleAndProfile(ctx, ts, record, value) + value = s.addJobCycleAndProfile(ctx, ts, record, value) + if budget, ok := s.jobScanBudget(ctx, ts, record.Job); ok { + value["scan_budget"] = budget + } + return value +} + +// jobScanBudget reports whether the job's estimated work fits the probe +// budget of its unit, so the job page can explain why its scheduled runs are +// skipped. approval_would_fit says whether an administrator's high-cost +// approval would let it run. It is left out when the budget cannot be read. +func (s *Server) jobScanBudget(ctx context.Context, ts *store.TenantStore, job config.Job) (map[string]any, bool) { + if s.App == nil { + return nil, false + } + _, err := s.App.CheckScanWorkBudget(ctx, ts, job) + var budgetErr *app.ScanWorkBudgetError + switch { + case err == nil: + return map[string]any{"exceeded": false}, true + case !errors.As(err, &budgetErr): + return nil, false + } + budget := map[string]any{"exceeded": true, "estimated_probes": budgetErr.Estimate.Probes, "limit": budgetErr.Budget, "approval_would_fit": false} + if !job.AllowHighCost { + approved := job + approved.AllowHighCost = true + _, approvedErr := s.App.CheckScanWorkBudget(ctx, ts, approved) + budget["approval_would_fit"] = approvedErr == nil + } + return budget, true } type pendingChangeView struct { diff --git a/internal/web/scan_handlers.go b/internal/web/scan_handlers.go index a05b9a4..ae72740 100644 --- a/internal/web/scan_handlers.go +++ b/internal/web/scan_handlers.go @@ -187,6 +187,7 @@ func (s *Server) updateJob(w http.ResponseWriter, r *http.Request, session store job.AllowHighCost = false } } + approvalCleared := current.Job.AllowHighCost && !job.AllowHighCost if active && scopeChanged { writeError(w, 409, "job_active", "security-relevant settings cannot change during an active scan", nil) return @@ -214,7 +215,11 @@ func (s *Server) updateJob(w http.ResponseWriter, r *http.Request, session store return } if errors.Is(err, store.ErrRebaselineRequired) { - writeError(w, 409, "rebaseline_confirmation_required", "security-relevant settings changed; confirm rebaseline to continue", map[string]any{"previous_hash": current.Job.SecurityHash(), "new_hash": job.SecurityHash(), "changes": securityScopeChanges(current.Job, job)}) + changes := securityScopeChanges(current.Job, job) + if approvalCleared { + changes = append(changes, s.highCostClearedChange(r.Context(), ts, job)) + } + writeError(w, 409, "rebaseline_confirmation_required", "security-relevant settings changed; confirm rebaseline to continue", map[string]any{"previous_hash": current.Job.SecurityHash(), "new_hash": job.SecurityHash(), "changes": changes}) return } if errors.Is(err, store.ErrJobScanActive) { @@ -245,7 +250,26 @@ func (s *Server) updateJob(w http.ResponseWriter, r *http.Request, session store s.App.RefreshSchedules() state, _ := ts.RuntimeState(r.Context(), id) s.broadcastTo(context.WithoutCancel(r.Context()), audienceTenant(ts), map[string]any{"type": "job.updated", "job_id": id}) - writeJSON(w, 200, s.jobJSONWithCycle(r.Context(), ts, record, state)) + response := s.jobJSONWithCycle(r.Context(), ts, record, state) + if approvalCleared { + response["high_cost_approval_cleared"] = true + } + writeJSON(w, 200, response) +} + +// highCostClearedChange describes, in the scope-change confirmation, the +// high-cost approval that saving the new scope clears, and whether the new +// scope still needs one. +func (s *Server) highCostClearedChange(ctx context.Context, ts *store.TenantStore, job config.Job) string { + const cleared = "high-cost approval: cleared; an administrator must approve the new scope again" + budget, ok := s.jobScanBudget(ctx, ts, job) + if !ok { + return cleared + } + if exceeded, _ := budget["exceeded"].(bool); exceeded { + return fmt.Sprintf("%s (about %d probes exceed the budget of %d, so scheduled scans are skipped until then)", cleared, budget["estimated_probes"], budget["limit"]) + } + return cleared + " (the new scope fits the probe budget without it)" } // canOverrideHighCost deliberately reuses the administrator-only users.manage diff --git a/src/main.test.tsx b/src/main.test.tsx index b3013f9..4180142 100644 --- a/src/main.test.tsx +++ b/src/main.test.tsx @@ -193,6 +193,11 @@ describe('application shell', () => { expect(invalidate, type).toHaveBeenCalledWith({ queryKey: key }) expect(invalidate, type).not.toHaveBeenCalledWith() } + invalidate.mockClear() + act(() => stream.emit('scan-budget-exceeded', 'job-9')) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['activity-events'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['job', 'job-9'] }) + expect(invalidate).not.toHaveBeenCalledWith() act(() => stream.emit('scan.skipped', 'job-9', 'paused')) expect(skipped).toHaveBeenCalledWith(expect.objectContaining({ detail: { job_id: 'job-9', reason: 'paused' } })) expect(invalidate).toHaveBeenCalledWith({ queryKey: ['active-scans'] }) diff --git a/src/main.tsx b/src/main.tsx index 42d2809..ca9c79e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -143,6 +143,7 @@ export function Shell({ displayName, role, permissions, onLogout, unit }: { disp case 'scan-canceled': case 'scan-interrupted': case 'scan-anomaly': + case 'scan-budget-exceeded': void client.invalidateQueries({ queryKey: ['jobs'] }) void client.invalidateQueries({ queryKey: ['active-scans'] }) void client.invalidateQueries({ queryKey: ['scans'] }) diff --git a/src/pages/Activity.test.tsx b/src/pages/Activity.test.tsx index 683ad92..a436c2f 100644 --- a/src/pages/Activity.test.tsx +++ b/src/pages/Activity.test.tsx @@ -66,16 +66,19 @@ describe('activity history', () => { { type: 'scan-anomaly', job_id: 'job-1', job: 'Production', message: 'Coverage was incomplete', created_at: '2026-09-20T11:00:00Z' }, { type: 'scan-canceled', job_id: 'job-1', job: 'Production', message: 'Scan canceled', created_at: '2026-09-20T10:00:00Z' }, { type: 'scan-interrupted', job_id: 'job-1', job: 'Production', message: 'Scan interrupted because EdgeWatch stopped', created_at: '2026-09-20T09:00:00Z' }, - ], pagination: { ...page, total: 4 } }) + { type: 'scan-budget-exceeded', job_id: 'job-1', job: 'Production', message: 'Scheduled scan skipped: about 999 probes exceed the probe budget of 100.', created_at: '2026-09-20T08:00:00Z' }, + ], pagination: { ...page, total: 5 } }) renderWithProviders() - await waitFor(() => expect(document.querySelectorAll('.activity-event')).toHaveLength(4)) + await waitFor(() => expect(document.querySelectorAll('.activity-event')).toHaveLength(5)) const events = Array.from(document.querySelectorAll('.activity-event')) expect(events[0]).toHaveClass('failure') expect(events[1]).toHaveClass('warning') expect(events[2].querySelector('.activity-event-heading strong')).toHaveTextContent('Scan canceled') expect(events[3].querySelector('.activity-event-heading strong')).toHaveTextContent('Scan interrupted') expect(events[3]).not.toHaveClass('failure') + expect(events[4]).toHaveClass('warning') + expect(events[4].querySelector('.activity-event-heading strong')).toHaveTextContent('Scheduled scan skipped') expect(screen.queryByText('Scan cancelled', { exact: true })).not.toBeInTheDocument() }) diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index 82fa423..dc6be71 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -22,6 +22,7 @@ const eventLabels: Record = { 'scan-canceled': 'Scan canceled', 'scan-interrupted': 'Scan interrupted', 'scan-anomaly': 'Scan anomaly', + 'scan-budget-exceeded': 'Scheduled scan skipped', 'application-update-available': 'Update available', 'application-updated': 'Application updated', 'job-silent': 'Job notification warning', @@ -31,7 +32,7 @@ function eventTone(type: string) { if (type === 'changes-recovered' || type === 'incident-accepted') return 'recovered' if (type === 'changes-detected' || type === 'changes-reminder') return 'incident' if (type === 'scan-failure') return 'failure' - if (type === 'scan-anomaly') return 'warning' + if (type === 'scan-anomaly' || type === 'scan-budget-exceeded') return 'warning' return 'neutral' } diff --git a/src/pages/JobDetail.actions.test.tsx b/src/pages/JobDetail.actions.test.tsx index fb74c92..3e5ac64 100644 --- a/src/pages/JobDetail.actions.test.tsx +++ b/src/pages/JobDetail.actions.test.tsx @@ -136,6 +136,26 @@ describe('job detail actions', () => { expect(screen.queryByRole('button', { name: 'Cancel queued scan' })).not.toBeInTheDocument() }) + it('explains why scheduled scans of an over-budget job are skipped', async () => { + vi.mocked(getJob).mockResolvedValue({ ...job, scan_budget: { exceeded: true, estimated_probes: 999, limit: 100, approval_would_fit: true } } as never) + const view = renderPage() + expect(await screen.findByText(/About 999 probes exceed this unit's probe budget of 100\. Scheduled scans are skipped until an administrator approves high-cost scans for this job\./)).toBeInTheDocument() + view.unmount() + + vi.mocked(getJob).mockResolvedValue({ ...job, job: { ...job.job, allow_high_cost: true }, scan_budget: { exceeded: true, estimated_probes: 2000000000, limit: 1000000000, approval_would_fit: false } } as never) + renderPage() + expect(await screen.findByText(/Scheduled scans are skipped until its scope is reduced\./)).toBeInTheDocument() + expect(screen.getByText(/High-cost scans approved/)).toBeInTheDocument() + }) + + it('shows no budget warning for a job that fits its budget', async () => { + vi.mocked(getJob).mockResolvedValue({ ...job, scan_budget: { exceeded: false } } as never) + renderPage() + await screen.findByRole('button', { name: 'Scan now' }) + expect(document.querySelector('.scan-budget-warning')).toBeNull() + expect(screen.queryByText(/High-cost scans approved/)).not.toBeInTheDocument() + }) + it('clears a locally queued request when the scan appears in history', async () => { const { client } = renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Scan now' })) diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index dcef694..dd625f7 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -44,7 +44,7 @@ import { ActionDialog } from '../components/ActionDialog' import { ErrorNotice } from '../components/ErrorNotice' import { PortScopeDetails } from '../components/PortScopeDetails' import { SurfaceUnitList } from '../components/SurfaceUnitList' -import type { ActiveScan, QueuedRun, WorkEstimate } from '../types' +import type { ActiveScan, QueuedRun, ScanBudget, WorkEstimate } from '../types' import { baselinePresentation } from '../baseline' import { formatDateTime } from '../format' import { changeKindLabel, jobStatePresentation, scanOutcomeTone, severityTone } from '../status' @@ -541,7 +541,7 @@ export function JobDetail() {

{value.job.name}

{jobStatus.label} -

Revision {value.revision} · Updated {formatDateTime(value.updated_at)}

+

Revision {value.revision} · Updated {formatDateTime(value.updated_at)}{value.job.allow_high_cost ? ' · High-cost scans approved' : ''}

{canOperate &&
} {actionError &&
{actionError}
} + {value.scan_budget?.exceeded &&
{scanBudgetMessage(value.scan_budget)}
} {canOperate && canReadScans && active.error && active.refetch()} />} {canOperate && canReadScans && (activeJobScan || pendingScanRequest || activeJobQueuedRun) && } +function scanBudgetMessage(budget: ScanBudget) { + const estimate = budget.estimated_probes?.toLocaleString() ?? 'The estimated' + const limit = budget.limit?.toLocaleString() ?? 'its limit' + return budget.approval_would_fit + ? `About ${estimate} probes exceed this unit's probe budget of ${limit}. Scheduled scans are skipped until an administrator approves high-cost scans for this job.` + : `About ${estimate} probes exceed the most this job may send (${limit}). Scheduled scans are skipped until its scope is reduced.` +} + 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/pages/JobEditor.behavior.test.tsx b/src/pages/JobEditor.behavior.test.tsx index 762e263..090207a 100644 --- a/src/pages/JobEditor.behavior.test.tsx +++ b/src/pages/JobEditor.behavior.test.tsx @@ -502,6 +502,7 @@ describe('job editor workflow coverage', () => { renderWithProviders(} />, { route: ['/jobs/job-1/edit'] }) await waitFor(() => expect(screen.getByDisplayValue('Broad edge')).toBeInTheDocument()) expect(await screen.findByText(/Only an administrator can approve high-cost scans\./)).toBeInTheDocument() + expect(screen.getByText(/Changing the targets, ports or scanner clears this approval, and an administrator must approve the new scope again\./)).toBeInTheDocument() const highCost = screen.getByRole('checkbox', { name: /Allow high-cost scans/ }) expect(highCost).toBeChecked() expect(highCost).toBeEnabled() diff --git a/src/pages/JobEditor.tsx b/src/pages/JobEditor.tsx index f6aa513..f7b1b92 100644 --- a/src/pages/JobEditor.tsx +++ b/src/pages/JobEditor.tsx @@ -385,7 +385,7 @@ export function JobEditor() { : 'Compare DNS answer membership, individual host reachability, and ports/services. Choose aggregate mode only when DNS answers rotate routinely; it will not alert on address membership or per-backend reachability. Changing this requires confirming a new baseline.'} - +
Large CIDRs can take a long time to scan. The expansion limit protects the host from accidental wide scopes.
diff --git a/src/types.ts b/src/types.ts index 9ffd3e3..11f8323 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,10 @@ export type JobForm = { resume_window?: string; notification_destinations?: string[] } export type WorkEstimate = { hosts: number; tcp_ports: number; udp_ports: number; probes: number; naabu_probes?: number; nmap_probes?: number; naabu_invocations?: number; nmap_invocations: number; estimated_seconds?: number; unknown_dns: number } -export type Job = { id: string; revision: number; enabled: boolean; archived: boolean; security_hash: string; created_at: string; updated_at: string; job: JobForm; baseline: { status: string; samples?: number; attempts?: number; incomplete_attempts?: number; scan_id?: string; modified?: boolean; incidents?: number; pending?: number; host_count?: number }; scan_estimate?: WorkEstimate; scan_cycle?: ScanCycle | null; scan_cycle_error?: 'cycle_status_unavailable'; missing_notification_destinations?: string[] } +export type Job = { id: string; revision: number; enabled: boolean; archived: boolean; security_hash: string; created_at: string; updated_at: string; job: JobForm; baseline: { status: string; samples?: number; attempts?: number; incomplete_attempts?: number; scan_id?: string; modified?: boolean; incidents?: number; pending?: number; host_count?: number }; scan_estimate?: WorkEstimate; scan_budget?: ScanBudget; high_cost_approval_cleared?: boolean; scan_cycle?: ScanCycle | null; scan_cycle_error?: 'cycle_status_unavailable'; missing_notification_destinations?: string[] } +// 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 } export type ActiveScan = {