Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ offline CPE support #168

Merged
merged 15 commits into from
Dec 4, 2024
Merged
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/charmbracelet/lipgloss v1.0.0
github.com/dustin/go-humanize v1.0.1
github.com/fumeapp/taskin v0.1.8
github.com/hashicorp/go-version v1.7.0
github.com/itchyny/gojq v0.12.16
github.com/package-url/packageurl-go v0.1.3
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
Expand Down
86 changes: 86 additions & 0 deletions pkg/cmd/offline/cpe/cpe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cpe

import (
"fmt"
"github.com/spf13/cobra"
"github.com/vulncheck-oss/cli/pkg/cache"
"github.com/vulncheck-oss/cli/pkg/cmd/offline/sync"
"github.com/vulncheck-oss/cli/pkg/config"
"github.com/vulncheck-oss/cli/pkg/cpe/cpeoffline"
"github.com/vulncheck-oss/cli/pkg/cpe/cpeuri"
"github.com/vulncheck-oss/cli/pkg/cpe/cpeutils"
"github.com/vulncheck-oss/cli/pkg/search"
"github.com/vulncheck-oss/cli/pkg/ui"
)

func Command() *cobra.Command {

var jsonOutput bool

var statsOnly bool

cmd := &cobra.Command{
Use: "cpe <scheme>",
Short: "Offline CPE lookup",
Long: "Search offline package data via CPE schemes",
Example: "vulncheck offline cpe \"pkg:hackage/aeson@0.3.2.8\"",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {

cpe, err := cpeuri.ToStruct(args[0])

if err != nil {
return err
}

indices, err := cache.Indices()
if err != nil {
return err
}

indexAvailable, err := sync.EnsureIndexSync(indices, "cpecve", false)
if err != nil {
return err
}

if !indexAvailable {
return fmt.Errorf("index cpecve is required to proceed")
}

query, err := cpeoffline.Query(cpe)

if err != nil {
return err
}

results, stats, err := search.IndexCPE("cpecve", *cpe, query)

if err != nil {
return err
}
cves, err := cpeutils.Process(cpe, results)

if err != nil {
return err
}

if jsonOutput || config.IsCI() {
ui.Json(cves)
return nil
}

ui.Stat("Results found/filtered", fmt.Sprintf("%d/%d", len(results), len(cves)))
ui.Stat("Files/Lines processed", fmt.Sprintf("%d/%d", stats.TotalFiles, stats.TotalLines))
ui.Stat("Search duration", fmt.Sprintf("%.2f seconds", stats.Duration.Seconds()))

if !statsOnly {
ui.Json(cves)
}

return nil
},
}
cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "Output in JSON format")
cmd.Flags().BoolVarP(&statsOnly, "stats", "s", false, "Output stats only")
return cmd
}
2 changes: 1 addition & 1 deletion pkg/cmd/offline/ipintel/ipintel.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func Command() *cobra.Command {
ui.Info(fmt.Sprintf("Searching index %s, last updated on %s", index.Name, utils.ParseDate(index.LastUpdated)))
}

