Skip to content

Commit 27fe27a

Browse files
committed
test(sca): pip install-discovery + requirements-detection coverage
Add tests for the pip requirements detection and install-directory discovery that shipped in v3.40.0, plus a go.mod tidy.
1 parent 1962ea0 commit 27fe27a

5 files changed

Lines changed: 393 additions & 1 deletion

File tree

cmd/scan.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,9 @@ func runLocalScan(
20482048
// Write any requested file outputs.
20492049
if outCfg.cdxFile != "" {
20502050
outBOM := cdx.BuildFromLocalScan(localResults, "1.7", scanCtx, seedBOM)
2051+
// Carry the dependency tree into the file output too, so `-o file.cdx.json`
2052+
// is as complete as the canonical .vulnetix/sbom.cdx.json.
2053+
outBOM.Dependencies = cdx.BuildDependencies(manifestGroups, cdx.ExportCompRefs(outBOM))
20512054
if err := writeBOMToFile(outBOM, outCfg.cdxFile); err != nil {
20522055
fmt.Fprintf(os.Stderr, " warning: could not write CDX to %s: %v\n", outCfg.cdxFile, err)
20532056
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/vulnetix/cli/v3
33
go 1.25.0
44

55
require (
6+
github.com/BurntSushi/toml v1.6.0
67
github.com/Masterminds/semver/v3 v3.5.0
78
github.com/Vulnetix/vdb-cyclonedx v0.2.0
89
github.com/alecthomas/chroma/v2 v2.26.1
@@ -27,7 +28,6 @@ require (
2728

2829
require (
2930
dario.cat/mergo v1.0.2 // indirect
30-
github.com/BurntSushi/toml v1.6.0 // indirect
3131
github.com/Microsoft/go-winio v0.6.2 // indirect
3232
github.com/ProtonMail/go-crypto v1.4.1 // indirect
3333
github.com/agnivade/levenshtein v1.2.1 // indirect

internal/scan/pip_detect_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package scan
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func writePyTestFile(t *testing.T, path, body string) {
11+
t.Helper()
12+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
13+
t.Fatal(err)
14+
}
15+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
16+
t.Fatal(err)
17+
}
18+
}
19+
20+
// TestDetectManifest_RequirementsVariations covers non-standard requirements
21+
// filenames (matched by name pattern → confident) and arbitrary names matched by
22+
// content (versioned/hashed → confident; bare names → tentative).
23+
func TestDetectManifest_RequirementsVariations(t *testing.T) {
24+
dir := t.TempDir()
25+
cases := []struct {
26+
rel, body, wantConf string
27+
}{
28+
{"requirements-dev.txt", "irrelevant content\n", ConfidenceConfident}, // name pattern wins, no read
29+
{"requirements/base.in", "irrelevant content\n", ConfidenceConfident}, // parent dir = requirements/
30+
{"constraints.txt", "irrelevant\n", ConfidenceConfident},
31+
{"app.pip", "irrelevant\n", ConfidenceConfident}, // .pip extension
32+
{"deps.list", "flask==2.3.0\n --hash=sha256:abc\n", ConfidenceConfident}, // content: versioned + hash
33+
{"names.lst", "flask\nrequests\nnumpy\n", ConfidenceTentative}, // content: bare names
34+
}
35+
for _, c := range cases {
36+
full := filepath.Join(dir, c.rel)
37+
writePyTestFile(t, full, c.body)
38+
info, ok := DetectManifest(full)
39+
if !ok {
40+
t.Errorf("%s: not detected", c.rel)
41+
continue
42+
}
43+
if info.Type != "requirements.txt" {
44+
t.Errorf("%s: type = %q, want requirements.txt", c.rel, info.Type)
45+
}
46+
if info.Ecosystem != "pypi" {
47+
t.Errorf("%s: ecosystem = %q, want pypi", c.rel, info.Ecosystem)
48+
}
49+
if info.Confidence != c.wantConf {
50+
t.Errorf("%s: confidence = %q, want %q", c.rel, info.Confidence, c.wantConf)
51+
}
52+
}
53+
}
54+
55+
func TestDetectManifest_NotRequirements(t *testing.T) {
56+
dir := t.TempDir()
57+
cases := []struct{ rel, body string }{
58+
{"notes.txt", "This is prose with spaces.\nMore prose here.\n"}, // spaces → not requirements
59+
{"paths.txt", "src/foo.py\nsrc/bar.py\n"}, // path separators
60+
{"readme.md", "flask\nrequests\n"}, // .md not a sniff candidate
61+
{"data.txt", "key = value\nother = thing\n"}, // assignment, not requirements
62+
}
63+
for _, c := range cases {
64+
full := filepath.Join(dir, c.rel)
65+
writePyTestFile(t, full, c.body)
66+
if info, ok := DetectManifest(full); ok {
67+
t.Errorf("%s: unexpectedly detected as %s (conf=%s)", c.rel, info.Type, info.Confidence)
68+
}
69+
}
70+
}
71+
72+
func TestDetectManifest_OversizedNotSniffed(t *testing.T) {
73+
dir := t.TempDir()
74+
full := filepath.Join(dir, "big.txt")
75+
var b strings.Builder
76+
for b.Len() < 300*1024 {
77+
b.WriteString("flask\n")
78+
}
79+
writePyTestFile(t, full, b.String())
80+
if _, ok := DetectManifest(full); ok {
81+
t.Error("a >256KB .txt must not be content-sniffed as requirements")
82+
}
83+
}
84+
85+
func TestDetectManifest_PylockExact(t *testing.T) {
86+
info, ok := DetectManifest("pylock.toml")
87+
if !ok {
88+
t.Fatal("pylock.toml not detected")
89+
}
90+
if info.Type != "pylock.toml" || info.Ecosystem != "pypi" || !info.IsLock {
91+
t.Fatalf("pylock.toml: %+v", info)
92+
}
93+
}
94+
95+
// Exact requirements.txt / requirements.in keep confident confidence.
96+
func TestDetectManifest_ExactRequirementsConfident(t *testing.T) {
97+
for _, name := range []string{"requirements.txt", "requirements.in"} {
98+
info, ok := DetectManifest(name)
99+
if !ok || info.Confidence != ConfidenceConfident {
100+
t.Errorf("%s: ok=%v confidence=%q, want confident", name, ok, info.Confidence)
101+
}
102+
}
103+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package scan
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// makePyVenv creates a minimal project venv with the given name→version
11+
// distributions as *.dist-info directories, and isolates the venv-discovery env.
12+
func makePyVenv(t *testing.T, root string, dists map[string]string) {
13+
t.Helper()
14+
t.Setenv("VIRTUAL_ENV", "")
15+
t.Setenv("CONDA_PREFIX", "")
16+
t.Setenv("UV_PROJECT_ENVIRONMENT", "")
17+
sp := filepath.Join(root, ".venv", "lib", "python3.13", "site-packages")
18+
if err := os.MkdirAll(sp, 0o755); err != nil {
19+
t.Fatal(err)
20+
}
21+
writePyTestFile(t, filepath.Join(root, ".venv", "pyvenv.cfg"), "home = /usr\nversion_info = 3.13.0\n")
22+
for name, ver := range dists {
23+
di := filepath.Join(sp, name+"-"+ver+".dist-info")
24+
if err := os.MkdirAll(di, 0o755); err != nil {
25+
t.Fatal(err)
26+
}
27+
writePyTestFile(t, filepath.Join(di, "METADATA"), "Name: "+name+"\nVersion: "+ver+"\n")
28+
}
29+
}
30+
31+
func TestRequirementsFullyLocked(t *testing.T) {
32+
cases := []struct {
33+
name string
34+
pkgs []ScopedPackage
35+
want bool
36+
}{
37+
{"exact pin", []ScopedPackage{{Name: "a", Version: "1.0", VersionSpec: "==1.0"}}, true},
38+
{"hashed", []ScopedPackage{{Name: "a", Checksums: []PackageChecksum{{Alg: "SHA-256", Value: "x"}}}}, true},
39+
{"range", []ScopedPackage{{Name: "a", Version: "1.0", VersionSpec: ">=1.0"}}, false},
40+
{"bare name", []ScopedPackage{{Name: "a"}}, false},
41+
{"empty", nil, false},
42+
{"mixed unpinned", []ScopedPackage{{Name: "a", Version: "1.0", VersionSpec: "==1.0"}, {Name: "b"}}, false},
43+
}
44+
for _, c := range cases {
45+
if got := RequirementsFullyLocked(c.pkgs); got != c.want {
46+
t.Errorf("%s: RequirementsFullyLocked = %v, want %v", c.name, got, c.want)
47+
}
48+
}
49+
}
50+
51+
func TestPyLockfilePresent(t *testing.T) {
52+
dir := t.TempDir()
53+
if PyLockfilePresent(dir) {
54+
t.Error("empty dir reports a lock")
55+
}
56+
writePyTestFile(t, filepath.Join(dir, "uv.lock"), "version = 1\n")
57+
if !PyLockfilePresent(dir) {
58+
t.Error("uv.lock present but not detected")
59+
}
60+
}
61+
62+
func TestReadInstalledPythonPackages(t *testing.T) {
63+
dir := t.TempDir()
64+
for _, n := range []string{
65+
"attrs-25.3.0.dist-info",
66+
"jsonschema_specifications-2025.4.1.dist-info",
67+
"foo-1.0.egg-info",
68+
} {
69+
if err := os.MkdirAll(filepath.Join(dir, n), 0o755); err != nil {
70+
t.Fatal(err)
71+
}
72+
}
73+
m, err := readInstalledPythonPackages(dir)
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if m["attrs"].Version != "25.3.0" {
78+
t.Errorf("attrs = %+v", m["attrs"])
79+
}
80+
if m["jsonschema_specifications"].Version != "2025.4.1" {
81+
t.Errorf("jsonschema_specifications = %+v", m["jsonschema_specifications"])
82+
}
83+
if m["foo"].Version != "1.0" {
84+
t.Errorf("foo (egg-info) = %+v", m["foo"])
85+
}
86+
}
87+
88+
// Confident requirements file resolved from a project venv: declared packages
89+
// are manifest provenance, venv-only packages are installed transitives.
90+
func TestResolvePython_VenvResolvesWithProvenance(t *testing.T) {
91+
root := t.TempDir()
92+
writePyTestFile(t, filepath.Join(root, "requirements.txt"), "flask\n")
93+
makePyVenv(t, root, map[string]string{"flask": "2.3.0", "werkzeug": "2.3.0"})
94+
95+
declared := []ScopedPackage{{Name: "flask", Ecosystem: "pypi", Scope: ScopeProduction, IsDirect: true}}
96+
got, err := ResolvePythonRequirementsFromSitePackages(
97+
filepath.Join(root, "requirements.txt"), "requirements.txt", declared, true)
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
102+
flask, ok := findPkg(got, "flask")
103+
if !ok {
104+
t.Fatal("flask missing")
105+
}
106+
if flask.Version != "2.3.0" || !flask.IsDirect || flask.SourceType != SourceTypeManifest || flask.InstalledPath != "" {
107+
t.Errorf("flask provenance wrong: %+v", flask)
108+
}
109+
wk, ok := findPkg(got, "werkzeug")
110+
if !ok {
111+
t.Fatal("werkzeug (transitive) missing")
112+
}
113+
if wk.IsDirect || wk.SourceType != SourceTypeInstalled || wk.InstalledPath == "" {
114+
t.Errorf("werkzeug provenance wrong: %+v", wk)
115+
}
116+
}
117+
118+
// A confident file with a declared dep absent from the env is a fatal error with
119+
// the build-or-lock remediation.
120+
func TestResolvePython_ConfidentMissingErrors(t *testing.T) {
121+
root := t.TempDir()
122+
writePyTestFile(t, filepath.Join(root, "requirements.txt"), "flask>=2.0\n")
123+
makePyVenv(t, root, map[string]string{"requests": "2.0"}) // flask NOT installed
124+
125+
declared := []ScopedPackage{{Name: "flask", VersionSpec: ">=2.0", Ecosystem: "pypi"}}
126+
_, err := ResolvePythonRequirementsFromSitePackages(
127+
filepath.Join(root, "requirements.txt"), "requirements.txt", declared, true)
128+
if err == nil {
129+
t.Fatal("expected error for a missing confident dependency")
130+
}
131+
for _, want := range []string{"build the app", "generate a lock file", "flask"} {
132+
if !strings.Contains(err.Error(), want) {
133+
t.Errorf("error missing %q: %v", want, err)
134+
}
135+
}
136+
}
137+
138+
// A tentative file is confirmed when ANY declared name is installed (user choice).
139+
func TestResolvePython_TentativeAnyMatchConfirms(t *testing.T) {
140+
root := t.TempDir()
141+
writePyTestFile(t, filepath.Join(root, "names.lst"), "flask\nnotapkg\n")
142+
makePyVenv(t, root, map[string]string{"flask": "2.3.0"})
143+
144+
declared := []ScopedPackage{{Name: "flask"}, {Name: "notapkg"}}
145+
got, err := ResolvePythonRequirementsFromSitePackages(
146+
filepath.Join(root, "names.lst"), "names.lst", declared, false)
147+
if err != nil {
148+
t.Fatalf("tentative file with 1 installed match should resolve: %v", err)
149+
}
150+
if _, ok := findPkg(got, "flask"); !ok {
151+
t.Error("flask should be resolved")
152+
}
153+
}
154+
155+
// A tentative file with no installed matches is disregarded (resolver errors so
156+
// the caller drops it).
157+
func TestResolvePython_TentativeNoMatchDropped(t *testing.T) {
158+
root := t.TempDir()
159+
writePyTestFile(t, filepath.Join(root, "names.lst"), "alpha\nbeta\n")
160+
makePyVenv(t, root, map[string]string{"flask": "2.3.0"})
161+
162+
declared := []ScopedPackage{{Name: "alpha"}, {Name: "beta"}}
163+
if _, err := ResolvePythonRequirementsFromSitePackages(
164+
filepath.Join(root, "names.lst"), "names.lst", declared, false); err == nil {
165+
t.Fatal("tentative file with no installed match should error (be dropped)")
166+
}
167+
}

0 commit comments

Comments
 (0)