Skip to content

Commit ba96e19

Browse files
authored
feat: add rule category filtering (#1500)
* feat: add rule category filtering Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> * appease the linter Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> --------- Signed-off-by: egibs <20933572+egibs@users.noreply.github.com>
1 parent e695690 commit ba96e19

5 files changed

Lines changed: 446 additions & 3 deletions

File tree

cmd/mal/mal.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ var (
7171
outputFlag string
7272
profileFlag bool
7373
quantityIncreasesRiskFlag bool
74+
ruleCategoriesFlag []string
7475
sensitivityFlag int
7576
statsFlag bool
7677
thirdPartyFlag bool
@@ -269,6 +270,7 @@ func main() {
269270
OCI: ociFlag,
270271
QuantityIncreasesRisk: quantityIncreasesRiskFlag,
271272
Renderer: renderer,
273+
RuleCategories: ruleCategoriesFlag,
272274
Rules: yrs,
273275
Stats: statsFlag,
274276
}
@@ -425,6 +427,13 @@ func main() {
425427
Destination: &quantityIncreasesRiskFlag,
426428
Local: false,
427429
},
430+
&cli.StringSliceFlag{
431+
Name: "rule-category",
432+
Value: []string{},
433+
Usage: "Only show matches whose rule path starts with one of the given categories (e.g. exfil, exfil/stealer); repeatable, no-op when unset",
434+
Destination: &ruleCategoriesFlag,
435+
Local: false,
436+
},
428437
&cli.BoolFlag{
429438
Name: "stats",
430439
Aliases: []string{"s"},

pkg/action/category.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2026 Chainguard, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package action
5+
6+
import (
7+
"strings"
8+
9+
"github.com/chainguard-dev/malcontent/pkg/malcontent"
10+
)
11+
12+
// MatchesAnyCategory reports whether ruleID matches any of the supplied
13+
// categories. A category matches when it is equal to ruleID or is a
14+
// '/'-bounded prefix of it (so "exfil" matches "exfil/stealer/foo" but
15+
// not "exfiltrate/foo"). An empty or nil categories slice is a no-op
16+
// (returns true) so the filter is opt-in.
17+
func MatchesAnyCategory(ruleID string, categories []string) bool {
18+
if len(categories) == 0 {
19+
return true
20+
}
21+
for _, c := range categories {
22+
if c == "" {
23+
continue
24+
}
25+
if ruleID == c || strings.HasPrefix(ruleID, c+"/") {
26+
return true
27+
}
28+
}
29+
return false
30+
}
31+
32+
// FilterBehaviorsByCategory returns the subset of behaviors whose ID
33+
// matches any of the supplied categories, plus the count of dropped
34+
// entries. Empty/nil categories returns the input slice unchanged.
35+
func FilterBehaviorsByCategory(behaviors []*malcontent.Behavior, categories []string) ([]*malcontent.Behavior, int) {
36+
if len(categories) == 0 {
37+
return behaviors, 0
38+
}
39+
prefixes := buildCategoryPrefixes(categories)
40+
kept := make([]*malcontent.Behavior, 0, len(behaviors))
41+
dropped := 0
42+
for _, b := range behaviors {
43+
if b == nil {
44+
continue
45+
}
46+
if matchesPrefixes(b.ID, prefixes) {
47+
kept = append(kept, b)
48+
} else {
49+
dropped++
50+
}
51+
}
52+
return kept, dropped
53+
}
54+
55+
// trimFileReportBehaviors applies the category filter to one FileReport
56+
// in place. It never drops the report itself — callers that want empty
57+
// reports removed must do so explicitly. Returns false only when the
58+
// report ended up with zero matching behaviors.
59+
func trimFileReportBehaviors(fr *malcontent.FileReport, categories []string) bool {
60+
if fr == nil || len(categories) == 0 {
61+
return true
62+
}
63+
if fr.Skipped != "" {
64+
return true
65+
}
66+
kept, dropped := FilterBehaviorsByCategory(fr.Behaviors, categories)
67+
fr.Behaviors = kept
68+
fr.FilteredBehaviors += dropped
69+
return len(kept) > 0
70+
}
71+
72+
// TrimFileReport trims behaviors on a single FileReport to those matching
73+
// any of the categories. Returns true when at least one behavior remains
74+
// (useful for callers that want to skip rendering empty reports). Empty/nil
75+
// categories is a no-op (returns true).
76+
func TrimFileReport(fr *malcontent.FileReport, categories []string) bool {
77+
return trimFileReportBehaviors(fr, categories)
78+
}
79+
80+
// ApplyCategoryFilter trims each FileReport in the report so that only
81+
// behaviors matching one of the categories remain, then removes any
82+
// FileReport whose behavior list became empty. Empty/nil categories is
83+
// a no-op. Use this for analyze/scan output where empty entries are
84+
// noise — for the diff path, prefer TrimFileReport per-file so that
85+
// reconciliation can still see both sides of a change.
86+
func ApplyCategoryFilter(r *malcontent.Report, categories []string) {
87+
if r == nil || r.Files == nil || len(categories) == 0 {
88+
return
89+
}
90+
r.Files.Range(func(key string, fr *malcontent.FileReport) bool {
91+
if !trimFileReportBehaviors(fr, categories) {
92+
r.Files.Delete(key)
93+
}
94+
return true
95+
})
96+
}
97+
98+
// categoryPrefix is "<cat>/" and exact is "<cat>" for each non-empty
99+
// category, hoisted out of the inner loop to avoid repeated allocation.
100+
type categoryPrefix struct {
101+
exact, withSlash string
102+
}
103+
104+
func buildCategoryPrefixes(categories []string) []categoryPrefix {
105+
out := make([]categoryPrefix, 0, len(categories))
106+
for _, c := range categories {
107+
if c == "" {
108+
continue
109+
}
110+
out = append(out, categoryPrefix{exact: c, withSlash: c + "/"})
111+
}
112+
return out
113+
}
114+
115+
func matchesPrefixes(ruleID string, prefixes []categoryPrefix) bool {
116+
for _, p := range prefixes {
117+
if ruleID == p.exact || strings.HasPrefix(ruleID, p.withSlash) {
118+
return true
119+
}
120+
}
121+
return false
122+
}

0 commit comments

Comments
 (0)