From 14294dee0dad4f798ad9027fa0bb7b6e8cc748f9 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 8 Nov 2026 17:51:16 +0000 Subject: [PATCH 1/4] Add bounded SARIF summaries --- README.md | 30 +++ summary/summary.go | 584 ++++++++++++++++++++++++++++++++++++++++ summary/summary_test.go | 220 +++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 summary/summary.go create mode 100644 summary/summary_test.go diff --git a/README.md b/README.md index f0a14ce..f5d928a 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,36 @@ if sarif.Valid(log) { } ``` +## Bounded summaries + +The `summary` package reads aggregate evidence without constructing the full +generated SARIF model. Input bytes, distinct values, and tool records are +bounded for use with stored analysis logs: + +```go +file, err := os.Open("results.sarif") +if err != nil { + log.Fatal(err) +} +defer file.Close() + +result, err := summary.Inspect(file, summary.Options{ + MaxBytes: 16 << 20, + MaxUniqueValues: 5_000, + MaxTools: 100, +}) +if err != nil { + log.Fatal(err) +} + +fmt.Println(result.Tools, result.Levels, result.Rules) +``` + +The summary includes runs, results, occurrence counts, tools, effective result +levels, baseline states, rules, taxa, and suppressions. Level resolution uses +non-failing result kinds, explicit result levels, and rule defaults referenced +by index or ID. `Truncated` is true when a value or tool limit was reached. + ## Regenerating Types Types are generated from the bundled SARIF JSON schema: diff --git a/summary/summary.go b/summary/summary.go new file mode 100644 index 0000000..261822c --- /dev/null +++ b/summary/summary.go @@ -0,0 +1,584 @@ +// Package summary extracts bounded aggregate evidence from SARIF logs. +package summary + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" +) + +const ( + DefaultMaxBytes = 64 << 20 + DefaultMaxUniqueValues = 10_000 + DefaultMaxTools = 1_000 +) + +var ( + ErrInvalid = errors.New("invalid SARIF") + ErrLimit = errors.New("sarif summary limit exceeded") +) + +type Options struct { + MaxBytes int64 + MaxUniqueValues int + MaxTools int +} + +type Tool struct { + Name string + Version string + SemanticVersion string +} + +type ruleDefinition struct { + ID string + Level string +} + +type ruleReference struct { + ID string + Index int + HasIndex bool +} + +type suppression struct { + Kind string `json:"kind"` + Status string `json:"status"` + State string `json:"state"` +} + +type Summary struct { + Version string + Runs int + Results int + Occurrences int + SuppressedResults int + Tools []Tool + Levels map[string]int + BaselineStates map[string]int + Rules map[string]int + Taxa map[string]int + Suppressions map[string]int + Truncated bool +} + +func Inspect(reader io.Reader, options Options) (Summary, error) { + options, err := options.defaults() + if err != nil { + return Summary{}, err + } + if reader == nil { + return Summary{}, errors.New("inspect SARIF: nil reader") + } + + limited := &limitReader{reader: reader, remaining: options.MaxBytes} + decoder := json.NewDecoder(limited) + summary := newSummary() + if err := inspectLog(decoder, &summary, options); err != nil { + return finishSummary(summary), inspectError(err, limited) + } + if token, err := decoder.Token(); err != io.EOF { + if err != nil { + return finishSummary(summary), inspectError(err, limited) + } + return finishSummary(summary), fmt.Errorf("inspect SARIF: %w: unexpected trailing token %v", ErrInvalid, token) + } + return finishSummary(summary), nil +} + +func finishSummary(summary Summary) Summary { + slices.SortFunc(summary.Tools, func(left, right Tool) int { + if compared := strings.Compare(left.Name, right.Name); compared != 0 { + return compared + } + if compared := strings.Compare(left.SemanticVersion, right.SemanticVersion); compared != 0 { + return compared + } + return strings.Compare(left.Version, right.Version) + }) + return summary +} + +func inspectError(err error, reader *limitReader) error { + switch { + case errors.Is(err, ErrLimit): + return fmt.Errorf("inspect SARIF: %w", err) + case reader.readErr != nil: + return fmt.Errorf("inspect SARIF: %w", reader.readErr) + default: + return fmt.Errorf("inspect SARIF: %w: %v", ErrInvalid, err) + } +} + +func (options Options) defaults() (Options, error) { + if options.MaxBytes < 0 || options.MaxUniqueValues < 0 || options.MaxTools < 0 { + return Options{}, errors.New("SARIF summary limits must not be negative") + } + if options.MaxBytes == 0 { + options.MaxBytes = DefaultMaxBytes + } + if options.MaxUniqueValues == 0 { + options.MaxUniqueValues = DefaultMaxUniqueValues + } + if options.MaxTools == 0 { + options.MaxTools = DefaultMaxTools + } + return options, nil +} + +func newSummary() Summary { + return Summary{ + Levels: make(map[string]int), + BaselineStates: make(map[string]int), + Rules: make(map[string]int), + Taxa: make(map[string]int), + Suppressions: make(map[string]int), + } +} + +func inspectLog(decoder *json.Decoder, summary *Summary, options Options) error { + if err := expectDelimiter(decoder, '{'); err != nil { + return err + } + hasRuns := false + for decoder.More() { + name, err := objectName(decoder) + if err != nil { + return err + } + switch name { + case "version": + err = decoder.Decode(&summary.Version) + case "runs": + hasRuns = true + err = inspectRuns(decoder, summary, options) + default: + err = skipValue(decoder) + } + if err != nil { + return err + } + } + if err := expectDelimiter(decoder, '}'); err != nil { + return err + } + if summary.Version == "" { + return fmt.Errorf("%w: missing version", ErrInvalid) + } + if !hasRuns { + return fmt.Errorf("%w: missing runs", ErrInvalid) + } + return nil +} + +func inspectRuns(decoder *json.Decoder, summary *Summary, options Options) error { + if err := expectDelimiter(decoder, '['); err != nil { + return err + } + for decoder.More() { + summary.Runs++ + if err := inspectRun(decoder, summary, options); err != nil { + return err + } + } + return expectDelimiter(decoder, ']') +} + +func inspectRun(decoder *json.Decoder, summary *Summary, options Options) error { + if err := expectDelimiter(decoder, '{'); err != nil { + return err + } + var driverRules []ruleDefinition + indexedRules := make(map[int]int) + pendingLevels := make(map[ruleReference]int) + for decoder.More() { + name, err := objectName(decoder) + if err != nil { + return err + } + switch name { + case "tool": + driverRules, err = inspectTool(decoder, summary, options) + case "results": + err = inspectResults(decoder, summary, indexedRules, pendingLevels, options) + default: + err = skipValue(decoder) + } + if err != nil { + return err + } + } + if err := expectDelimiter(decoder, '}'); err != nil { + return err + } + for index, count := range indexedRules { + if index >= 0 && index < len(driverRules) && driverRules[index].ID != "" { + addCount(summary.Rules, driverRules[index].ID, count, options.MaxUniqueValues, &summary.Truncated) + } else { + addCount(summary.Rules, "", count, options.MaxUniqueValues, &summary.Truncated) + } + } + resolveResultLevels(summary, driverRules, pendingLevels, options.MaxUniqueValues) + return nil +} + +func inspectTool(decoder *json.Decoder, summary *Summary, options Options) ([]ruleDefinition, error) { + if err := expectDelimiter(decoder, '{'); err != nil { + return nil, err + } + var driverRules []ruleDefinition + for decoder.More() { + name, err := objectName(decoder) + if err != nil { + return nil, err + } + switch name { + case "driver": + var tool Tool + tool, driverRules, err = inspectToolComponent(decoder, true, options.MaxUniqueValues, &summary.Truncated) + addTool(summary, tool, options.MaxTools) + case "extensions": + err = inspectToolExtensions(decoder, summary, options.MaxTools) + default: + err = skipValue(decoder) + } + if err != nil { + return nil, err + } + } + return driverRules, expectDelimiter(decoder, '}') +} + +func inspectToolExtensions(decoder *json.Decoder, summary *Summary, limit int) error { + if err := expectDelimiter(decoder, '['); err != nil { + return err + } + for decoder.More() { + tool, _, err := inspectToolComponent(decoder, false, 0, nil) + if err != nil { + return err + } + addTool(summary, tool, limit) + } + return expectDelimiter(decoder, ']') +} + +func inspectToolComponent( + decoder *json.Decoder, + rules bool, + maxRules int, + truncated *bool, +) (Tool, []ruleDefinition, error) { + if err := expectDelimiter(decoder, '{'); err != nil { + return Tool{}, nil, err + } + var tool Tool + var ruleDefinitions []ruleDefinition + for decoder.More() { + name, err := objectName(decoder) + if err != nil { + return Tool{}, nil, err + } + switch name { + case "name": + err = decoder.Decode(&tool.Name) + case "version": + err = decoder.Decode(&tool.Version) + case "semanticVersion": + err = decoder.Decode(&tool.SemanticVersion) + case "rules": + if rules { + ruleDefinitions, err = inspectRuleDefinitions(decoder, maxRules, truncated) + } else { + err = skipValue(decoder) + } + default: + err = skipValue(decoder) + } + if err != nil { + return Tool{}, nil, err + } + } + return tool, ruleDefinitions, expectDelimiter(decoder, '}') +} + +func inspectRuleDefinitions(decoder *json.Decoder, limit int, truncated *bool) ([]ruleDefinition, error) { + if err := expectDelimiter(decoder, '['); err != nil { + return nil, err + } + var definitions []ruleDefinition + for decoder.More() { + var rule struct { + ID string `json:"id"` + DefaultConfiguration struct { + Level string `json:"level"` + } `json:"defaultConfiguration"` + } + if err := decoder.Decode(&rule); err != nil { + return nil, err + } + if len(definitions) < limit { + definitions = append(definitions, ruleDefinition{ID: rule.ID, Level: rule.DefaultConfiguration.Level}) + } else if truncated != nil { + *truncated = true + } + } + return definitions, expectDelimiter(decoder, ']') +} + +func inspectResults( + decoder *json.Decoder, + summary *Summary, + indexedRules map[int]int, + pendingLevels map[ruleReference]int, + options Options, +) error { + if err := expectDelimiter(decoder, '['); err != nil { + return err + } + for decoder.More() { + var result struct { + Kind string `json:"kind"` + Level string `json:"level"` + RuleID string `json:"ruleId"` + RuleIndex *int `json:"ruleIndex"` + BaselineState string `json:"baselineState"` + OccurrenceCount int `json:"occurrenceCount"` + Suppressions []suppression `json:"suppressions"` + Taxa []struct { + ID string `json:"id"` + } `json:"taxa"` + } + if err := decoder.Decode(&result); err != nil { + return err + } + + summary.Results++ + occurrences := max(1, result.OccurrenceCount) + summary.Occurrences += occurrences + switch { + case result.Kind != "" && result.Kind != "fail": + addCount(summary.Levels, "none", 1, options.MaxUniqueValues, &summary.Truncated) + case result.Level != "": + addCount(summary.Levels, result.Level, 1, options.MaxUniqueValues, &summary.Truncated) + default: + reference := ruleReference{ID: result.RuleID} + if result.RuleIndex != nil { + reference.Index = *result.RuleIndex + reference.HasIndex = true + } + addRuleReferenceCount(pendingLevels, reference, options.MaxUniqueValues, &summary.Truncated) + } + if result.BaselineState != "" { + addCount(summary.BaselineStates, result.BaselineState, 1, options.MaxUniqueValues, &summary.Truncated) + } + switch { + case result.RuleID != "": + addCount(summary.Rules, result.RuleID, 1, options.MaxUniqueValues, &summary.Truncated) + case result.RuleIndex != nil: + addIndexCount(indexedRules, *result.RuleIndex, options.MaxUniqueValues, &summary.Truncated) + default: + addCount(summary.Rules, "", 1, options.MaxUniqueValues, &summary.Truncated) + } + if resultSuppressed(result.Suppressions) { + summary.SuppressedResults++ + } + for _, suppression := range result.Suppressions { + value := suppression.Kind + if status := suppression.reviewStatus(); status != "" { + value += "/" + status + } + addCount(summary.Suppressions, value, 1, options.MaxUniqueValues, &summary.Truncated) + } + for _, taxon := range result.Taxa { + if taxon.ID != "" { + addCount(summary.Taxa, taxon.ID, 1, options.MaxUniqueValues, &summary.Truncated) + } + } + } + return expectDelimiter(decoder, ']') +} + +func resultSuppressed(suppressions []suppression) bool { + for _, suppression := range suppressions { + status := suppression.reviewStatus() + if status == "" || status == "accepted" { + return true + } + } + return false +} + +func (suppression suppression) reviewStatus() string { + if suppression.Status != "" { + return suppression.Status + } + return suppression.State +} + +func resolveResultLevels( + summary *Summary, + rules []ruleDefinition, + pending map[ruleReference]int, + limit int, +) { + rulesByID := make(map[string]string, len(rules)) + for _, rule := range rules { + if rule.ID != "" { + rulesByID[rule.ID] = rule.Level + } + } + for reference, count := range pending { + level := "" + if reference.HasIndex && reference.Index >= 0 && reference.Index < len(rules) { + level = rules[reference.Index].Level + } + if level == "" && reference.ID != "" { + level = rulesByID[reference.ID] + } + if level == "" { + level = "warning" + } + addCount(summary.Levels, level, count, limit, &summary.Truncated) + } +} + +func addRuleReferenceCount( + counts map[ruleReference]int, + reference ruleReference, + limit int, + truncated *bool, +) { + if _, exists := counts[reference]; exists || len(counts) < limit { + counts[reference]++ + return + } + *truncated = true +} + +func addIndexCount(counts map[int]int, index, limit int, truncated *bool) { + if _, exists := counts[index]; exists || len(counts) < limit { + counts[index]++ + return + } + *truncated = true +} + +func addTool(summary *Summary, tool Tool, limit int) { + if tool.Name == "" { + return + } + for _, current := range summary.Tools { + if current == tool { + return + } + } + if len(summary.Tools) >= limit { + summary.Truncated = true + return + } + summary.Tools = append(summary.Tools, tool) +} + +func addCount(values map[string]int, value string, count, limit int, truncated *bool) { + if _, ok := values[value]; !ok && len(values) >= limit { + *truncated = true + return + } + values[value] += count +} + +func objectName(decoder *json.Decoder) (string, error) { + token, err := decoder.Token() + if err != nil { + return "", err + } + name, ok := token.(string) + if !ok { + return "", fmt.Errorf("expected object name, got %v", token) + } + return name, nil +} + +func expectDelimiter(decoder *json.Decoder, expected json.Delim) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok || delimiter != expected { + return fmt.Errorf("expected %q, got %v", expected, token) + } + return nil +} + +func skipValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + for decoder.More() { + if _, err := objectName(decoder); err != nil { + return err + } + if err := skipValue(decoder); err != nil { + return err + } + } + return expectDelimiter(decoder, '}') + case '[': + for decoder.More() { + if err := skipValue(decoder); err != nil { + return err + } + } + return expectDelimiter(decoder, ']') + default: + return fmt.Errorf("unexpected delimiter %q", delimiter) + } +} + +type limitReader struct { + reader io.Reader + remaining int64 + err error + readErr error +} + +func (reader *limitReader) Read(buffer []byte) (int, error) { + if reader.err != nil { + return 0, reader.err + } + if reader.remaining == 0 { + var probe [1]byte + count, err := reader.reader.Read(probe[:]) + if count > 0 { + reader.err = ErrLimit + return 0, reader.err + } + reader.err = err + if err != io.EOF { + reader.readErr = err + } + return 0, err + } + if int64(len(buffer)) > reader.remaining { + buffer = buffer[:reader.remaining] + } + count, err := reader.reader.Read(buffer) + reader.remaining -= int64(count) + reader.err = err + if err != nil && err != io.EOF { + reader.readErr = err + } + return count, err +} diff --git a/summary/summary_test.go b/summary/summary_test.go new file mode 100644 index 0000000..d382ffe --- /dev/null +++ b/summary/summary_test.go @@ -0,0 +1,220 @@ +package summary_test + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/git-pkgs/sarif/summary" +) + +func TestInspectSummarizesRunsAndResults(t *testing.T) { + input := `{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "CodeQL", + "semanticVersion": "2.20.0", + "rules": [{"id": "js/xss"}, {"id": "js/sql-injection"}] + }, + "extensions": [{"name": "security-extended", "version": "1.2"}] + }, + "results": [{ + "ruleId": "js/xss", + "level": "error", + "baselineState": "new", + "occurrenceCount": 2, + "message": {"text": "finding"}, + "suppressions": [{"kind": "inSource", "state": "accepted"}], + "taxa": [{"id": "CWE-79"}] + }, { + "ruleIndex": 1, + "message": {"text": "uses default warning level"} + }] + }] +}` + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + if got.Version != "2.1.0" || got.Runs != 1 || got.Results != 2 || got.Occurrences != 3 || + got.SuppressedResults != 1 || got.Truncated { + t.Fatalf("summary = %#v", got) + } + wantTools := []summary.Tool{ + {Name: "CodeQL", SemanticVersion: "2.20.0"}, + {Name: "security-extended", Version: "1.2"}, + } + if len(got.Tools) != len(wantTools) { + t.Fatalf("tools = %#v", got.Tools) + } + for index := range wantTools { + if got.Tools[index] != wantTools[index] { + t.Errorf("tool %d = %#v, want %#v", index, got.Tools[index], wantTools[index]) + } + } + assertCounts(t, got.Levels, map[string]int{"error": 1, "warning": 1}) + assertCounts(t, got.BaselineStates, map[string]int{"new": 1}) + assertCounts(t, got.Rules, map[string]int{"js/sql-injection": 1, "js/xss": 1}) + assertCounts(t, got.Taxa, map[string]int{"CWE-79": 1}) + assertCounts(t, got.Suppressions, map[string]int{"inSource/accepted": 1}) +} + +func TestInspectResolvesRuleIndexesWhenResultsPrecedeTool(t *testing.T) { + input := `{ + "runs": [{ + "results": [{"ruleIndex": 0, "message": {"text": "finding"}}], + "tool": {"driver": {"name": "scanner", "rules": [{ + "id": "RULE-1", + "defaultConfiguration": {"level": "error"} + }]}} + }], + "version": "2.1.0" +}` + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + assertCounts(t, got.Rules, map[string]int{"RULE-1": 1}) + assertCounts(t, got.Levels, map[string]int{"error": 1}) +} + +func TestInspectResolvesEffectiveLevels(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "CodeQL", "rules": [ + {"id": "error-rule", "defaultConfiguration": {"level": "error"}}, + {"id": "note-rule", "defaultConfiguration": {"level": "note"}}, + {"id": "default-rule"} + ]}}, + "results": [ + {"ruleIndex": 0, "message": {"text": "index"}}, + {"ruleId": "note-rule", "message": {"text": "id"}}, + {"ruleId": "error-rule", "ruleIndex": -1, "message": {"text": "invalid index"}}, + {"ruleId": "default-rule", "message": {"text": "SARIF default"}}, + {"ruleId": "error-rule", "kind": "pass", "level": "error", "message": {"text": "pass"}}, + {"ruleId": "note-rule", "level": "error", "message": {"text": "explicit"}} + ] + }] +}` + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + assertCounts(t, got.Levels, map[string]int{ + "error": 3, + "none": 1, + "note": 1, + "warning": 1, + }) +} + +func TestInspectCountsEffectiveSuppressions(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "scanner"}}, + "results": [ + {"ruleId": "one", "message": {"text": "accepted"}, "suppressions": [{"kind": "inSource", "status": "accepted"}]}, + {"ruleId": "two", "message": {"text": "implicit"}, "suppressions": [{"kind": "external"}]}, + {"ruleId": "three", "message": {"text": "review"}, "suppressions": [{"kind": "external", "status": "underReview"}]}, + {"ruleId": "four", "message": {"text": "rejected"}, "suppressions": [{"kind": "inSource", "status": "rejected"}]}, + {"ruleId": "five", "message": {"text": "legacy state"}, "suppressions": [{"kind": "inSource", "state": "accepted"}]} + ] + }] +}` + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + if got.SuppressedResults != 3 { + t.Fatalf("suppressed results = %d, want 3", got.SuppressedResults) + } + assertCounts(t, got.Suppressions, map[string]int{ + "external": 1, + "external/underReview": 1, + "inSource/accepted": 2, + "inSource/rejected": 1, + }) +} + +func TestInspectBoundsUniqueValuesAndTools(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "driver", "rules": [ + {"id": "one", "defaultConfiguration": {"level": "error"}}, + {"id": "two", "defaultConfiguration": {"level": "warning"}} + ]}, "extensions": [{"name": "one"}, {"name": "two"}]}, + "results": [ + {"ruleIndex": 0, "message": {"text": "one"}}, + {"ruleIndex": 1, "message": {"text": "two"}} + ] + }] +}` + got, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxUniqueValues: 1, MaxTools: 1}) + if err != nil { + t.Fatal(err) + } + if !got.Truncated || len(got.Tools) != 1 || len(got.Rules) != 1 || len(got.Levels) != 1 { + t.Fatalf("bounded summary = %#v", got) + } +} + +func TestInspectEnforcesInputLimit(t *testing.T) { + input := `{"version":"2.1.0","runs":[]}` + _, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxBytes: int64(len(input) - 1)}) + if !errors.Is(err, summary.ErrLimit) { + t.Fatalf("Inspect() error = %v, want ErrLimit", err) + } + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxBytes: int64(len(input))}) + if err != nil || got.Version != "2.1.0" { + t.Fatalf("exact-limit Inspect() = %#v, %v", got, err) + } +} + +func TestInspectSurfacesReaderAndSyntaxErrors(t *testing.T) { + readErr := errors.New("read failed") + _, err := summary.Inspect(io.MultiReader( + strings.NewReader(`{"version":"2.1.0",`), + errorReader{err: readErr}, + ), summary.Options{}) + if !errors.Is(err, readErr) { + t.Fatalf("Inspect() error = %v, want read error", err) + } + + if _, err := summary.Inspect(strings.NewReader(`{"version":`), summary.Options{}); !errors.Is(err, summary.ErrInvalid) { + t.Fatalf("malformed SARIF error = %v, want ErrInvalid", err) + } + if _, err := summary.Inspect(strings.NewReader(`{}`), summary.Options{}); !errors.Is(err, summary.ErrInvalid) { + t.Fatalf("incomplete SARIF error = %v, want ErrInvalid", err) + } +} + +func assertCounts(t *testing.T, got, want map[string]int) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("counts = %#v, want %#v", got, want) + } + for name, count := range want { + if got[name] != count { + t.Errorf("count %q = %d, want %d", name, got[name], count) + } + } +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} From c2e53906ee5136e6849f6590dde48cf44a7e2b20 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 8 Nov 2026 18:48:15 +0000 Subject: [PATCH 2/4] Resolve SARIF rule references --- summary/summary.go | 44 +++++++++++++++++++++++++++-------------- summary/summary_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/summary/summary.go b/summary/summary.go index 261822c..b4bb117 100644 --- a/summary/summary.go +++ b/summary/summary.go @@ -63,6 +63,7 @@ type Summary struct { Taxa map[string]int Suppressions map[string]int Truncated bool + toolSet map[Tool]struct{} } func Inspect(reader io.Reader, options Options) (Summary, error) { @@ -99,6 +100,7 @@ func finishSummary(summary Summary) Summary { } return strings.Compare(left.Version, right.Version) }) + summary.toolSet = nil return summary } @@ -136,6 +138,7 @@ func newSummary() Summary { Rules: make(map[string]int), Taxa: make(map[string]int), Suppressions: make(map[string]int), + toolSet: make(map[Tool]struct{}), } } @@ -341,10 +344,14 @@ func inspectResults( } for decoder.More() { var result struct { - Kind string `json:"kind"` - Level string `json:"level"` - RuleID string `json:"ruleId"` - RuleIndex *int `json:"ruleIndex"` + Kind string `json:"kind"` + Level string `json:"level"` + RuleID string `json:"ruleId"` + RuleIndex *int `json:"ruleIndex"` + Rule struct { + ID string `json:"id"` + Index *int `json:"index"` + } `json:"rule"` BaselineState string `json:"baselineState"` OccurrenceCount int `json:"occurrenceCount"` Suppressions []suppression `json:"suppressions"` @@ -359,15 +366,23 @@ func inspectResults( summary.Results++ occurrences := max(1, result.OccurrenceCount) summary.Occurrences += occurrences + ruleID := result.RuleID + if ruleID == "" { + ruleID = result.Rule.ID + } + ruleIndex := result.RuleIndex + if ruleIndex == nil { + ruleIndex = result.Rule.Index + } switch { case result.Kind != "" && result.Kind != "fail": addCount(summary.Levels, "none", 1, options.MaxUniqueValues, &summary.Truncated) case result.Level != "": addCount(summary.Levels, result.Level, 1, options.MaxUniqueValues, &summary.Truncated) default: - reference := ruleReference{ID: result.RuleID} - if result.RuleIndex != nil { - reference.Index = *result.RuleIndex + reference := ruleReference{ID: ruleID} + if ruleIndex != nil { + reference.Index = *ruleIndex reference.HasIndex = true } addRuleReferenceCount(pendingLevels, reference, options.MaxUniqueValues, &summary.Truncated) @@ -376,10 +391,10 @@ func inspectResults( addCount(summary.BaselineStates, result.BaselineState, 1, options.MaxUniqueValues, &summary.Truncated) } switch { - case result.RuleID != "": - addCount(summary.Rules, result.RuleID, 1, options.MaxUniqueValues, &summary.Truncated) - case result.RuleIndex != nil: - addIndexCount(indexedRules, *result.RuleIndex, options.MaxUniqueValues, &summary.Truncated) + case ruleID != "": + addCount(summary.Rules, ruleID, 1, options.MaxUniqueValues, &summary.Truncated) + case ruleIndex != nil: + addIndexCount(indexedRules, *ruleIndex, options.MaxUniqueValues, &summary.Truncated) default: addCount(summary.Rules, "", 1, options.MaxUniqueValues, &summary.Truncated) } @@ -471,15 +486,14 @@ func addTool(summary *Summary, tool Tool, limit int) { if tool.Name == "" { return } - for _, current := range summary.Tools { - if current == tool { - return - } + if _, exists := summary.toolSet[tool]; exists { + return } if len(summary.Tools) >= limit { summary.Truncated = true return } + summary.toolSet[tool] = struct{}{} summary.Tools = append(summary.Tools, tool) } diff --git a/summary/summary_test.go b/summary/summary_test.go index d382ffe..062e950 100644 --- a/summary/summary_test.go +++ b/summary/summary_test.go @@ -115,6 +115,29 @@ func TestInspectResolvesEffectiveLevels(t *testing.T) { }) } +func TestInspectResolvesReportingDescriptorReferences(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "scanner", "rules": [ + {"id": "by-id", "defaultConfiguration": {"level": "error"}}, + {"id": "by-index", "defaultConfiguration": {"level": "note"}} + ]}}, + "results": [ + {"rule": {"id": "by-id"}, "message": {"text": "id"}}, + {"rule": {"index": 1}, "message": {"text": "index"}} + ] + }] +}` + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + assertCounts(t, got.Rules, map[string]int{"by-id": 1, "by-index": 1}) + assertCounts(t, got.Levels, map[string]int{"error": 1, "note": 1}) +} + func TestInspectCountsEffectiveSuppressions(t *testing.T) { input := `{ "version": "2.1.0", @@ -168,6 +191,24 @@ func TestInspectBoundsUniqueValuesAndTools(t *testing.T) { } } +func TestInspectDeduplicatesToolsAtLimit(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "scanner"}, "extensions": [ + {"name": "scanner"}, {"name": "scanner"} + ]} + }] +}` + got, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxTools: 1}) + if err != nil { + t.Fatal(err) + } + if got.Truncated || len(got.Tools) != 1 { + t.Fatalf("tools = %#v, truncated = %t", got.Tools, got.Truncated) + } +} + func TestInspectEnforcesInputLimit(t *testing.T) { input := `{"version":"2.1.0","runs":[]}` _, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxBytes: int64(len(input) - 1)}) From c865f2716858b1509f94ef589d02ef6204a58990 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 8 Nov 2026 19:01:20 +0000 Subject: [PATCH 3/4] Resolve extension rule references --- summary/summary.go | 293 ++++++++++++++++++++++++++++------------ summary/summary_test.go | 55 +++++++- 2 files changed, 257 insertions(+), 91 deletions(-) diff --git a/summary/summary.go b/summary/summary.go index b4bb117..303db96 100644 --- a/summary/summary.go +++ b/summary/summary.go @@ -35,21 +35,53 @@ type Tool struct { type ruleDefinition struct { ID string + GUID string Level string } -type ruleReference struct { - ID string +type ruleComponent struct { + tool Tool + GUID string + Rules []ruleDefinition +} + +type ruleComponents struct { + Driver ruleComponent + Extensions []ruleComponent +} + +type componentReference struct { + Name string + GUID string Index int HasIndex bool } +type ruleReference struct { + ID string + GUID string + Index int + HasIndex bool + Component componentReference +} + type suppression struct { Kind string `json:"kind"` Status string `json:"status"` State string `json:"state"` } +type resultRule struct { + ID string `json:"id"` + GUID string `json:"guid"` + Index *int `json:"index"` + ToolComponent struct { + Name string `json:"name"` + GUID string `json:"guid"` + Index *int `json:"index"` + } `json:"toolComponent"` +} + type Summary struct { Version string Runs int @@ -194,8 +226,8 @@ func inspectRun(decoder *json.Decoder, summary *Summary, options Options) error if err := expectDelimiter(decoder, '{'); err != nil { return err } - var driverRules []ruleDefinition - indexedRules := make(map[int]int) + var components ruleComponents + pendingRules := make(map[ruleReference]int) pendingLevels := make(map[ruleReference]int) for decoder.More() { name, err := objectName(decoder) @@ -204,9 +236,9 @@ func inspectRun(decoder *json.Decoder, summary *Summary, options Options) error } switch name { case "tool": - driverRules, err = inspectTool(decoder, summary, options) + components, err = inspectTool(decoder, summary, options) case "results": - err = inspectResults(decoder, summary, indexedRules, pendingLevels, options) + err = inspectResults(decoder, summary, pendingRules, pendingLevels, options) default: err = skipValue(decoder) } @@ -217,95 +249,108 @@ func inspectRun(decoder *json.Decoder, summary *Summary, options Options) error if err := expectDelimiter(decoder, '}'); err != nil { return err } - for index, count := range indexedRules { - if index >= 0 && index < len(driverRules) && driverRules[index].ID != "" { - addCount(summary.Rules, driverRules[index].ID, count, options.MaxUniqueValues, &summary.Truncated) - } else { - addCount(summary.Rules, "", count, options.MaxUniqueValues, &summary.Truncated) - } - } - resolveResultLevels(summary, driverRules, pendingLevels, options.MaxUniqueValues) + resolveResultRules(summary, components, pendingRules, options.MaxUniqueValues) + resolveResultLevels(summary, components, pendingLevels, options.MaxUniqueValues) return nil } -func inspectTool(decoder *json.Decoder, summary *Summary, options Options) ([]ruleDefinition, error) { +func inspectTool(decoder *json.Decoder, summary *Summary, options Options) (ruleComponents, error) { if err := expectDelimiter(decoder, '{'); err != nil { - return nil, err + return ruleComponents{}, err } - var driverRules []ruleDefinition + var components ruleComponents + remainingRules := options.MaxUniqueValues for decoder.More() { name, err := objectName(decoder) if err != nil { - return nil, err + return ruleComponents{}, err } switch name { case "driver": - var tool Tool - tool, driverRules, err = inspectToolComponent(decoder, true, options.MaxUniqueValues, &summary.Truncated) - addTool(summary, tool, options.MaxTools) + components.Driver, err = inspectToolComponent(decoder, remainingRules, &summary.Truncated) + remainingRules -= len(components.Driver.Rules) + addTool(summary, components.Driver.tool, options.MaxTools) case "extensions": - err = inspectToolExtensions(decoder, summary, options.MaxTools) + components.Extensions, err = inspectToolExtensions( + decoder, summary, options.MaxTools, &remainingRules, &summary.Truncated, + ) default: err = skipValue(decoder) } if err != nil { - return nil, err + return ruleComponents{}, err } } - return driverRules, expectDelimiter(decoder, '}') + return components, expectDelimiter(decoder, '}') } -func inspectToolExtensions(decoder *json.Decoder, summary *Summary, limit int) error { +func inspectToolExtensions( + decoder *json.Decoder, + summary *Summary, + toolLimit int, + remainingRules *int, + truncated *bool, +) ([]ruleComponent, error) { if err := expectDelimiter(decoder, '['); err != nil { - return err + return nil, err } + var components []ruleComponent for decoder.More() { - tool, _, err := inspectToolComponent(decoder, false, 0, nil) + retain := len(components) < toolLimit + maxRules := 0 + if retain { + maxRules = *remainingRules + } + component, err := inspectToolComponent(decoder, maxRules, truncated) if err != nil { - return err + return nil, err + } + if retain { + *remainingRules -= len(component.Rules) + components = append(components, component) + } else { + *truncated = true } - addTool(summary, tool, limit) + addTool(summary, component.tool, toolLimit) } - return expectDelimiter(decoder, ']') + return components, expectDelimiter(decoder, ']') } func inspectToolComponent( decoder *json.Decoder, - rules bool, maxRules int, truncated *bool, -) (Tool, []ruleDefinition, error) { +) (ruleComponent, error) { if err := expectDelimiter(decoder, '{'); err != nil { - return Tool{}, nil, err + return ruleComponent{}, err } + var component ruleComponent var tool Tool - var ruleDefinitions []ruleDefinition for decoder.More() { name, err := objectName(decoder) if err != nil { - return Tool{}, nil, err + return ruleComponent{}, err } switch name { case "name": err = decoder.Decode(&tool.Name) + case "guid": + err = decoder.Decode(&component.GUID) case "version": err = decoder.Decode(&tool.Version) case "semanticVersion": err = decoder.Decode(&tool.SemanticVersion) case "rules": - if rules { - ruleDefinitions, err = inspectRuleDefinitions(decoder, maxRules, truncated) - } else { - err = skipValue(decoder) - } + component.Rules, err = inspectRuleDefinitions(decoder, maxRules, truncated) default: err = skipValue(decoder) } if err != nil { - return Tool{}, nil, err + return ruleComponent{}, err } } - return tool, ruleDefinitions, expectDelimiter(decoder, '}') + component.tool = tool + return component, expectDelimiter(decoder, '}') } func inspectRuleDefinitions(decoder *json.Decoder, limit int, truncated *bool) ([]ruleDefinition, error) { @@ -316,6 +361,7 @@ func inspectRuleDefinitions(decoder *json.Decoder, limit int, truncated *bool) ( for decoder.More() { var rule struct { ID string `json:"id"` + GUID string `json:"guid"` DefaultConfiguration struct { Level string `json:"level"` } `json:"defaultConfiguration"` @@ -324,7 +370,9 @@ func inspectRuleDefinitions(decoder *json.Decoder, limit int, truncated *bool) ( return nil, err } if len(definitions) < limit { - definitions = append(definitions, ruleDefinition{ID: rule.ID, Level: rule.DefaultConfiguration.Level}) + definitions = append(definitions, ruleDefinition{ + ID: rule.ID, GUID: rule.GUID, Level: rule.DefaultConfiguration.Level, + }) } else if truncated != nil { *truncated = true } @@ -335,7 +383,7 @@ func inspectRuleDefinitions(decoder *json.Decoder, limit int, truncated *bool) ( func inspectResults( decoder *json.Decoder, summary *Summary, - indexedRules map[int]int, + pendingRules map[ruleReference]int, pendingLevels map[ruleReference]int, options Options, ) error { @@ -344,14 +392,11 @@ func inspectResults( } for decoder.More() { var result struct { - Kind string `json:"kind"` - Level string `json:"level"` - RuleID string `json:"ruleId"` - RuleIndex *int `json:"ruleIndex"` - Rule struct { - ID string `json:"id"` - Index *int `json:"index"` - } `json:"rule"` + Kind string `json:"kind"` + Level string `json:"level"` + RuleID string `json:"ruleId"` + RuleIndex *int `json:"ruleIndex"` + Rule resultRule `json:"rule"` BaselineState string `json:"baselineState"` OccurrenceCount int `json:"occurrenceCount"` Suppressions []suppression `json:"suppressions"` @@ -366,35 +411,23 @@ func inspectResults( summary.Results++ occurrences := max(1, result.OccurrenceCount) summary.Occurrences += occurrences - ruleID := result.RuleID - if ruleID == "" { - ruleID = result.Rule.ID - } - ruleIndex := result.RuleIndex - if ruleIndex == nil { - ruleIndex = result.Rule.Index - } + reference := makeRuleReference(result.RuleID, result.RuleIndex, result.Rule) switch { case result.Kind != "" && result.Kind != "fail": addCount(summary.Levels, "none", 1, options.MaxUniqueValues, &summary.Truncated) case result.Level != "": addCount(summary.Levels, result.Level, 1, options.MaxUniqueValues, &summary.Truncated) default: - reference := ruleReference{ID: ruleID} - if ruleIndex != nil { - reference.Index = *ruleIndex - reference.HasIndex = true - } addRuleReferenceCount(pendingLevels, reference, options.MaxUniqueValues, &summary.Truncated) } if result.BaselineState != "" { addCount(summary.BaselineStates, result.BaselineState, 1, options.MaxUniqueValues, &summary.Truncated) } switch { - case ruleID != "": - addCount(summary.Rules, ruleID, 1, options.MaxUniqueValues, &summary.Truncated) - case ruleIndex != nil: - addIndexCount(indexedRules, *ruleIndex, options.MaxUniqueValues, &summary.Truncated) + case reference.ID != "": + addCount(summary.Rules, reference.ID, 1, options.MaxUniqueValues, &summary.Truncated) + case reference.HasIndex || reference.GUID != "": + addRuleReferenceCount(pendingRules, reference, options.MaxUniqueValues, &summary.Truncated) default: addCount(summary.Rules, "", 1, options.MaxUniqueValues, &summary.Truncated) } @@ -417,6 +450,32 @@ func inspectResults( return expectDelimiter(decoder, ']') } +func makeRuleReference(ruleID string, ruleIndex *int, rule resultRule) ruleReference { + if ruleID == "" { + ruleID = rule.ID + } + if ruleIndex == nil { + ruleIndex = rule.Index + } + reference := ruleReference{ + ID: ruleID, + GUID: rule.GUID, + Component: componentReference{ + Name: rule.ToolComponent.Name, + GUID: rule.ToolComponent.GUID, + }, + } + if ruleIndex != nil { + reference.Index = *ruleIndex + reference.HasIndex = true + } + if componentIndex := rule.ToolComponent.Index; componentIndex != nil { + reference.Component.Index = *componentIndex + reference.Component.HasIndex = true + } + return reference +} + func resultSuppressed(suppressions []suppression) bool { for _, suppression := range suppressions { status := suppression.reviewStatus() @@ -434,26 +493,31 @@ func (suppression suppression) reviewStatus() string { return suppression.State } -func resolveResultLevels( +func resolveResultRules( summary *Summary, - rules []ruleDefinition, + components ruleComponents, pending map[ruleReference]int, limit int, ) { - rulesByID := make(map[string]string, len(rules)) - for _, rule := range rules { - if rule.ID != "" { - rulesByID[rule.ID] = rule.Level + for reference, count := range pending { + rule, found := resolveRule(components, reference) + if !found || rule.ID == "" { + addCount(summary.Rules, "", count, limit, &summary.Truncated) + continue } + addCount(summary.Rules, rule.ID, count, limit, &summary.Truncated) } +} + +func resolveResultLevels( + summary *Summary, + components ruleComponents, + pending map[ruleReference]int, + limit int, +) { for reference, count := range pending { - level := "" - if reference.HasIndex && reference.Index >= 0 && reference.Index < len(rules) { - level = rules[reference.Index].Level - } - if level == "" && reference.ID != "" { - level = rulesByID[reference.ID] - } + rule, _ := resolveRule(components, reference) + level := rule.Level if level == "" { level = "warning" } @@ -461,6 +525,63 @@ func resolveResultLevels( } } +func resolveRule(components ruleComponents, reference ruleReference) (ruleDefinition, bool) { + rules, found := componentRules(components, reference.Component) + if !found { + return ruleDefinition{}, false + } + if reference.HasIndex && reference.Index >= 0 && reference.Index < len(rules) { + return rules[reference.Index], true + } + if reference.ID != "" { + for _, rule := range rules { + if rule.ID == reference.ID { + return rule, true + } + } + } + if reference.GUID != "" { + for _, rule := range rules { + if rule.GUID == reference.GUID { + return rule, true + } + } + } + return ruleDefinition{}, false +} + +func componentRules(components ruleComponents, reference componentReference) ([]ruleDefinition, bool) { + if !reference.HasIndex && reference.Name == "" && reference.GUID == "" { + return components.Driver.Rules, true + } + if reference.HasIndex { + if reference.Index >= 0 && reference.Index < len(components.Extensions) { + return components.Extensions[reference.Index].Rules, true + } + return nil, false + } + if reference.GUID != "" { + if components.Driver.GUID == reference.GUID { + return components.Driver.Rules, true + } + for _, component := range components.Extensions { + if component.GUID == reference.GUID { + return component.Rules, true + } + } + return nil, false + } + if components.Driver.tool.Name == reference.Name { + return components.Driver.Rules, true + } + for _, component := range components.Extensions { + if component.tool.Name == reference.Name { + return component.Rules, true + } + } + return nil, false +} + func addRuleReferenceCount( counts map[ruleReference]int, reference ruleReference, @@ -474,14 +595,6 @@ func addRuleReferenceCount( *truncated = true } -func addIndexCount(counts map[int]int, index, limit int, truncated *bool) { - if _, exists := counts[index]; exists || len(counts) < limit { - counts[index]++ - return - } - *truncated = true -} - func addTool(summary *Summary, tool Tool, limit int) { if tool.Name == "" { return diff --git a/summary/summary_test.go b/summary/summary_test.go index 062e950..680e6af 100644 --- a/summary/summary_test.go +++ b/summary/summary_test.go @@ -138,6 +138,37 @@ func TestInspectResolvesReportingDescriptorReferences(t *testing.T) { assertCounts(t, got.Levels, map[string]int{"error": 1, "note": 1}) } +func TestInspectResolvesExtensionRuleReferences(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": {"name": "scanner"}, + "extensions": [{ + "name": "plugin", + "guid": "11111111-1111-4111-8111-111111111111", + "rules": [ + {"id": "ext-error", "guid": "22222222-2222-4222-8222-222222222222", "defaultConfiguration": {"level": "error"}}, + {"id": "ext-note", "defaultConfiguration": {"level": "note"}} + ] + }] + }, + "results": [ + {"rule": {"index": 0, "toolComponent": {"index": 0}}, "message": {"text": "index"}}, + {"rule": {"id": "ext-note", "toolComponent": {"name": "plugin"}}, "message": {"text": "name"}}, + {"rule": {"guid": "22222222-2222-4222-8222-222222222222", "toolComponent": {"guid": "11111111-1111-4111-8111-111111111111"}}, "message": {"text": "guid"}} + ] + }] +}` + + got, err := summary.Inspect(strings.NewReader(input), summary.Options{}) + if err != nil { + t.Fatal(err) + } + assertCounts(t, got.Rules, map[string]int{"ext-error": 2, "ext-note": 1}) + assertCounts(t, got.Levels, map[string]int{"error": 2, "note": 1}) +} + func TestInspectCountsEffectiveSuppressions(t *testing.T) { input := `{ "version": "2.1.0", @@ -196,7 +227,7 @@ func TestInspectDeduplicatesToolsAtLimit(t *testing.T) { "version": "2.1.0", "runs": [{ "tool": {"driver": {"name": "scanner"}, "extensions": [ - {"name": "scanner"}, {"name": "scanner"} + {"name": "scanner"} ]} }] }` @@ -209,6 +240,28 @@ func TestInspectDeduplicatesToolsAtLimit(t *testing.T) { } } +func TestInspectBoundsExtensionRuleMetadata(t *testing.T) { + input := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "scanner"}, "extensions": [ + {"name": "one", "rules": [{"id": "one", "defaultConfiguration": {"level": "error"}}]}, + {"name": "two", "rules": [{"id": "two", "defaultConfiguration": {"level": "error"}}]} + ]}, + "results": [{"rule": {"index": 0, "toolComponent": {"index": 1}}, "message": {"text": "two"}}] + }] +}` + got, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxTools: 1}) + if err != nil { + t.Fatal(err) + } + if !got.Truncated { + t.Fatal("summary was not truncated") + } + assertCounts(t, got.Rules, map[string]int{"": 1}) + assertCounts(t, got.Levels, map[string]int{"warning": 1}) +} + func TestInspectEnforcesInputLimit(t *testing.T) { input := `{"version":"2.1.0","runs":[]}` _, err := summary.Inspect(strings.NewReader(input), summary.Options{MaxBytes: int64(len(input) - 1)}) From 05983351b29ef1b9231e76ff8d849a3868293a1e Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 8 Nov 2026 19:06:44 +0000 Subject: [PATCH 4/4] Treat negative SARIF indexes as absent --- summary/summary.go | 4 ++-- summary/summary_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/summary/summary.go b/summary/summary.go index 303db96..c6a2433 100644 --- a/summary/summary.go +++ b/summary/summary.go @@ -465,11 +465,11 @@ func makeRuleReference(ruleID string, ruleIndex *int, rule resultRule) ruleRefer GUID: rule.ToolComponent.GUID, }, } - if ruleIndex != nil { + if ruleIndex != nil && *ruleIndex >= 0 { reference.Index = *ruleIndex reference.HasIndex = true } - if componentIndex := rule.ToolComponent.Index; componentIndex != nil { + if componentIndex := rule.ToolComponent.Index; componentIndex != nil && *componentIndex >= 0 { reference.Component.Index = *componentIndex reference.Component.HasIndex = true } diff --git a/summary/summary_test.go b/summary/summary_test.go index 680e6af..7526645 100644 --- a/summary/summary_test.go +++ b/summary/summary_test.go @@ -155,7 +155,7 @@ func TestInspectResolvesExtensionRuleReferences(t *testing.T) { }, "results": [ {"rule": {"index": 0, "toolComponent": {"index": 0}}, "message": {"text": "index"}}, - {"rule": {"id": "ext-note", "toolComponent": {"name": "plugin"}}, "message": {"text": "name"}}, + {"rule": {"id": "ext-note", "index": -1, "toolComponent": {"name": "plugin", "index": -1}}, "message": {"text": "name"}}, {"rule": {"guid": "22222222-2222-4222-8222-222222222222", "toolComponent": {"guid": "11111111-1111-4111-8111-111111111111"}}, "message": {"text": "guid"}} ] }]