results, stats, err := search.Index(index.Name, query)
results, stats, err := search.IPIndex(index.Name, query)
if err != nil {
return err
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/offline/offline.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package offline

import (
"github.com/spf13/cobra"
"github.com/vulncheck-oss/cli/pkg/cmd/offline/cpe"
"github.com/vulncheck-oss/cli/pkg/cmd/offline/ipintel"
"github.com/vulncheck-oss/cli/pkg/cmd/offline/purl"
"github.com/vulncheck-oss/cli/pkg/cmd/offline/sync"
Expand All @@ -22,6 +23,7 @@ func Command() *cobra.Command {
cmd.AddCommand(ipintel.Command())
cmd.AddCommand(ipintel.AliasCommands()...)
cmd.AddCommand(purl.Command())
cmd.AddCommand(cpe.Command())

return cmd
}
7 changes: 7 additions & 0 deletions pkg/cmd/offline/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"slices"
)

var specialIndices = []string{"cpecve"}

func Command() *cobra.Command {

var addIndices, removeIndices []string
Expand All @@ -37,6 +39,11 @@ func Command() *cobra.Command {
availableIndices[index.Name] = true
}

// Add special indices to availableIndices
for _, specialIndex := range specialIndices {
availableIndices[specialIndex] = true
}

// Handle purge flag
if purge {
if err := cache.PurgeIndices(); err != nil {
Expand Down
64 changes: 64 additions & 0 deletions pkg/cpe/cpeoffline/cpeoffline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cpeoffline

import (
"fmt"
"github.com/vulncheck-oss/cli/pkg/cpe/cpeutils"
"strings"
)

func Query(cpe *cpeutils.CPE) (string, error) {

if cpe.Vendor == "*" && cpe.Product == "*" {
return "", fmt.Errorf("need at least vendor or product specified")
}

return buildCPEQuery(cpe, !cpeutils.IsParseableVersion(cpeutils.Unquote(cpe.Version)))
}

func addCondition(field, value string) string {
if value != "*" {
return fmt.Sprintf(`(.%s == %q or .%s == "*")`, field, value, field)
}
return ""
}

func buildCPEQuery(cpe *cpeutils.CPE, queryVersion bool) (string, error) {
if cpe.Vendor == "*" && cpe.Product == "*" {
return "", fmt.Errorf("need at least vendor or product specified")
}

var conditions []string

fields := []struct {
name string
value string
}{
{"vendor", cpe.Vendor},
{"product", cpe.Product},
{"update", cpe.Update},
{"edition", cpe.Edition},
{"language", cpe.Language},
{"sw_edition", cpe.SoftwareEdition},
{"target_sw", cpe.TargetSoftware},
{"target_hw", cpe.TargetHardware},
{"other", cpe.Other},
}

// Add version field only if withVersion is true
if queryVersion {
fields = append(fields, struct{ name, value string }{"version", cpeutils.Unquote(cpe.Version)})
}

for _, field := range fields {
if condition := addCondition(field.name, field.value); condition != "" {
conditions = append(conditions, condition)
}
}

if len(conditions) == 0 {
return "true", nil
}

query := strings.Join(conditions, " and ")
return query, nil
}
164 changes: 164 additions & 0 deletions pkg/cpe/cpeoffline/cpeoffline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cpeoffline

import (
"github.com/vulncheck-oss/cli/pkg/cpe/cpeutils"
"testing"
)

func TestQuery(t *testing.T) {
tests := []struct {
name string
cpe *cpeutils.CPE
want string
wantErr bool
}{
{
name: "Valid CPE",
cpe: &cpeutils.CPE{
Vendor: "vendor",
Product: "product",
Version: "1.0",
},
want: `(.vendor == "vendor" or .vendor == "*") and (.product == "product" or .product == "*") and (.update == "" or .update == "*") and (.edition == "" or .edition == "*") and (.language == "" or .language == "*") and (.sw_edition == "" or .sw_edition == "*") and (.target_sw == "" or .target_sw == "*") and (.target_hw == "" or .target_hw == "*") and (.other == "" or .other == "*")`,
wantErr: false,
},
{
name: "CPE with all fields",
cpe: &cpeutils.CPE{
Vendor: "vendor",
Product: "product",
Version: "1.0",
Update: "update",
Edition: "edition",
Language: "language",
SoftwareEdition: "sw_edition",
TargetSoftware: "target_sw",
TargetHardware: "target_hw",
Other: "other",
},
want: `(.vendor == "vendor" or .vendor == "*") and (.product == "product" or .product == "*") and ` +
`(.update == "update" or .update == "*") and (.edition == "edition" or .edition == "*") and ` +
`(.language == "language" or .language == "*") and (.sw_edition == "sw_edition" or .sw_edition == "*") and ` +
`(.target_sw == "target_sw" or .target_sw == "*") and (.target_hw == "target_hw" or .target_hw == "*") and ` +
`(.other == "other" or .other == "*")`,
wantErr: false,
},
{
name: "CPE with wildcard version",
cpe: &cpeutils.CPE{
Vendor: "vendor",
Product: "product",
Version: "x.y",
},
want: `(.vendor == "vendor" or .vendor == "*") and (.product == "product" or .product == "*") and (.update == "" or .update == "*") and (.edition == "" or .edition == "*") and (.language == "" or .language == "*") and (.sw_edition == "" or .sw_edition == "*") and (.target_sw == "" or .target_sw == "*") and (.target_hw == "" or .target_hw == "*") and (.other == "" or .other == "*") and (.version == "x.y" or .version == "*")`,
wantErr: false,
},
{
name: "Invalid CPE (both vendor and product are wildcards)",
cpe: &cpeutils.CPE{
Vendor: "*",
Product: "*",
},
want: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Query(tt.cpe)
if (err != nil) != tt.wantErr {
t.Errorf("Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Query() = %v, want %v", got, tt.want)
}
})
}
}

func TestAddCondition(t *testing.T) {
tests := []struct {
name string
field string
value string
want string
}{
{
name: "Non-wildcard value",
field: "vendor",
value: "test",
want: `(.vendor == "test" or .vendor == "*")`,
},
{
name: "Wildcard value",
field: "product",
value: "*",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := addCondition(tt.field, tt.value); got != tt.want {
t.Errorf("addCondition() = %v, want %v", got, tt.want)
}
})
}
}

func TestBuildCPEQuery(t *testing.T) {
tests := []struct {
name string
cpe *cpeutils.CPE
queryVersion bool
want string
wantErr bool
}{
{
name: "Valid CPE without version",
cpe: &cpeutils.CPE{
Vendor: "vendor",
Product: "product",
},
queryVersion: false,
want: `(.vendor == "vendor" or .vendor == "*") and (.product == "product" or .product == "*") and (.update == "" or .update == "*") and (.edition == "" or .edition == "*") and (.language == "" or .language == "*") and (.sw_edition == "" or .sw_edition == "*") and (.target_sw == "" or .target_sw == "*") and (.target_hw == "" or .target_hw == "*") and (.other == "" or .other == "*")`,
wantErr: false,
},
{
name: "Valid CPE with version",
cpe: &cpeutils.CPE{
Vendor: "vendor",
Product: "product",
Version: "1.0",
},
queryVersion: true,
want: `(.vendor == "vendor" or .vendor == "*") and (.product == "product" or .product == "*") and (.update == "" or .update == "*") and (.edition == "" or .edition == "*") and (.language == "" or .language == "*") and (.sw_edition == "" or .sw_edition == "*") and (.target_sw == "" or .target_sw == "*") and (.target_hw == "" or .target_hw == "*") and (.other == "" or .other == "*") and (.version == "1.0" or .version == "*")`,
wantErr: false,
},
{
name: "Invalid CPE (both vendor and product are wildcards)",
cpe: &cpeutils.CPE{
Vendor: "*",
Product: "*",
},
queryVersion: false,
want: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildCPEQuery(tt.cpe, tt.queryVersion)
if (err != nil) != tt.wantErr {
t.Errorf("buildCPEQuery() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("buildCPEQuery() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading