diff --git a/internal/lsp/sca_engine_queries_test.go b/internal/lsp/sca_engine_queries_test.go new file mode 100644 index 000000000..03e14751a --- /dev/null +++ b/internal/lsp/sca_engine_queries_test.go @@ -0,0 +1,137 @@ +package lsp + +import ( + "reflect" + "testing" + + "github.com/vulnetix/cli/v4/internal/lsp/protocol" + "github.com/vulnetix/cli/v4/internal/sast" + "github.com/vulnetix/cli/v4/internal/scan" +) + +func TestEngineManifestLookups(t *testing.T) { + e := seedEngine(t) + e.typeByFile["web/package-lock.json"] = "package-lock.json" + + pkgs, typ, ok := e.PackagesFor("package.json") + if !ok || typ != "package.json" || len(pkgs) != 3 { + t.Errorf("PackagesFor(package.json) = %d packages, %q, %v", len(pkgs), typ, ok) + } + if pkgs, typ, ok := e.PackagesFor("missing.json"); ok || typ != "" || pkgs != nil { + t.Errorf("PackagesFor(unknown) = %v, %q, %v", pkgs, typ, ok) + } + + if !e.IsManifest("package.json") || e.IsManifest("main.go") { + t.Error("IsManifest is true only for parsed manifests") + } + if got := e.ManifestPaths(); !reflect.DeepEqual(got, []string{"package.json", "web/package-lock.json"}) { + t.Errorf("ManifestPaths = %v", got) + } +} + +func TestEngineVerdictFor(t *testing.T) { + e := seedEngine(t) + v, ok := e.VerdictFor("lodash", "4.17.20", "npm") + if !ok || v == nil || !v.Vulnerable() { + t.Fatalf("VerdictFor(lodash) = %+v, %v", v, ok) + } + if v, ok := e.VerdictFor("express", "4.16.0", "npm"); !ok || v.Vulnerable() { + t.Errorf("a clean package still has a verdict: %+v, %v", v, ok) + } + if _, ok := e.VerdictFor("lodash", "9.9.9", "npm"); ok { + t.Error("an unknown version has no verdict") + } + if _, ok := e.VerdictFor("", "", ""); ok { + t.Error("no coordinate, no purl, no verdict") + } +} + +func TestEngineIntroducedBy(t *testing.T) { + e := seedEngine(t) + pkg := func(name string) scan.ScopedPackage { return scan.ScopedPackage{Name: name} } + e.groups = []scan.ManifestGroup{ + { + Dir: "web", + Graph: &scan.DepGraph{ + DirectDeps: map[string]scan.ScopedPackage{"express": pkg("express"), "mocha": pkg("mocha")}, + Edges: map[string][]string{ + "express": {"qs", "debug"}, + "mocha": {"debug", "minimist"}, + "debug": {"ms"}, // debug is transitive: not a parent to report + }, + }, + }, + {Dir: "other", Graph: &scan.DepGraph{ + DirectDeps: map[string]scan.ScopedPackage{"koa": pkg("koa")}, + Edges: map[string][]string{"koa": {"debug"}}, + }}, + {Dir: "nograph"}, + } + + if got := e.IntroducedBy("web/package.json", "debug"); !reflect.DeepEqual(got, []string{"express", "mocha"}) { + t.Errorf("IntroducedBy(debug) = %v, want the direct parents in this directory, sorted", got) + } + if got := e.IntroducedBy("web/package.json", "ms"); got != nil { + t.Errorf("IntroducedBy(ms) = %v, want none: its parent is itself transitive", got) + } + if got := e.IntroducedBy("web/package.json", "express"); got != nil { + t.Errorf("a direct package is introduced by nobody: %v", got) + } + if got := e.IntroducedBy("nograph/package.json", "debug"); got != nil { + t.Errorf("no graph, no claim: %v", got) + } + if got := e.IntroducedBy("elsewhere/package.json", "debug"); got != nil { + t.Errorf("an unknown directory: %v", got) + } +} + +func TestEngineAnchorFor(t *testing.T) { + e := seedEngine(t) + text := "{\n \"dependencies\": {\n \"lodash\": \"4.17.20\"\n }\n}\n" + res, ok := e.AnchorFor("package.json", text, "lodash") + if !ok || res.Line != 3 { + t.Errorf("AnchorFor(lodash) = %+v, %v; want line 3 (1-based)", res, ok) + } + if _, ok := e.AnchorFor("never-parsed.json", text, "lodash"); ok { + t.Error("a path the engine never parsed has no anchor") + } +} + +func TestDiagnosticMappingFallbacks(t *testing.T) { + if got := SourceForKind(sast.KindSecrets); got != "vulnetix-secrets" { + t.Errorf("SourceForKind(secrets) = %q", got) + } + if got := SourceForKind("brand-new-kind"); got != "vulnetix-sast" { + t.Errorf("an unmapped kind falls back to the sast source: %q", got) + } + + for _, tt := range []struct { + sev string + hint bool + want int + }{ + {"critical", false, protocol.SeverityError}, + {" HIGH ", false, protocol.SeverityError}, + {"Medium", false, protocol.SeverityWarning}, + {"low", false, protocol.SeverityInformation}, + {"low", true, protocol.SeverityHint}, + {"info", true, protocol.SeverityHint}, + {"", false, protocol.SeverityInformation}, + {"bogus", true, protocol.SeverityHint}, + } { + if got := severityToLSP(tt.sev, SeverityMapping{LowAsHint: tt.hint}); got != tt.want { + t.Errorf("severityToLSP(%q, hint=%v) = %d, want %d", tt.sev, tt.hint, got, tt.want) + } + } + + for kind, want := range map[string]string{ + sast.KindOCI: "container", + "": sast.KindSAST, + sast.KindSecrets: sast.KindSecrets, + "custom": "custom", + } { + if got := kindToTool(kind); got != want { + t.Errorf("kindToTool(%q) = %q, want %q", kind, got, want) + } + } +} diff --git a/internal/lsp/sca_helpers_test.go b/internal/lsp/sca_helpers_test.go new file mode 100644 index 000000000..936b7e7aa --- /dev/null +++ b/internal/lsp/sca_helpers_test.go @@ -0,0 +1,130 @@ +package lsp + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" + "time" + + "github.com/vulnetix/cli/v4/internal/lsp/protocol" + "github.com/vulnetix/cli/v4/internal/scan" + "github.com/vulnetix/cli/v4/pkg/vdb" +) + +func TestIsTransientSCAErr(t *testing.T) { + for _, tt := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"429", &vdb.CliAPIError{StatusCode: 429}, true}, + {"500", &vdb.CliAPIError{StatusCode: 500}, true}, + {"503 wrapped", fmt.Errorf("batch: %w", &vdb.CliAPIError{StatusCode: 503}), true}, + {"400", &vdb.CliAPIError{StatusCode: 400}, false}, + {"403", &vdb.CliAPIError{StatusCode: 403}, false}, + {"not found", &vdb.NotFoundError{Message: "gone"}, false}, + {"deadline", fmt.Errorf("call: %w", context.DeadlineExceeded), true}, + {"cancelled", fmt.Errorf("call: %w", context.Canceled), false}, + {"transport", errors.New("failed to execute request: connection reset"), true}, + {"decode", errors.New("invalid character '<' looking for beginning of value"), false}, + } { + if got := isTransientSCAErr(tt.err); got != tt.want { + t.Errorf("%s: isTransientSCAErr = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestBackoffFor(t *testing.T) { + for round, want := range map[int]time.Duration{ + -3: scaBackoffBase, + 0: scaBackoffBase, + 1: 2 * scaBackoffBase, + 2: 4 * scaBackoffBase, + 3: scaBackoffMax, + 10: scaBackoffMax, + } { + if got := backoffFor(round); got != want { + t.Errorf("backoffFor(%d) = %s, want %s", round, got, want) + } + } +} + +func TestCountBySeverityAndDescribeCounts(t *testing.T) { + diags := map[string][]protocol.Diagnostic{ + "file:///a.go": {{Severity: protocol.SeverityError}, {Severity: protocol.SeverityWarning}, {Severity: protocol.SeverityInformation}}, + "file:///b.go": {{Severity: protocol.SeverityError}, {Severity: protocol.SeverityHint}}, + "file:///c.go": nil, + } + counts := CountBySeverity(diags) + if want := map[string]int{"error": 2, "warning": 1, "info": 2}; !reflect.DeepEqual(counts, want) { + t.Errorf("CountBySeverity = %v, want %v", counts, want) + } + if got := DescribeCounts(counts); got != "2 error(s), 1 warning(s), 2 note(s)" { + t.Errorf("DescribeCounts = %q", got) + } + if got := DescribeCounts(CountBySeverity(nil)); got != "no findings" { + t.Errorf("DescribeCounts(empty) = %q", got) + } + if got := DescribeCounts(map[string]int{"warning": 3}); got != "0 error(s), 3 warning(s), 0 note(s)" { + t.Errorf("DescribeCounts(warnings only) = %q", got) + } +} + +func TestFlattenPackagesIsInPathOrder(t *testing.T) { + byFile := map[string][]scan.ScopedPackage{ + "web/package.json": {{Name: "react"}, {Name: "lodash"}}, + "api/go.mod": {{Name: "chi"}}, + "z/Cargo.toml": nil, + } + var names []string + for range 5 { + names = names[:0] + for _, p := range flattenPackages(byFile) { + names = append(names, p.Name) + } + if want := []string{"chi", "react", "lodash"}; !reflect.DeepEqual(names, want) { + t.Fatalf("flattenPackages order = %v, want %v", names, want) + } + } +} + +func TestBuildPurlsIsIndexAlignedAndDeduplicated(t *testing.T) { + pkgs := []scan.ScopedPackage{ + {Name: "lodash", Version: "4.17.20", Ecosystem: "npm"}, + {Name: "", Version: "", Ecosystem: ""}, + {Name: "lodash", Version: "4.17.20", Ecosystem: "npm"}, + {Name: "requests", Version: "2.19.1", Ecosystem: "pypi"}, + } + byIndex, unique := buildPurls(pkgs) + if len(byIndex) != len(pkgs) { + t.Fatalf("byIndex has %d entries for %d packages", len(byIndex), len(pkgs)) + } + if byIndex[0] == "" || byIndex[0] != byIndex[2] { + t.Errorf("the same package must get the same purl at its own index: %v", byIndex) + } + if len(unique) != 2 || unique[0] != byIndex[0] || unique[1] != byIndex[3] { + t.Errorf("unique = %v, want each non-empty purl once in first-seen order (byIndex %v)", unique, byIndex) + } + for _, u := range unique { + if u == "" { + t.Errorf("an empty purl is never requested: %v", unique) + } + } +} + +func TestCountVulnerable(t *testing.T) { + verdicts := map[string]*scaVerdict{ + "a": {Vulns: []scan.EnrichedVuln{{}}}, + "b": {}, + "c": {Vulns: []scan.EnrichedVuln{{}, {}}}, + } + if got := countVulnerable(verdicts); got != 2 { + t.Errorf("countVulnerable = %d, want 2", got) + } + if got := countVulnerable(nil); got != 0 { + t.Errorf("countVulnerable(nil) = %d", got) + } +}