diff options
| author | Aleksandr Nogikh <nogikh@google.com> | 2025-11-21 10:16:00 +0100 |
|---|---|---|
| committer | Aleksandr Nogikh <nogikh@google.com> | 2025-12-01 09:33:02 +0000 |
| commit | 3887d60d86f72cd0c697fb996e1936a991bfc383 (patch) | |
| tree | 00e79fe46d22728a91c7fd76f7bc4f311862bae9 /syz-cluster | |
| parent | 7d8e572eb37b9122f38f136102bf6801e1021970 (diff) | |
syz-cluster: basic support for finding invalidation
Add some initial #syz invalid support to syz-cluster. For now, mark all
findings as invalid and don't display that such series have findings on
the web dashboard.
Diffstat (limited to 'syz-cluster')
| -rw-r--r-- | syz-cluster/dashboard/templates/series.html | 1 | ||||
| -rw-r--r-- | syz-cluster/email-reporter/handler.go | 14 | ||||
| -rw-r--r-- | syz-cluster/email-reporter/handler_test.go | 33 | ||||
| -rw-r--r-- | syz-cluster/pkg/api/api.go | 1 | ||||
| -rw-r--r-- | syz-cluster/pkg/api/reporter.go | 5 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/entities.go | 23 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/migrations/7_invalidated_findings.down.sql | 1 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/migrations/7_invalidated_findings.up.sql | 1 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/series_repo.go | 8 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/series_repo_test.go | 14 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/util_test.go | 14 | ||||
| -rw-r--r-- | syz-cluster/pkg/report/template.txt | 3 | ||||
| -rw-r--r-- | syz-cluster/pkg/report/testdata/1.moderation.txt | 3 | ||||
| -rw-r--r-- | syz-cluster/pkg/report/testdata/2.moderation.txt | 3 | ||||
| -rw-r--r-- | syz-cluster/pkg/reporter/api.go | 7 | ||||
| -rw-r--r-- | syz-cluster/pkg/reporter/api_test.go | 39 | ||||
| -rw-r--r-- | syz-cluster/pkg/service/finding.go | 21 | ||||
| -rw-r--r-- | syz-cluster/pkg/service/report.go | 11 |
18 files changed, 181 insertions, 21 deletions
diff --git a/syz-cluster/dashboard/templates/series.html b/syz-cluster/dashboard/templates/series.html index a37c7a283..3f349435d 100644 --- a/syz-cluster/dashboard/templates/series.html +++ b/syz-cluster/dashboard/templates/series.html @@ -148,6 +148,7 @@ {{range .Findings}} <tr> <td> + {{if not .InvalidatedAt.IsNull}}<b>[invalidated]</b>{{end}} {{if .ReportURI}} <a href="/findings/{{.ID}}/report" class="modal-link-raw">{{.Title}}</a> {{else}} diff --git a/syz-cluster/email-reporter/handler.go b/syz-cluster/email-reporter/handler.go index 5cfd9dbb3..16f34717f 100644 --- a/syz-cluster/email-reporter/handler.go +++ b/syz-cluster/email-reporter/handler.go @@ -126,19 +126,23 @@ func (h *Handler) IncomingEmail(ctx context.Context, msg *email.Email) error { var reply string for _, command := range msg.Commands { + var err error switch command.Command { case email.CmdUpstream: - err := h.apiClient.UpstreamReport(ctx, reportID, &api.UpstreamReportReq{ + // Reply nothing on success. + err = h.apiClient.UpstreamReport(ctx, reportID, &api.UpstreamReportReq{ User: msg.Author, }) - if err != nil { - reply = fmt.Sprintf("Failed to process the command. Contact %s.", - h.emailConfig.SupportEmail) - } + case email.CmdInvalid: // Reply nothing on success. + err = h.apiClient.InvalidateReport(ctx, reportID) default: reply = "Unknown command" } + if err != nil { + reply = fmt.Sprintf("Failed to process the command. Contact %s.", + h.emailConfig.SupportEmail) + } } if reply == "" { diff --git a/syz-cluster/email-reporter/handler_test.go b/syz-cluster/email-reporter/handler_test.go index d26002764..186897e61 100644 --- a/syz-cluster/email-reporter/handler_test.go +++ b/syz-cluster/email-reporter/handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/google/syzkaller/syz-cluster/pkg/emailclient" "github.com/google/syzkaller/syz-cluster/pkg/reporter" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var testEmailConfig = emailclient.TestEmailConfig() @@ -64,6 +65,38 @@ func TestModerationReportFlow(t *testing.T) { }, receivedEmail) } +func TestReportInvalidationFlow(t *testing.T) { + env, ctx := app.TestEnvironment(t) + testSeries := controller.DummySeries() + handler, _, emailServer := setupHandlerTest(t, env, ctx, testSeries) + + report, err := handler.PollAndReport(ctx) + require.NoError(t, err) + + receivedEmail := emailServer.email() + require.NotNil(t, receivedEmail, "a moderation email must be sent") + receivedEmail.Body = nil // for now don't validate the body + + // Emulate an "upstream" command. + err = handler.IncomingEmail(ctx, &email.Email{ + BugIDs: []string{report.ID}, + Commands: []*email.SingleCommand{ + { + Command: email.CmdInvalid, + }, + }, + }) + require.NoError(t, err) + + // The report must be not sent upstream. + report, err = handler.PollAndReport(ctx) + require.NoError(t, err) + assert.Nil(t, report) + + receivedEmail = emailServer.email() + assert.Nil(t, receivedEmail, "an email must not be sent upstream") +} + func TestInvalidReply(t *testing.T) { env, ctx := app.TestEnvironment(t) testSeries := controller.DummySeries() diff --git a/syz-cluster/pkg/api/api.go b/syz-cluster/pkg/api/api.go index 8b7478312..205180bbb 100644 --- a/syz-cluster/pkg/api/api.go +++ b/syz-cluster/pkg/api/api.go @@ -174,6 +174,7 @@ type Finding struct { Build BuildInfo `json:"build"` LinkCRepro string `json:"c_repro"` LinkSyzRepro string `json:"syz_repro"` + Invalidated bool `json:"invalidated"` } type BuildInfo struct { diff --git a/syz-cluster/pkg/api/reporter.go b/syz-cluster/pkg/api/reporter.go index ea83dfbe6..c01c2b4ae 100644 --- a/syz-cluster/pkg/api/reporter.go +++ b/syz-cluster/pkg/api/reporter.go @@ -45,6 +45,11 @@ func (client ReporterClient) UpstreamReport(ctx context.Context, id string, req return err } +func (client ReporterClient) InvalidateReport(ctx context.Context, id string) error { + _, err := postJSON[any, any](ctx, client.baseURL+"/reports/"+id+"/invalidate", nil) + return err +} + type RecordReplyReq struct { MessageID string `json:"message_id"` ReportID string `json:"report_id"` diff --git a/syz-cluster/pkg/db/entities.go b/syz-cluster/pkg/db/entities.go index 13ba2fb85..563d7e4ec 100644 --- a/syz-cluster/pkg/db/entities.go +++ b/syz-cluster/pkg/db/entities.go @@ -133,15 +133,20 @@ type SessionTest struct { } type Finding struct { - ID string `spanner:"ID"` - SessionID string `spanner:"SessionID"` - TestName string `spanner:"TestName"` - Title string `spanner:"Title"` - ReportURI string `spanner:"ReportURI"` - LogURI string `spanner:"LogURI"` - SyzReproURI string `spanner:"SyzReproURI"` - SyzReproOptsURI string `spanner:"SyzReproOptsURI"` - CReproURI string `spanner:"CReproURI"` + ID string `spanner:"ID"` + SessionID string `spanner:"SessionID"` + TestName string `spanner:"TestName"` + Title string `spanner:"Title"` + ReportURI string `spanner:"ReportURI"` + LogURI string `spanner:"LogURI"` + SyzReproURI string `spanner:"SyzReproURI"` + SyzReproOptsURI string `spanner:"SyzReproOptsURI"` + CReproURI string `spanner:"CReproURI"` + InvalidatedAt spanner.NullTime `spanner:"InvalidatedAt"` +} + +func (f *Finding) SetInvalidatedAt(t time.Time) { + f.InvalidatedAt = spanner.NullTime{Time: t, Valid: true} } type SessionReport struct { diff --git a/syz-cluster/pkg/db/migrations/7_invalidated_findings.down.sql b/syz-cluster/pkg/db/migrations/7_invalidated_findings.down.sql new file mode 100644 index 000000000..a1aada500 --- /dev/null +++ b/syz-cluster/pkg/db/migrations/7_invalidated_findings.down.sql @@ -0,0 +1 @@ +ALTER TABLE Findings DROP COLUMN InvalidatedAt; diff --git a/syz-cluster/pkg/db/migrations/7_invalidated_findings.up.sql b/syz-cluster/pkg/db/migrations/7_invalidated_findings.up.sql new file mode 100644 index 000000000..f4e764e9a --- /dev/null +++ b/syz-cluster/pkg/db/migrations/7_invalidated_findings.up.sql @@ -0,0 +1 @@ +ALTER TABLE Findings ADD COLUMN InvalidatedAt TIMESTAMP DEFAULT(NULL); diff --git a/syz-cluster/pkg/db/series_repo.go b/syz-cluster/pkg/db/series_repo.go index 0c6ce4f79..0ac32415d 100644 --- a/syz-cluster/pkg/db/series_repo.go +++ b/syz-cluster/pkg/db/series_repo.go @@ -173,8 +173,9 @@ func (repo *SeriesRepository) ListLatest(ctx context.Context, filter SeriesFilte stmt.SQL += ")" } if filter.WithFindings { - stmt.SQL += " AND Series.LatestSessionID IS NOT NULL " + - "AND EXISTS(SELECT 1 FROM Findings WHERE Findings.SessionID = Series.LatestSessionID)" + stmt.SQL += " AND Series.LatestSessionID IS NOT NULL AND EXISTS(" + + "SELECT 1 FROM Findings WHERE " + + "Findings.SessionID = Series.LatestSessionID AND Findings.InvalidatedAt IS NULL)" } stmt.SQL += " ORDER BY PublishedAt DESC, ID" if filter.Limit > 0 { @@ -262,7 +263,8 @@ func (repo *SeriesRepository) queryFindingCounts(ctx context.Context, ro *spanne } list, err := readEntities[findingCount](ctx, repo.client.Single(), spanner.Statement{ SQL: "SELECT `SessionID`, COUNT(`ID`) as `Count` FROM `Findings` " + - "WHERE `SessionID` IN UNNEST(@ids) GROUP BY `SessionID`", + "WHERE `SessionID` IN UNNEST(@ids) AND `Findings`.`InvalidatedAt` IS NULL " + + "GROUP BY `SessionID`", Params: map[string]interface{}{ "ids": keys, }, diff --git a/syz-cluster/pkg/db/series_repo_test.go b/syz-cluster/pkg/db/series_repo_test.go index ab13b8e12..576d48b94 100644 --- a/syz-cluster/pkg/db/series_repo_test.go +++ b/syz-cluster/pkg/db/series_repo_test.go @@ -145,7 +145,7 @@ func TestSeriesRepositoryList(t *testing.T) { }) dtd.addSessionTest(session, "test") - dtd.addFinding(session, "title", "test") + finding := dtd.addFinding(session, "title", "test") dtd.finishSession(session) t.Run("query_finding_count", func(t *testing.T) { list, err := repo.ListLatest(ctx, SeriesFilter{Status: SessionStatusFinished}, time.Time{}) @@ -160,6 +160,18 @@ func TestSeriesRepositoryList(t *testing.T) { assert.Len(t, list, 1) assert.Equal(t, "Series 2", list[0].Series.Title) }) + + dtd.invalidateFinding(finding) + t.Run("invalidated_findings", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{WithFindings: true}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 0) + // When not filtered, ensure invalidated findings are not counted in. + list, err = repo.ListLatest(ctx, SeriesFilter{Status: SessionStatusFinished}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, 0, list[0].Findings) + }) } func TestSeriesRepositoryUpdate(t *testing.T) { diff --git a/syz-cluster/pkg/db/util_test.go b/syz-cluster/pkg/db/util_test.go index 6b3d7a3df..f426126c9 100644 --- a/syz-cluster/pkg/db/util_test.go +++ b/syz-cluster/pkg/db/util_test.go @@ -65,11 +65,21 @@ func (d *dummyTestData) finishSession(session *Session) { assert.NoError(d.t, err) } -func (d *dummyTestData) addFinding(session *Session, title, test string) { +func (d *dummyTestData) addFinding(session *Session, title, test string) *Finding { findingRepo := NewFindingRepository(d.client) - assert.NoError(d.t, findingRepo.mustStore(d.ctx, &Finding{ + finding := &Finding{ SessionID: session.ID, Title: title, TestName: test, + } + assert.NoError(d.t, findingRepo.mustStore(d.ctx, finding)) + return finding +} + +func (d *dummyTestData) invalidateFinding(f *Finding) { + findingRepo := NewFindingRepository(d.client) + assert.NoError(d.t, findingRepo.Update(d.ctx, f.ID, func(f *Finding) error { + f.SetInvalidatedAt(time.Now()) + return nil })) } diff --git a/syz-cluster/pkg/report/template.txt b/syz-cluster/pkg/report/template.txt index 023793887..e303c1b13 100644 --- a/syz-cluster/pkg/report/template.txt +++ b/syz-cluster/pkg/report/template.txt @@ -75,4 +75,7 @@ The email will later be sent to: If the report looks fine to you, reply with: #syz upstream + +If the report is a false positive, reply with +#syz invalid {{end}} diff --git a/syz-cluster/pkg/report/testdata/1.moderation.txt b/syz-cluster/pkg/report/testdata/1.moderation.txt index 89d166ed8..d87300fb1 100644 --- a/syz-cluster/pkg/report/testdata/1.moderation.txt +++ b/syz-cluster/pkg/report/testdata/1.moderation.txt @@ -62,3 +62,6 @@ The email will later be sent to: If the report looks fine to you, reply with: #syz upstream +If the report is a false positive, reply with +#syz invalid + diff --git a/syz-cluster/pkg/report/testdata/2.moderation.txt b/syz-cluster/pkg/report/testdata/2.moderation.txt index 8e86ec5f8..16e5b4e46 100644 --- a/syz-cluster/pkg/report/testdata/2.moderation.txt +++ b/syz-cluster/pkg/report/testdata/2.moderation.txt @@ -45,3 +45,6 @@ The email will later be sent to: If the report looks fine to you, reply with: #syz upstream +If the report is a false positive, reply with +#syz invalid + diff --git a/syz-cluster/pkg/reporter/api.go b/syz-cluster/pkg/reporter/api.go index 89bd21eb1..310dd57d1 100644 --- a/syz-cluster/pkg/reporter/api.go +++ b/syz-cluster/pkg/reporter/api.go @@ -31,6 +31,7 @@ func (s *APIServer) Mux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/reports/{report_id}/upstream", s.upstreamReport) mux.HandleFunc("/reports/{report_id}/confirm", s.confirmReport) + mux.HandleFunc("/reports/{report_id}/invalidate", s.invalidateReport) mux.HandleFunc("/reports/record_reply", s.recordReply) mux.HandleFunc("/reports/last_reply", s.lastReply) mux.HandleFunc("/reports", s.nextReports) @@ -48,6 +49,12 @@ func (s *APIServer) upstreamReport(w http.ResponseWriter, r *http.Request) { reply[interface{}](w, nil, err) } +func (s *APIServer) invalidateReport(w http.ResponseWriter, r *http.Request) { + // TODO: journal the action. + err := s.reportService.Invalidate(r.Context(), r.PathValue("report_id")) + reply[interface{}](w, nil, err) +} + func (s *APIServer) nextReports(w http.ResponseWriter, r *http.Request) { resp, err := s.reportService.Next(r.Context(), r.FormValue("reporter")) reply(w, resp, err) diff --git a/syz-cluster/pkg/reporter/api_test.go b/syz-cluster/pkg/reporter/api_test.go index dccff237b..fd62189b6 100644 --- a/syz-cluster/pkg/reporter/api_test.go +++ b/syz-cluster/pkg/reporter/api_test.go @@ -10,7 +10,9 @@ import ( "github.com/google/syzkaller/syz-cluster/pkg/api" "github.com/google/syzkaller/syz-cluster/pkg/app" "github.com/google/syzkaller/syz-cluster/pkg/controller" + "github.com/google/syzkaller/syz-cluster/pkg/service" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIReportFlow(t *testing.T) { @@ -192,3 +194,40 @@ func TestReplyReporting(t *testing.T) { }, resp) }) } + +func TestInvalidate(t *testing.T) { + env, ctx := app.TestEnvironment(t) + client := controller.TestServer(t, env) + testSeries := controller.DummySeries() + ids := controller.FakeSeriesWithFindings(t, ctx, env, client, testSeries) + + generator := NewGenerator(env) + err := generator.Process(ctx, 1) + require.NoError(t, err) + + // Create a report. + reportClient := TestServer(t, env) + nextResp, err := reportClient.GetNextReport(ctx, api.LKMLReporter) + require.NoError(t, err) + reportID := nextResp.Report.ID + err = reportClient.ConfirmReport(ctx, reportID) + require.NoError(t, err) + + // Invalidate the findings. + err = reportClient.InvalidateReport(ctx, reportID) + require.NoError(t, err) + + // Report should not appear in Next(). + emptyNext, err := reportClient.GetNextReport(ctx, api.LKMLReporter) + require.NoError(t, err) + assert.Nil(t, emptyNext.Report) + + // All findings must be invalidated. + findingService := service.NewFindingService(env) + list, err := findingService.List(ctx, ids.SessionID, 0) + require.NoError(t, err) + assert.Len(t, list, 2) + for i, finding := range list { + assert.True(t, finding.Invalidated, "finding %d must be invalidated", i) + } +} diff --git a/syz-cluster/pkg/service/finding.go b/syz-cluster/pkg/service/finding.go index df5f7c2d9..c44783b87 100644 --- a/syz-cluster/pkg/service/finding.go +++ b/syz-cluster/pkg/service/finding.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "time" "github.com/google/syzkaller/syz-cluster/pkg/api" "github.com/google/syzkaller/syz-cluster/pkg/app" @@ -87,6 +88,23 @@ func (s *FindingService) saveAssets(finding *db.Finding, req *api.NewFinding) er return nil } +func (s *FindingService) InvalidateSession(ctx context.Context, sessionID string) error { + findings, err := s.findingRepo.ListForSession(ctx, sessionID, 0) + if err != nil { + return err + } + for _, finding := range findings { + err := s.findingRepo.Update(ctx, finding.ID, func(finding *db.Finding) error { + finding.SetInvalidatedAt(time.Now()) + return nil + }) + if err != nil { + return fmt.Errorf("failed to update finding %s: %w", finding.ID, err) + } + } + return nil +} + func (s *FindingService) List(ctx context.Context, sessionID string, limit int) ([]*api.Finding, error) { list, err := s.findingRepo.ListForSession(ctx, sessionID, limit) if err != nil { @@ -112,6 +130,9 @@ func (s *FindingService) List(ctx context.Context, sessionID string, limit int) if item.CReproURI != "" { finding.LinkCRepro = s.urls.FindingCRepro(item.ID) } + if !item.InvalidatedAt.IsNull() { + finding.Invalidated = true + } build := testPerName[item.TestName].PatchedBuild if build != nil { finding.Build = makeBuildInfo(s.urls, build) diff --git a/syz-cluster/pkg/service/report.go b/syz-cluster/pkg/service/report.go index c92ccfa11..a33c9d889 100644 --- a/syz-cluster/pkg/service/report.go +++ b/syz-cluster/pkg/service/report.go @@ -51,7 +51,7 @@ var ErrNotOnModeration = errors.New("the report is not on moderation") func (rs *ReportService) Upstream(ctx context.Context, id string, req *api.UpstreamReportReq) error { rep, err := rs.query(ctx, id) if err != nil { - return nil + return err } else if !rep.Moderation { return ErrNotOnModeration } @@ -68,6 +68,15 @@ func (rs *ReportService) Upstream(ctx context.Context, id string, req *api.Upstr return nil } +func (rs *ReportService) Invalidate(ctx context.Context, id string) error { + rep, err := rs.query(ctx, id) + if err != nil { + return err + } + // For now, invalidate all the findings at once - later we can do it more selectively. + return rs.findingService.InvalidateSession(ctx, rep.SessionID) +} + const maxFindingsPerReport = 5 func (rs *ReportService) Next(ctx context.Context, reporter string) (*api.NextReportResp, error) { |
