aboutsummaryrefslogtreecommitdiffstats
path: root/syz-cluster
diff options
context:
space:
mode:
authorAleksandr Nogikh <nogikh@google.com>2025-11-21 10:16:00 +0100
committerAleksandr Nogikh <nogikh@google.com>2025-12-01 09:33:02 +0000
commit3887d60d86f72cd0c697fb996e1936a991bfc383 (patch)
tree00e79fe46d22728a91c7fd76f7bc4f311862bae9 /syz-cluster
parent7d8e572eb37b9122f38f136102bf6801e1021970 (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.html1
-rw-r--r--syz-cluster/email-reporter/handler.go14
-rw-r--r--syz-cluster/email-reporter/handler_test.go33
-rw-r--r--syz-cluster/pkg/api/api.go1
-rw-r--r--syz-cluster/pkg/api/reporter.go5
-rw-r--r--syz-cluster/pkg/db/entities.go23
-rw-r--r--syz-cluster/pkg/db/migrations/7_invalidated_findings.down.sql1
-rw-r--r--syz-cluster/pkg/db/migrations/7_invalidated_findings.up.sql1
-rw-r--r--syz-cluster/pkg/db/series_repo.go8
-rw-r--r--syz-cluster/pkg/db/series_repo_test.go14
-rw-r--r--syz-cluster/pkg/db/util_test.go14
-rw-r--r--syz-cluster/pkg/report/template.txt3
-rw-r--r--syz-cluster/pkg/report/testdata/1.moderation.txt3
-rw-r--r--syz-cluster/pkg/report/testdata/2.moderation.txt3
-rw-r--r--syz-cluster/pkg/reporter/api.go7
-rw-r--r--syz-cluster/pkg/reporter/api_test.go39
-rw-r--r--syz-cluster/pkg/service/finding.go21
-rw-r--r--syz-cluster/pkg/service/report.go11
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) {