diff --git a/go.mod b/go.mod index 0b9a29970..d569130b0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.0 require ( github.com/Masterminds/semver v1.5.0 + github.com/cespare/xxhash/v2 v2.3.0 github.com/doug-martin/goqu/v8 v8.6.0 + github.com/google/cel-go v0.30.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 @@ -35,9 +37,10 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect github.com/anchore/go-struct-converter v0.1.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -56,7 +59,11 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sirupsen/logrus v1.9.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/mod v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 33378e042..91c6f264a 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08= github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -26,6 +30,8 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= +github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -49,6 +55,10 @@ github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GX github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8= github.com/knqyf263/go-deb-version v0.0.0-20190517075300-09fca494f03d h1:X4cedH4Kn3JPupAwwWuo4AzYp16P0OyLO9d7OnMZc/c= github.com/knqyf263/go-deb-version v0.0.0-20190517075300-09fca494f03d/go.mod h1:o8sgWoz3JADecfc/cTYD92/Et1yMqMy0utV1z+VaZao= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -85,6 +95,8 @@ github.com/regclient/regclient v0.11.5 h1:OHRsXO0F3qHGfa4HEUv+EkMH9NXNcCTBKjNzyC github.com/regclient/regclient v0.11.5/go.mod h1:DZUOfIT14WFTK2Pj4vjd93avy9O4Fdpjrf9ir23TbRE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= @@ -114,10 +126,14 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -149,9 +165,15 @@ golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/rhel/vex/fixed_in_cel.go b/rhel/vex/fixed_in_cel.go new file mode 100644 index 000000000..02f9cfba6 --- /dev/null +++ b/rhel/vex/fixed_in_cel.go @@ -0,0 +1,93 @@ +package vex + +import ( + "fmt" + + "github.com/cespare/xxhash/v2" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/ext" + "github.com/package-url/packageurl-go" +) + +// CompileFixedInVersionCEL compiles a CEL expression that evaluates to a string. +// +// The expression may use these variables: +// +// - type (string): PURL type (oci, rpm, etc.) +// - namespace (string): PURL namespace +// - name (string): PURL name +// - version (string): PURL version field +// - qualifiers (map[string]string): PURL qualifiers +// - fixed_in (string): default FixedInVersion from stock extraction +// +// The environment includes the CEL strings extension (startsWith, +// substring, split etc.) and bindings to be able to set variables. +// +// An empty expression returns a nil program. +// Production expressions are supplied by callers if desired. +func CompileFixedInVersionCEL(expr string) (cel.Program, error) { + if expr == "" { + return nil, nil + } + env, err := fixedInCELEnv() + if err != nil { + return nil, err + } + ast, iss := env.Compile(expr) + if iss.Err() != nil { + return nil, fmt.Errorf("fixed_in_version_cel: compile: %w", iss.Err()) + } + if !ast.OutputType().IsExactType(cel.StringType) { + return nil, fmt.Errorf("fixed_in_version_cel: expression must evaluate to string, got %v", ast.OutputType()) + } + prog, err := env.Program(ast) + if err != nil { + return nil, fmt.Errorf("fixed_in_version_cel: program: %w", err) + } + return prog, nil +} + +// EvalFixedInVersionCEL evaluates a compiled FixedInVersion CEL program. +func EvalFixedInVersionCEL(prog cel.Program, p *packageurl.PackageURL, defaultVersion string) (string, error) { + if prog == nil { + return defaultVersion, nil + } + out, _, err := prog.Eval(map[string]any{ + "type": p.Type, + "namespace": p.Namespace, + "name": p.Name, + "version": p.Version, + "qualifiers": p.Qualifiers.Map(), + "fixed_in": defaultVersion, + }) + if err != nil { + return "", fmt.Errorf("fixed_in_version_cel: eval: %w", err) + } + s, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("fixed_in_version_cel: result type %T, want string", out.Value()) + } + return s, nil +} + +func fixedInCELEnv() (*cel.Env, error) { + return cel.NewEnv( + cel.Variable("type", cel.StringType), + cel.Variable("namespace", cel.StringType), + cel.Variable("name", cel.StringType), + cel.Variable("version", cel.StringType), + cel.Variable("qualifiers", cel.MapType(cel.StringType, cel.StringType)), + cel.Variable("fixed_in", cel.StringType), + ext.Strings(), + ext.Bindings(), + ) +} + +// CelExprFingerprintDigest returns a hex-encoded xxhash of expr for fingerprinting. +// Empty expr returns an empty string. +func celExprFingerprintDigest(expr string) string { + if expr == "" { + return "" + } + return fmt.Sprintf("%016x", xxhash.Sum64String(expr)) +} diff --git a/rhel/vex/fixed_in_cel_test.go b/rhel/vex/fixed_in_cel_test.go new file mode 100644 index 000000000..9c17dcb60 --- /dev/null +++ b/rhel/vex/fixed_in_cel_test.go @@ -0,0 +1,176 @@ +package vex + +import ( + "testing" + + "github.com/package-url/packageurl-go" +) + +func TestCompileFixedInVersionCEL(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + expr string + wantNil bool + wantErr bool + }{ + { + name: "empty", + expr: "", + wantNil: true, + }, + { + name: "invalid", + expr: "this is not valid CEL", + wantErr: true, + }, + { + name: "non-string", + expr: "true", + wantErr: true, + }, + { + name: "valid", + expr: `fixed_in`, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + prog, err := CompileFixedInVersionCEL(tc.expr) + if tc.wantErr { + if err == nil { + t.Fatal("expected compile error") + } + return + } + if err != nil { + t.Fatalf("compile: %v", err) + } + if tc.wantNil { + if prog != nil { + t.Fatal("expected nil program") + } + return + } + if prog == nil { + t.Fatal("expected non-nil program") + } + }) + } +} + +// TestEvalFixedInVersionCEL exercises rewriting FixedInVersion via a CEL +// expression that uses PURL fields such as type and qualifiers. +func TestEvalFixedInVersionCEL(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + expr string + purl packageurl.PackageURL + stock string + want string + }{ + { + name: "oci with tag2", + // Prefer an alternate qualifier when present; otherwise keep stock fixed_in. + expr: `type == "oci" && has(qualifiers.tag2) ? qualifiers.tag2 : fixed_in`, + purl: packageurl.PackageURL{ + Type: packageurl.TypeOCI, + Namespace: "redhat", + Name: "example", + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "tag": "1.0.0", + "tag2": "2.0.0-from-tag2", + "repository_url": "registry.example/example", + }), + }, + stock: "1.0.0", + want: "2.0.0-from-tag2", + }, + { + name: "oci without tag2", + expr: `type == "oci" && has(qualifiers.tag2) ? qualifiers.tag2 : fixed_in`, + purl: packageurl.PackageURL{ + Type: packageurl.TypeOCI, + Name: "example", + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "tag": "1.0.0", + }), + }, + stock: "1.0.0", + want: "1.0.0", + }, + { + name: "rpm unchanged", + expr: `type == "oci" && has(qualifiers.tag2) ? qualifiers.tag2 : fixed_in`, + purl: packageurl.PackageURL{ + Type: packageurl.TypeRPM, + Namespace: "redhat", + Name: "bash", + Version: "5.1.8-6.el9", + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "epoch": "0", + }), + }, + stock: "0:5.1.8-6.el9", + want: "0:5.1.8-6.el9", + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + prog, err := CompileFixedInVersionCEL(tc.expr) + if err != nil { + t.Fatalf("compile: %v", err) + } + opt, err := WithFixedInVersionCEL(tc.expr) + if err != nil { + t.Fatalf("WithFixedInVersionCEL: %v", err) + } + p := NewParser(opt) + + stock, err := extractFixedInVersion(&tc.purl) + if err != nil { + t.Fatalf("stock extract: %v", err) + } + if stock != tc.stock { + t.Fatalf("stock = %q, want %q", stock, tc.stock) + } + got, err := EvalFixedInVersionCEL(prog, &tc.purl, stock) + if err != nil { + t.Fatalf("cel eval: %v", err) + } + if got != tc.want { + t.Fatalf("cel rewrite = %q, want %q", got, tc.want) + } + gotCreator, err := (&creator{fixedInCEL: p.fixedInCEL}).FixedInVersion(&tc.purl) + if err != nil { + t.Fatalf("creator.FixedInVersion: %v", err) + } + if gotCreator != tc.want { + t.Fatalf("creator rewrite = %q, want %q", gotCreator, tc.want) + } + }) + } +} + +func TestFingerprintVersionCEL(t *testing.T) { + t.Parallel() + base := &Updater{} + withCEL := &Updater{fixedInCELDigest: celExprFingerprintDigest(`fixed_in`)} + otherCEL := &Updater{fixedInCELDigest: celExprFingerprintDigest(`type == "oci" ? fixed_in : fixed_in`)} + + if base.fingerprintVersion() != updaterVersion { + t.Fatalf("base = %q, want %q", base.fingerprintVersion(), updaterVersion) + } + if withCEL.fingerprintVersion() == base.fingerprintVersion() { + t.Fatal("CEL fingerprint should differ from base") + } + if withCEL.fingerprintVersion() == otherCEL.fingerprintVersion() { + t.Fatal("different CEL expressions should produce different fingerprints") + } + if d := celExprFingerprintDigest(""); d != "" { + t.Fatalf("empty digest = %q", d) + } +} diff --git a/rhel/vex/parser.go b/rhel/vex/parser.go index 1850f07e0..ad76c0f69 100644 --- a/rhel/vex/parser.go +++ b/rhel/vex/parser.go @@ -18,6 +18,7 @@ import ( "sync" "unique" + "github.com/google/cel-go/cel" "github.com/klauspost/compress/snappy" "github.com/package-url/packageurl-go" @@ -44,11 +45,7 @@ func (u *Updater) DeltaParse(ctx context.Context, contents io.ReadCloser) ([]*cl out := map[string][]*claircore.Vulnerability{} deleted := []string{} - var opts []ParserOption - if u.ignoreKernelPackages { - opts = append(opts, WithIgnoreKernelPackages()) - } - p := NewParser(opts...) + p := NewParser(u.parserOpts()...) r := bufio.NewReader(snappy.NewReader(contents)) sz := 0 for b, err := r.ReadBytes('\n'); err == nil; b, err = r.ReadBytes('\n') { @@ -94,6 +91,7 @@ type Parser struct { defaultComponent *defaultComponentIndex productIDInLinks bool ignoreKernelPackages bool + fixedInCEL cel.Program } // ParserOption is a functional option for [Parser]. @@ -114,6 +112,16 @@ func WithIgnoreKernelPackages() ParserOption { return func(p *Parser) { p.ignoreKernelPackages = true } } +// WithFixedInVersionCEL compiles expr and installs it as the FixedInVersion +// rewrite program. See [FactoryConfig.FixedInVersionCEL] for the CEL variables. +func WithFixedInVersionCEL(expr string) (ParserOption, error) { + prog, err := CompileFixedInVersionCEL(expr) + if err != nil { + return nil, err + } + return func(p *Parser) { p.fixedInCEL = prog }, nil +} + // NewParser creates a new Parser with initialised caches. func NewParser(opts ...ParserOption) *Parser { p := &Parser{ @@ -327,6 +335,7 @@ func (p *Parser) creator(name string, doc *csaf.CSAF) *creator { skip: make(map[string]skipReason), productIDInLinks: p.productIDInLinks, ignoreKernelPackages: p.ignoreKernelPackages, + fixedInCEL: p.fixedInCEL, } } @@ -344,6 +353,7 @@ type creator struct { defaultComponent *defaultComponentIndex productIDInLinks bool ignoreKernelPackages bool + fixedInCEL cel.Program } // SkipReason records the reason a "product_id" is going to be skipped for the @@ -553,12 +563,16 @@ func (s *status) PackageName() (string, error) { return extractPackageName(s.PURL) } -// FixedInVersion reports the "fixed in" version and any error encountered while -// trying to determine it. +// FixedInVersion reports the "fixed in" version for p, applying any configured +// CEL rewrite on the creator. // // May be an empty string even if the returned [error] is nil. -func (s *status) FixedInVersion() (string, error) { - return extractFixedInVersion(s.PURL) +func (c *creator) FixedInVersion(p *packageurl.PackageURL) (string, error) { + v, err := extractFixedInVersion(p) + if err != nil || c.fixedInCEL == nil { + return v, err + } + return EvalFixedInVersionCEL(c.fixedInCEL, p, v) } // Module reports the module name and any error encountered while trying to @@ -868,7 +882,7 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v *csaf.Vulnerabilit key := st.Key() vuln, created := lookup(key) if created { - fixedIn, err := st.FixedInVersion() + fixedIn, err := c.FixedInVersion(st.PURL) if err != nil { log.WarnContext(ctx, "bad purl", "reason", err, "purl", st.PURL, "missing", "FixedInVersion") continue @@ -1147,6 +1161,8 @@ func componentPURLToModuleName(p *packageurl.PackageURL) (string, error) { // - TypeOCI: return the tag qualifier. // - TypeRPM: check for an epoch qualifier and prepend it to the purl.Version. // If no epoch qualifier, default to 0. +// +// Embedders may rewrite the result via [FactoryConfig.FixedInVersionCEL]. func extractFixedInVersion(p *packageurl.PackageURL) (string, error) { switch p.Type { case packageurl.TypeOCI: diff --git a/rhel/vex/updater.go b/rhel/vex/updater.go index bd3ae2d6b..017be3e3c 100644 --- a/rhel/vex/updater.go +++ b/rhel/vex/updater.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/google/cel-go/cel" + "github.com/quay/claircore" "github.com/quay/claircore/libvuln/driver" ) @@ -45,6 +47,8 @@ type Factory struct { base *url.URL compressedFileTimeout time.Duration ignoreKernelPackages bool + fixedInCEL cel.Program + fixedInCELDigest string } // UpdaterSet constructs one Updater @@ -55,6 +59,8 @@ func (f *Factory) UpdaterSet(_ context.Context) (driver.UpdaterSet, error) { client: f.c, compressedFileTimeout: f.compressedFileTimeout, ignoreKernelPackages: f.ignoreKernelPackages, + fixedInCEL: f.fixedInCEL, + fixedInCELDigest: f.fixedInCELDigest, } err := us.Add(u) if err != nil { @@ -81,6 +87,18 @@ type FactoryConfig struct { // // Defaults to false. Toggling this forces a full archive re-ingest. IgnoreKernelPackages bool `json:"ignore_kernel_packages" yaml:"ignore_kernel_packages"` + // FixedInVersionCEL is a CEL expression that must evaluate to a string and may + // rewrite the stock FixedInVersion extracted from a PURL. Empty means stock + // extraction only. Production expressions are defined by embedders. + // Changing this value forces a full archive re-ingest. + // + // Configured on the Factory and copied to the Updater by [Factory.UpdaterSet]. + // + // Available variables: type, namespace, name, version, qualifiers, fixed_in. + // The CEL strings and bindings extensions are enabled (for example + // startsWith, substring, split, cel.bind). Use [CompileFixedInVersionCEL] + // and [EvalFixedInVersionCEL] to enable testing of expressions on client side. + FixedInVersionCEL string `json:"fixed_in_version_cel" yaml:"fixed_in_version_cel"` } // Configure implements driver.Configurable @@ -108,10 +126,16 @@ func (f *Factory) Configure(ctx context.Context, cf driver.ConfigUnmarshaler, c f.compressedFileTimeout = time.Duration(cfg.CompressedFileTimeout) } f.ignoreKernelPackages = cfg.IgnoreKernelPackages + f.fixedInCEL, err = CompileFixedInVersionCEL(cfg.FixedInVersionCEL) + if err != nil { + return err + } + f.fixedInCELDigest = celExprFingerprintDigest(cfg.FixedInVersionCEL) slog.InfoContext(ctx, "vex factory configured", "base_url", f.base.String(), "compressed_file_timeout", f.compressedFileTimeout, - "ignore_kernel_packages", f.ignoreKernelPackages) + "ignore_kernel_packages", f.ignoreKernelPackages, + "fixed_in_version_cel", f.fixedInCEL != nil) return nil } @@ -122,6 +146,36 @@ type Updater struct { client *http.Client compressedFileTimeout time.Duration ignoreKernelPackages bool + fixedInCEL cel.Program + fixedInCELDigest string +} + +// FingerprintVersion returns the updater version encoded in a fingerprint. +// Special cases that affect the data ingestion are encoded in the version. +func (u *Updater) fingerprintVersion() string { + v := updaterVersion + if u.ignoreKernelPackages { + // IgnoreKernelPackages is included so toggling that setting forces a full + // archive re-fetch and re-parse. + v += "+ignore_kernel" + } + if u.fixedInCELDigest != "" { + v += "+cel:" + u.fixedInCELDigest + } + return v +} + +// ParserOpts returns [ParserOption] values reflecting the updater configuration. +func (u *Updater) parserOpts() []ParserOption { + var opts []ParserOption + if u.ignoreKernelPackages { + opts = append(opts, WithIgnoreKernelPackages()) + } + if u.fixedInCEL != nil { + prog := u.fixedInCEL + opts = append(opts, func(p *Parser) { p.fixedInCEL = prog }) + } + return opts } // fingerprint is used to track the state of the changes.csv and deletions.csv endpoints. @@ -200,13 +254,3 @@ func (u *Updater) Configure(ctx context.Context, f driver.ConfigUnmarshaler, c * u.client = c return nil } - -// FingerprintVersion returns the updater version encoded in a fingerprint. -func (u *Updater) fingerprintVersion() string { - if u.ignoreKernelPackages { - // IgnoreKernelPackages is included so toggling that setting forces a full - // archive re-fetch and re-parse. - return updaterVersion + "+ignore_kernel" - } - return updaterVersion -}