Skip to content

Commit

Permalink
Merge pull request #168 from vulncheck-oss/offline-cpe
Browse files Browse the repository at this point in the history
✨ offline CPE support
  • Loading branch information
acidjazz authored Dec 4, 2024
2 parents 3c344ef + b8c4fce commit 157a5a8
Show file tree
Hide file tree
Showing 14 changed files with 1,474 additions and 10 deletions.
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

0 comments on commit 157a5a8

Please sign in to comment.