Skip to content

Commit 2185e5c

Browse files
authored
fix(security): make scan report latency independent of scan history (MCP-2205) (#655)
* fix(security): make scan report latency independent of scan history (MCP-2205) GetScanReportByJobID and the latest-report path resolved companion/latest Pass-1/Pass-2 jobs via storage.ListScanJobs(serverName), which deserializes every scan job for the server — each carrying large scanner stdout/stderr in ScannerStatuses. Cold report latency therefore grew with a server's scan history (measured ~2.0s cold for a 13.8KB report on a server with 50 jobs). Introduce a lightweight persisted ScanJobMeta index (security_scan_job_index bucket), maintained on SaveScanJob/DeleteScanJob/DeleteServerScanJobs and backfilled on open for pre-existing databases. Companion and latest-job lookups now read this index (small fixed-size records, no stdout/stderr) and fetch only the one or two full jobs actually needed, so report latency no longer scales with scan-output size or history depth. Deterministic tests assert the report hot paths never invoke the full-history ListScanJobs, plus storage-level coverage for the index (projection, filtering, delete maintenance, and backfill). * style(security): use tagged switch in findLatestPassJobs (golangci-lint QF1002)
1 parent f4916d0 commit 2185e5c

8 files changed

Lines changed: 511 additions & 46 deletions

File tree

internal/security/scanner/service.go

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Storage interface {
3232
SaveScanJob(job *ScanJob) error
3333
GetScanJob(id string) (*ScanJob, error)
3434
ListScanJobs(serverName string) ([]*ScanJob, error)
35+
ListScanJobMetas(serverName string) ([]*ScanJobMeta, error)
3536
GetLatestScanJob(serverName string) (*ScanJob, error)
3637
DeleteScanJob(id string) error
3738
DeleteServerScanJobs(serverName string) error
@@ -1083,25 +1084,23 @@ func (s *Service) GetScanReportByJobID(ctx context.Context, jobID string) (*Aggr
10831084
agg.Pass1Complete = job.ScanPass == ScanPassSecurityScan && job.Status == ScanJobStatusCompleted
10841085
agg.Pass2Complete = job.ScanPass == ScanPassSupplyChainAudit && job.Status == ScanJobStatusCompleted
10851086

1086-
// If this is a Pass 1 job, try to find and merge companion Pass 2 results
1087+
// If this is a Pass 1 job, try to find and merge companion Pass 2 results.
1088+
// The companion is resolved via the lightweight scan-job index, so this does
1089+
// NOT deserialize the full per-server scan history (MCP-2205).
10871090
if job.ScanPass == ScanPassSecurityScan || job.ScanPass == 0 {
1088-
allJobs, _ := s.storage.ListScanJobs(job.ServerName)
1089-
for _, j := range allJobs {
1090-
if j.ScanPass == ScanPassSupplyChainAudit && j.Status == ScanJobStatusCompleted && j.StartedAt.After(job.StartedAt) {
1091-
pass2Reports, err := s.storage.ListScanReportsByJob(j.ID)
1092-
if err == nil {
1093-
for _, r := range pass2Reports {
1094-
for i := range r.Findings {
1095-
r.Findings[i].ScanPass = ScanPassSupplyChainAudit
1096-
}
1091+
if companionID := s.findCompanionPass2JobID(job); companionID != "" {
1092+
pass2Reports, err := s.storage.ListScanReportsByJob(companionID)
1093+
if err == nil {
1094+
for _, r := range pass2Reports {
1095+
for i := range r.Findings {
1096+
r.Findings[i].ScanPass = ScanPassSupplyChainAudit
10971097
}
1098-
allMerged := append(reports, pass2Reports...)
1099-
allMerged = deduplicatePass2Findings(allMerged)
1100-
agg = AggregateReportsWithJobStatus(job.ID, job.ServerName, allMerged, job)
1101-
agg.Pass1Complete = true
1102-
agg.Pass2Complete = true
11031098
}
1104-
break
1099+
allMerged := append(reports, pass2Reports...)
1100+
allMerged = deduplicatePass2Findings(allMerged)
1101+
agg = AggregateReportsWithJobStatus(job.ID, job.ServerName, allMerged, job)
1102+
agg.Pass1Complete = true
1103+
agg.Pass2Complete = true
11051104
}
11061105
}
11071106

@@ -1118,6 +1117,40 @@ func (s *Service) GetScanReportByJobID(ctx context.Context, jobID string) (*Aggr
11181117
return agg, nil
11191118
}
11201119

1120+
// findCompanionPass2JobID returns the ID of the Pass-2 (supply-chain audit) job
1121+
// that companions the given Pass-1 job: the earliest completed Pass-2 job that
1122+
// started after it. It reads the lightweight scan-job metadata index rather than
1123+
// the full job records, so its cost is independent of scan-output size and the
1124+
// report path no longer slows down as a server accrues scan history (MCP-2205).
1125+
// Returns "" when no companion exists.
1126+
func (s *Service) findCompanionPass2JobID(pass1 *ScanJob) string {
1127+
metas, err := s.storage.ListScanJobMetas(pass1.ServerName)
1128+
if err != nil {
1129+
s.logger.Warn("failed to list scan job metadata for companion lookup",
1130+
zap.String("server", pass1.ServerName),
1131+
zap.Error(err),
1132+
)
1133+
return ""
1134+
}
1135+
1136+
var best *ScanJobMeta
1137+
for _, m := range metas {
1138+
if m.ScanPass != ScanPassSupplyChainAudit || m.Status != ScanJobStatusCompleted {
1139+
continue
1140+
}
1141+
if !m.StartedAt.After(pass1.StartedAt) {
1142+
continue
1143+
}
1144+
if best == nil || m.StartedAt.Before(best.StartedAt) {
1145+
best = m
1146+
}
1147+
}
1148+
if best == nil {
1149+
return ""
1150+
}
1151+
return best.ID
1152+
}
1153+
11211154
// deduplicatePass2Findings removes Pass 2 findings that duplicate Pass 1 findings.
11221155
// The dedup key is scanner + rule_id + title (not location, since paths may differ between passes).
11231156
func deduplicatePass2Findings(reports []*ScanReport) []*ScanReport {
@@ -1158,38 +1191,51 @@ func deduplicatePass2Findings(reports []*ScanReport) []*ScanReport {
11581191
// findLatestPassJobs finds the latest Pass 1 and Pass 2 jobs for a server.
11591192
// Returns (pass1Job, pass2Job, error). At least one must be non-nil on success.
11601193
func (s *Service) findLatestPassJobs(serverName string) (*ScanJob, *ScanJob, error) {
1161-
jobs, err := s.storage.ListScanJobs(serverName)
1194+
// Read lightweight metadata rather than full job records so this scales with
1195+
// neither scan-output size nor history depth: we deserialize at most the two
1196+
// jobs we actually return (MCP-2205).
1197+
metas, err := s.storage.ListScanJobMetas(serverName)
11621198
if err != nil {
11631199
// Surface the underlying I/O error so the caller can distinguish
11641200
// transient failures from "no records found".
1165-
return nil, nil, fmt.Errorf("list scan jobs for %s: %w", serverName, err)
1201+
return nil, nil, fmt.Errorf("list scan job metadata for %s: %w", serverName, err)
11661202
}
1167-
if len(jobs) == 0 {
1203+
if len(metas) == 0 {
11681204
return nil, nil, fmt.Errorf("%w: %s", errNoScans, serverName)
11691205
}
11701206

1171-
// Sort by start time descending (newest first)
1172-
sort.Slice(jobs, func(i, j int) bool {
1173-
return jobs[i].StartedAt.After(jobs[j].StartedAt)
1174-
})
1175-
1176-
var pass1Job, pass2Job *ScanJob
1177-
for _, j := range jobs {
1178-
if j.ScanPass == ScanPassSupplyChainAudit && pass2Job == nil {
1179-
pass2Job = j
1180-
} else if (j.ScanPass == ScanPassSecurityScan || j.ScanPass == 0) && pass1Job == nil {
1207+
// Pick the newest Pass-1 and Pass-2 job IDs by start time.
1208+
var pass1Meta, pass2Meta *ScanJobMeta
1209+
for _, m := range metas {
1210+
switch m.ScanPass {
1211+
case ScanPassSupplyChainAudit:
1212+
if pass2Meta == nil || m.StartedAt.After(pass2Meta.StartedAt) {
1213+
pass2Meta = m
1214+
}
1215+
case ScanPassSecurityScan, 0:
11811216
// ScanPass == 0 handles legacy jobs (before two-pass was added)
1182-
pass1Job = j
1183-
}
1184-
if pass1Job != nil && pass2Job != nil {
1185-
break
1217+
if pass1Meta == nil || m.StartedAt.After(pass1Meta.StartedAt) {
1218+
pass1Meta = m
1219+
}
11861220
}
11871221
}
11881222

1189-
if pass1Job == nil && pass2Job == nil {
1223+
if pass1Meta == nil && pass2Meta == nil {
11901224
return nil, nil, fmt.Errorf("%w: %s", errNoScans, serverName)
11911225
}
11921226

1227+
var pass1Job, pass2Job *ScanJob
1228+
if pass1Meta != nil {
1229+
if pass1Job, err = s.storage.GetScanJob(pass1Meta.ID); err != nil {
1230+
return nil, nil, fmt.Errorf("load latest pass-1 job %s: %w", pass1Meta.ID, err)
1231+
}
1232+
}
1233+
if pass2Meta != nil {
1234+
if pass2Job, err = s.storage.GetScanJob(pass2Meta.ID); err != nil {
1235+
return nil, nil, fmt.Errorf("load latest pass-2 job %s: %w", pass2Meta.ID, err)
1236+
}
1237+
}
1238+
11931239
return pass1Job, pass2Job, nil
11941240
}
11951241

internal/security/scanner/service_test.go

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,26 @@ func (m *mockStorage) ListScanJobs(serverName string) ([]*ScanJob, error) {
9393
return result, nil
9494
}
9595

96+
func (m *mockStorage) ListScanJobMetas(serverName string) ([]*ScanJobMeta, error) {
97+
m.mu.Lock()
98+
defer m.mu.Unlock()
99+
var result []*ScanJobMeta
100+
for _, j := range m.jobs {
101+
if serverName != "" && j.ServerName != serverName {
102+
continue
103+
}
104+
result = append(result, &ScanJobMeta{
105+
ID: j.ID,
106+
ServerName: j.ServerName,
107+
Status: j.Status,
108+
ScanPass: j.ScanPass,
109+
StartedAt: j.StartedAt,
110+
CompletedAt: j.CompletedAt,
111+
})
112+
}
113+
return result, nil
114+
}
115+
96116
func (m *mockStorage) GetLatestScanJob(serverName string) (*ScanJob, error) {
97117
m.mu.Lock()
98118
defer m.mu.Unlock()
@@ -1545,12 +1565,14 @@ func TestScanFindingScanPassTag(t *testing.T) {
15451565
}
15461566
}
15471567

1548-
// countingStorage wraps any Storage and counts ListScanJobs invocations.
1549-
// Used for verifying the GetScanSummary negative-cache behavior introduced in
1550-
// spec 047.
1568+
// countingStorage wraps any Storage and counts storage probes. listCalls counts
1569+
// the heavy full-history ListScanJobs path (MCP-2205 asserts this stays 0 on the
1570+
// report hot paths); metaCalls counts the lightweight ListScanJobMetas index
1571+
// path used by the GetScanSummary negative-cache behavior (spec 047).
15511572
type countingStorage struct {
15521573
Storage
15531574
listCalls atomic.Int64
1575+
metaCalls atomic.Int64
15541576
}
15551577

15561578
func newCountingStorage(inner Storage) *countingStorage {
@@ -1562,6 +1584,11 @@ func (c *countingStorage) ListScanJobs(serverName string) ([]*ScanJob, error) {
15621584
return c.Storage.ListScanJobs(serverName)
15631585
}
15641586

1587+
func (c *countingStorage) ListScanJobMetas(serverName string) ([]*ScanJobMeta, error) {
1588+
c.metaCalls.Add(1)
1589+
return c.Storage.ListScanJobMetas(serverName)
1590+
}
1591+
15651592
// erroringStorage returns a transient error from ListScanJobs while delegating
15661593
// other calls to the inner Storage. Used to verify that non-errNoScans errors
15671594
// do NOT populate the negative cache.
@@ -1576,6 +1603,11 @@ func (e *erroringStorage) ListScanJobs(string) ([]*ScanJob, error) {
15761603
return nil, e.err
15771604
}
15781605

1606+
func (e *erroringStorage) ListScanJobMetas(string) ([]*ScanJobMeta, error) {
1607+
e.listCalls.Add(1)
1608+
return nil, e.err
1609+
}
1610+
15791611
// Spec 047 — Phase 3 (US1): cache the "no scans found" sentinel.
15801612

15811613
func TestGetScanSummary_CachesNegativeResult(t *testing.T) {
@@ -1590,8 +1622,8 @@ func TestGetScanSummary_CachesNegativeResult(t *testing.T) {
15901622
}
15911623

15921624
// Without the negative-cache fix, this would be N storage calls.
1593-
if got := store.listCalls.Load(); got != 1 {
1594-
t.Errorf("expected exactly 1 ListScanJobs call after %d GetScanSummary invocations, got %d", N, got)
1625+
if got := store.metaCalls.Load(); got != 1 {
1626+
t.Errorf("expected exactly 1 ListScanJobMetas call after %d GetScanSummary invocations, got %d", N, got)
15951627
}
15961628
}
15971629

@@ -1606,7 +1638,7 @@ func TestGetScanSummary_DoesNotCacheOnTransientError(t *testing.T) {
16061638

16071639
// Transient error must NOT populate the negative cache: every call retries.
16081640
if got := store.listCalls.Load(); got != int64(N) {
1609-
t.Errorf("expected %d ListScanJobs calls (no caching of transient errors), got %d", N, got)
1641+
t.Errorf("expected %d storage probes (no caching of transient errors), got %d", N, got)
16101642
}
16111643
}
16121644

@@ -1619,8 +1651,8 @@ func TestGetScanSummary_OverwritesNilSentinelOnRealScan(t *testing.T) {
16191651
if got := svc.GetScanSummary(context.Background(), "later-scanned"); got != nil {
16201652
t.Fatalf("expected nil summary on first call, got %+v", got)
16211653
}
1622-
if got := store.listCalls.Load(); got != 1 {
1623-
t.Fatalf("expected 1 ListScanJobs call after first GetScanSummary, got %d", got)
1654+
if got := store.metaCalls.Load(); got != 1 {
1655+
t.Fatalf("expected 1 ListScanJobMetas call after first GetScanSummary, got %d", got)
16241656
}
16251657

16261658
// Simulate a real scan landing for that server: insert a completed Pass-1 job
@@ -1646,3 +1678,95 @@ func TestGetScanSummary_OverwritesNilSentinelOnRealScan(t *testing.T) {
16461678
t.Errorf("expected real summary {Status: clean}, got %+v", got)
16471679
}
16481680
}
1681+
1682+
// TestGetScanReportByJobID_LatencyIndependentOfHistory verifies the MCP-2205
1683+
// fix: aggregating a Pass-1 report must NOT deserialize the full per-server scan
1684+
// history (ListScanJobs), whose job payloads carry large stdout/stderr. The
1685+
// companion Pass-2 lookup uses the lightweight metadata index instead, so report
1686+
// latency does not grow with how many times a server has been scanned.
1687+
func TestGetScanReportByJobID_LatencyIndependentOfHistory(t *testing.T) {
1688+
mock := newMockStorage()
1689+
store := newCountingStorage(mock)
1690+
svc := NewService(store, NewRegistry(t.TempDir(), zap.NewNop()), nil, t.TempDir(), zap.NewNop())
1691+
1692+
now := time.Now()
1693+
1694+
// Pass-1 job under inspection + its report.
1695+
pass1 := &ScanJob{ID: "p1", ServerName: "srv", Status: ScanJobStatusCompleted, ScanPass: ScanPassSecurityScan, StartedAt: now.Add(-10 * time.Minute)}
1696+
_ = mock.SaveScanJob(pass1)
1697+
_ = mock.SaveScanReport(&ScanReport{ID: "r1", JobID: "p1", ServerName: "srv", ScannerID: "s",
1698+
Findings: []ScanFinding{{RuleID: "T1", Title: "tool poisoning", Scanner: "s", ThreatLevel: ThreatLevelDangerous}}})
1699+
1700+
// Companion Pass-2 job that started after Pass-1 + its report.
1701+
pass2 := &ScanJob{ID: "p2", ServerName: "srv", Status: ScanJobStatusCompleted, ScanPass: ScanPassSupplyChainAudit, StartedAt: now.Add(-8 * time.Minute)}
1702+
_ = mock.SaveScanJob(pass2)
1703+
_ = mock.SaveScanReport(&ScanReport{ID: "r2", JobID: "p2", ServerName: "srv", ScannerID: "s",
1704+
Findings: []ScanFinding{{RuleID: "CVE-1", Title: "known cve", Scanner: "s", ThreatLevel: ThreatLevelWarning}}})
1705+
1706+
// Large historical backlog for the same server (the symptom: reports get
1707+
// slower the more a server is scanned).
1708+
for i := 0; i < 100; i++ {
1709+
_ = mock.SaveScanJob(&ScanJob{
1710+
ID: fmt.Sprintf("noise-%d", i),
1711+
ServerName: "srv",
1712+
Status: ScanJobStatusCompleted,
1713+
ScanPass: ScanPassSecurityScan,
1714+
StartedAt: now.Add(time.Duration(-20-i) * time.Minute),
1715+
})
1716+
}
1717+
1718+
agg, err := svc.GetScanReportByJobID(context.Background(), "p1")
1719+
if err != nil {
1720+
t.Fatalf("GetScanReportByJobID: %v", err)
1721+
}
1722+
1723+
// Correctness: companion Pass-2 still merged in.
1724+
if !agg.Pass1Complete || !agg.Pass2Complete {
1725+
t.Errorf("expected both passes complete, got pass1=%v pass2=%v", agg.Pass1Complete, agg.Pass2Complete)
1726+
}
1727+
if len(agg.Findings) != 2 {
1728+
t.Fatalf("expected 2 merged findings (Pass-1 + companion Pass-2), got %d", len(agg.Findings))
1729+
}
1730+
1731+
// Latency guard: must not scan the full per-server job history.
1732+
if got := store.listCalls.Load(); got != 0 {
1733+
t.Errorf("expected 0 ListScanJobs (full-history) calls in report path, got %d", got)
1734+
}
1735+
}
1736+
1737+
// TestGetScanReport_LatestLatencyIndependentOfHistory verifies the MCP-2205 fix
1738+
// also covers the "latest report" path (GetScanReport by server name, used by the
1739+
// Web UI server-detail view). Resolving the latest Pass-1/Pass-2 jobs must use the
1740+
// lightweight metadata index plus targeted GetScanJob loads (at most two full job
1741+
// deserializations), not a full ListScanJobs over the server's scan history.
1742+
func TestGetScanReport_LatestLatencyIndependentOfHistory(t *testing.T) {
1743+
mock := newMockStorage()
1744+
store := newCountingStorage(mock)
1745+
svc := NewService(store, NewRegistry(t.TempDir(), zap.NewNop()), nil, t.TempDir(), zap.NewNop())
1746+
1747+
now := time.Now()
1748+
1749+
// Latest Pass-1 + Pass-2 with reports.
1750+
_ = mock.SaveScanJob(&ScanJob{ID: "latest-p1", ServerName: "srv", Status: ScanJobStatusCompleted, ScanPass: ScanPassSecurityScan, StartedAt: now.Add(-2 * time.Minute)})
1751+
_ = mock.SaveScanReport(&ScanReport{ID: "lr1", JobID: "latest-p1", ServerName: "srv", ScannerID: "s",
1752+
Findings: []ScanFinding{{RuleID: "T1", Title: "tp", Scanner: "s", ThreatLevel: ThreatLevelDangerous}}})
1753+
_ = mock.SaveScanJob(&ScanJob{ID: "latest-p2", ServerName: "srv", Status: ScanJobStatusCompleted, ScanPass: ScanPassSupplyChainAudit, StartedAt: now.Add(-1 * time.Minute)})
1754+
_ = mock.SaveScanReport(&ScanReport{ID: "lr2", JobID: "latest-p2", ServerName: "srv", ScannerID: "s",
1755+
Findings: []ScanFinding{{RuleID: "CVE-9", Title: "cve", Scanner: "s", ThreatLevel: ThreatLevelWarning}}})
1756+
1757+
// Large historical backlog.
1758+
for i := 0; i < 100; i++ {
1759+
_ = mock.SaveScanJob(&ScanJob{ID: fmt.Sprintf("old-%d", i), ServerName: "srv", Status: ScanJobStatusCompleted, ScanPass: ScanPassSecurityScan, StartedAt: now.Add(time.Duration(-10-i) * time.Minute)})
1760+
}
1761+
1762+
report, err := svc.GetScanReport(context.Background(), "srv")
1763+
if err != nil {
1764+
t.Fatalf("GetScanReport: %v", err)
1765+
}
1766+
if len(report.Findings) != 2 {
1767+
t.Fatalf("expected 2 merged findings, got %d", len(report.Findings))
1768+
}
1769+
if got := store.listCalls.Load(); got != 0 {
1770+
t.Errorf("expected 0 ListScanJobs (full-history) calls in latest-report path, got %d", got)
1771+
}
1772+
}

internal/security/scanner/types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,20 @@ type ScanJob struct {
120120
ScanContext *ScanContext `json:"scan_context,omitempty"`
121121
}
122122

123+
// ScanJobMeta is a lightweight projection of a scan job, persisted in a
124+
// dedicated index bucket so that companion-job lookups during report
125+
// aggregation never deserialize the full job payload (whose ScannerStatuses can
126+
// carry large stdout/stderr blobs). This keeps report latency independent of a
127+
// server's scan history. See MCP-2205.
128+
type ScanJobMeta struct {
129+
ID string `json:"id"`
130+
ServerName string `json:"server_name"`
131+
Status string `json:"status"`
132+
ScanPass int `json:"scan_pass"`
133+
StartedAt time.Time `json:"started_at"`
134+
CompletedAt time.Time `json:"completed_at,omitempty"`
135+
}
136+
123137
// ScanJobSummary is a lightweight view of a scan job for history listing
124138
type ScanJobSummary struct {
125139
ID string `json:"id"`

internal/storage/bbolt.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ func (b *BoltDB) initBuckets() error {
9393
ActivityStatsBucket,
9494
ScannersBucket,
9595
ScanJobsBucket,
96+
ScanJobIndexBucket,
9697
ScanReportsBucket,
9798
IntegrityBaselinesBucket,
9899
OnboardingBucket,
@@ -104,6 +105,12 @@ func (b *BoltDB) initBuckets() error {
104105
}
105106
}
106107

108+
// Backfill the scan-job index for databases created before MCP-2205.
109+
// Idempotent: only runs when the index is empty but jobs exist.
110+
if err := backfillScanJobIndex(tx); err != nil {
111+
return fmt.Errorf("failed to backfill scan job index: %w", err)
112+
}
113+
107114
// Set schema version only for new databases. Existing databases keep their
108115
// stored version so migrations can observe and upgrade them.
109116
metaBucket := tx.Bucket([]byte(MetaBucket))

0 commit comments

Comments
 (0)