Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8e9ce75
test: add golden tests for AnalyzeLibrary
kotakanbe Mar 17, 2026
f7d7408
refactor: extract file-to-parser dispatch function
kotakanbe Mar 17, 2026
1509894
deps: remove fanal framework, call Trivy parsers directly
kotakanbe Mar 17, 2026
a536e40
style: fix goimports formatting and go mod tidy
kotakanbe Mar 17, 2026
0962eb9
test: address Copilot review on golden tests
kotakanbe Mar 17, 2026
4ebd1f2
test: copy text fixtures to testdata for CI compatibility
kotakanbe Mar 17, 2026
c7260d0
test: force-add requirements.txt ignored by *.txt gitignore
kotakanbe Mar 18, 2026
afed2c9
fix: address Copilot review on PR #2476
kotakanbe Mar 18, 2026
f9cc28c
fix: address Copilot review round 2 on PR #2476
kotakanbe Mar 18, 2026
b1c4c88
fix: address Copilot review round 3
kotakanbe Mar 18, 2026
3ac622b
test: add diff-lockfile for A/B regression testing
kotakanbe Mar 18, 2026
13329ff
chore: gitignore compare-lockfile binary
kotakanbe Mar 18, 2026
4a56ec1
docs: add scripts/README.md for diff-lockfile usage
kotakanbe Mar 18, 2026
c954ef7
fix: address Copilot review round 4 on PR #2476
kotakanbe Mar 18, 2026
8885a19
fix: update fixtures to non-zero libs and fix docs
kotakanbe Mar 18, 2026
2a7187f
fix: surface all filesystem errors in compare-lockfile
kotakanbe Mar 18, 2026
86550f6
fix: add complete sort tie-breakers and harden error handling
kotakanbe Mar 18, 2026
2ebd3e3
fix: sort imports per gofmt and sanitize worktree path
kotakanbe Mar 18, 2026
60207aa
fix: use MkdirTemp for worktree and handle result-counting errors
kotakanbe Mar 19, 2026
7bedc67
fix: check os.Remove and worktree cleanup errors
kotakanbe Mar 19, 2026
2e1001e
fix: use abspath instead of trivypath in error messages
kotakanbe Mar 19, 2026
7ada7fc
docs: use workdir-relative path in README instead of hardcoded /tmp
kotakanbe Mar 19, 2026
cf9810c
fix: address remaining code quality issues
kotakanbe Mar 19, 2026
4d70b7d
fix: fix result map key and add diff fallback
kotakanbe Mar 19, 2026
574ec26
fix: comprehensive code quality sweep
kotakanbe Mar 19, 2026
82d7e60
fix: add ComposerVendor dispatch and complete test coverage
kotakanbe Mar 19, 2026
504fa70
fix: add ComposerVendor dispatch, fix lint, use request context
kotakanbe Mar 19, 2026
77b85d8
fix: make diff-lockfile fetch optional via FETCH variable
kotakanbe Mar 19, 2026
364d87f
docs: fix README wording to match actual script behavior
kotakanbe Mar 19, 2026
27fa2c7
fix: detect .exe and exclude non-regular files in isExecutable
kotakanbe Mar 19, 2026
be81b3e
fix: address review feedback (rename, typo, comments, unused arg)
kotakanbe Mar 19, 2026
8ba7439
docs: add security rationale for not using submodules in CI
kotakanbe Mar 19, 2026
3f3890e
test: expand diff-lockfile to 129 fixtures, remove unused jar method
kotakanbe Mar 19, 2026
81d07f1
chore: add /compare-lockfile binary to .gitignore
kotakanbe Mar 19, 2026
8f58ddf
style: fix goimports alignment in dispatch_test.go
kotakanbe Mar 19, 2026
8661bd3
fix: check IsRegular before .exe in isExecutable
kotakanbe Mar 20, 2026
03dfcb6
docs: update fixture count in scripts/README.md (17 → 129)
kotakanbe Mar 23, 2026
204fb48
test: compare lib count instead of byte length in pom online test
kotakanbe Mar 23, 2026
3c8a234
fix: unmarshal golden JSON as slice in PomOnline test
kotakanbe Mar 23, 2026
ce2bcdf
Merge branch 'master' into diet-trivy-dispatch
kotakanbe Mar 24, 2026
52c78de
fix: address MaineK00n review feedback
claude Mar 27, 2026
a426e58
Merge branch 'master' into diet-trivy-dispatch
kotakanbe Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ vuls
/trivy-to-vuls
snmp2cpe
!snmp2cpe/
/scripts/compare-lockfile
/compare-lockfile
14 changes: 13 additions & 1 deletion GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
pretest \
test \
cov \
clean
clean \
compare-lockfile

SRCS = $(shell git ls-files '*.go')
PKGS = $(shell go list ./...)
Expand Down Expand Up @@ -241,6 +242,17 @@ define sed-d
find ${ONE_SEC_AFTER_JSON_DIR} -type f -exec sed -i -e '/scannedRevision/d' {} \;
endef

# Compare AnalyzeLibrary output between current branch and BASE ref.
# Fetches real-world lockfiles from popular OSS projects and compares results.
# Usage:
# make compare-lockfile # fetch fixtures and compare against master
# make compare-lockfile BASE=commit # compare against specific ref
# make compare-lockfile FETCH=0 # re-run with cached fixtures (skip download)
BASE ?= master
FETCH ?= 1
compare-lockfile:
$(GO) run scripts/compare-lockfile.go $(if $(filter 1,$(FETCH)),-fetch) -base $(BASE)

define count-cve
for jsonfile in ${NOW_JSON_DIR}/*.json ; do \
echo $$jsonfile; cat $$jsonfile | jq ".scannedCves | length" ; \
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ require (
github.com/vulsio/gost v0.7.2
go.etcd.io/bbolt v1.4.3
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
Expand Down Expand Up @@ -347,6 +346,7 @@ require (
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
Expand Down
317 changes: 317 additions & 0 deletions scanner/analyze_golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
package scanner

import (
"cmp"
"context"
"encoding/json"
"flag"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/future-architect/vuls/models"
)

var update = flag.Bool("update", false, "update golden files")

// lockfileEntry defines a test fixture for AnalyzeLibrary golden testing.
type lockfileEntry struct {
// path is the relative path from the fixtures directory.
path string
// filemode to pass to AnalyzeLibrary (0755 for executables, 0644 otherwise).
filemode os.FileMode
// binary indicates the fixture is a binary file only available in the
// integration submodule (not copied to testdata/fixtures/).
binary bool
// expectParseError indicates this fixture is known to produce a parse error
// (e.g. unsupported lockfile version). The test treats errors as empty result.
expectParseError bool
}

var lockfiles = []lockfileEntry{
// Node.js
{"npm-v1/package-lock.json", 0644, false, false},
{"npm-v2/package-lock.json", 0644, false, false},
{"npm-v3/package-lock.json", 0644, false, false},
{"yarn.lock", 0644, false, false},
{"pnpm/pnpm-lock.yaml", 0644, false, true}, // pnpm v8: known parse error
{"pnpm-v9/pnpm-lock.yaml", 0644, false, false},
{"bun.lock", 0644, false, false},

// Python
{"requirements.txt", 0644, false, false},
{"Pipfile.lock", 0644, false, false},
{"poetry-v1/poetry.lock", 0644, false, false},
{"poetry-v2/poetry.lock", 0644, false, false},
{"uv.lock", 0644, false, false},

// Ruby
{"Gemfile.lock", 0644, false, false},

// Rust
{"Cargo.lock", 0644, false, false},
{"hello-rust", 0755, true, false},

// PHP
{"composer.lock", 0644, false, false},
{"installed.json", 0644, false, false},

// Go
{"go.mod", 0644, false, false},
{"go.sum", 0644, false, false},
{"gobinary", 0755, true, false},

// Java
{"pom.xml", 0644, false, false},
{"gradle.lockfile", 0644, false, false},
{"log4j-core-2.13.0.jar", 0644, true, false},
{"wrong-name-log4j-core.jar", 0644, true, false},
{"juddiv3-war-3.3.5.war", 0644, true, false},

// .NET
{"packages.lock.json", 0644, false, false},
{"packages.config", 0644, false, false},
{"datacollector.deps.json", 0644, false, false},
{"Directory.Packages.props", 0644, false, false},

// C/C++
{"conan-v1/conan.lock", 0644, false, false},
{"conan-v2/conan.lock", 0644, false, false},

// Dart
{"pubspec.lock", 0644, false, false},

// Elixir
{"mix.lock", 0644, false, false},

// Swift
{"Podfile.lock", 0644, false, false},
{"Package.resolved", 0644, false, false},
}

// goldenFileName converts a lockfile path to a golden file name.
// e.g. "npm-v3/package-lock.json" -> "npm-v3_package-lock.json"
// Uses filepath.ToSlash to normalize path separators across platforms.
func goldenFileName(lockfilePath string) string {
return strings.ReplaceAll(filepath.ToSlash(lockfilePath), "/", "_") + ".json"
}

func TestAnalyzeLibrary_Golden(t *testing.T) {
fixturesDir := filepath.Join("testdata", "fixtures")
integrationDir := filepath.Join("..", "integration", "data", "lockfile")
goldenDir := filepath.Join("testdata", "golden")

for _, lf := range lockfiles {
t.Run(lf.path, func(t *testing.T) {
// Test fixtures are in testdata/fixtures/ (committed to repo).
// Binary fixtures (JAR, WAR, Go/Rust binaries) are only in the
// integration submodule — skip if not available.
// NOTE: We intentionally do NOT add submodules: true to CI checkout.
// Attack scenario: an attacker forks this repo, edits .gitmodules to
// replace the integration submodule URL with their own repo containing
// a malicious go.mod or _test.go, then opens a PR. If CI checks out
// submodules, `go test` executes attacker-controlled code with access
// to the CI environment (secrets, GITHUB_TOKEN, network).
// Binary fixture tests therefore run locally only.
srcPath := filepath.Join(fixturesDir, lf.path)
if lf.binary {
srcPath = filepath.Join(integrationDir, lf.path)
}
contents, err := os.ReadFile(srcPath)
if err != nil {
if lf.binary {
t.Skipf("Binary fixture not found: %s (requires: git submodule update --init)", srcPath)
}
t.Fatalf("Failed to read %s: %v", srcPath, err)
}

got, err := AnalyzeLibrary(context.Background(), lf.path, contents, lf.filemode, true)
if err != nil {
if lf.expectParseError {
// Verify the error is actually a parse error (contains "parse error" or the parser type)
errMsg := err.Error()
if !strings.Contains(errMsg, "parse error") && !strings.Contains(errMsg, "Failed to parse") {
t.Fatalf("AnalyzeLibrary(%s) expected parse error but got: %v", lf.path, err)
}
t.Logf("AnalyzeLibrary(%s) returned expected parse error: %v", lf.path, err)
got = nil
} else {
t.Fatalf("AnalyzeLibrary(%s) unexpected error: %v", lf.path, err)
}
}

gotJSON, err := json.MarshalIndent(normalizeResult(got), "", " ")
if err != nil {
t.Fatalf("Failed to marshal result: %v", err)
}

goldenPath := filepath.Join(goldenDir, goldenFileName(lf.path))

if *update {
if err := os.MkdirAll(goldenDir, 0755); err != nil {
t.Fatalf("Failed to create golden dir: %v", err)
}
if err := os.WriteFile(goldenPath, gotJSON, 0644); err != nil {
t.Fatalf("Failed to write golden file: %v", err)
}
t.Logf("Updated golden file: %s", goldenPath)
return
}

wantJSON, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("Golden file not found: %s (run with -update to generate)", goldenPath)
}

if string(gotJSON) != string(wantJSON) {
t.Errorf("AnalyzeLibrary(%s) output differs from golden file.\nGot:\n%s\nWant:\n%s",
lf.path, string(gotJSON), string(wantJSON))
}
})
}
}

// TestAnalyzeLibrary_PomOnline verifies that pom.xml parsing in online mode
// (resolving transitive dependencies from Maven Central) works correctly.
// Skipped with -short since it requires network access.
func TestAnalyzeLibrary_PomOnline(t *testing.T) {
if testing.Short() {
t.Skip("skipping online pom.xml test (requires network access)")
}

fixturesDir := filepath.Join("testdata", "fixtures")
goldenDir := filepath.Join("testdata", "golden")

contents, err := os.ReadFile(filepath.Join(fixturesDir, "pom.xml"))
if err != nil {
t.Fatalf("Failed to read pom.xml: %v", err)
}

got, err := AnalyzeLibrary(context.Background(), "pom.xml", contents, 0644, false)
if err != nil {
t.Fatalf("AnalyzeLibrary(pom.xml, online) unexpected error: %v", err)
}

gotJSON, err := json.MarshalIndent(normalizeResult(got), "", " ")
if err != nil {
t.Fatalf("Failed to marshal result: %v", err)
}

goldenPath := filepath.Join(goldenDir, "pom.xml.online.json")

if *update {
if err := os.MkdirAll(goldenDir, 0755); err != nil {
t.Fatalf("Failed to create golden dir: %v", err)
}
if err := os.WriteFile(goldenPath, gotJSON, 0644); err != nil {
t.Fatalf("Failed to write golden file: %v", err)
}
t.Logf("Updated golden file: %s", goldenPath)
return
}

wantJSON, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("Golden file not found: %s (run with -update to generate)", goldenPath)
}

if string(gotJSON) != string(wantJSON) {
t.Errorf("AnalyzeLibrary(pom.xml, online) output differs from golden file.\nGot:\n%s\nWant:\n%s",
string(gotJSON), string(wantJSON))
}

// Online mode should resolve transitive dependencies, producing more results than offline.
offlineGoldenPath := filepath.Join(goldenDir, "pom.xml.json")
offlineJSON, err := os.ReadFile(offlineGoldenPath)
if err != nil {
t.Logf("Offline golden file not found, skipping comparison: %s", offlineGoldenPath)
return
}

var onlineRes []goldenLibraryScanner
if err := json.Unmarshal(gotJSON, &onlineRes); err != nil {
t.Fatalf("Failed to unmarshal online JSON result: %v", err)
}
var offlineRes []goldenLibraryScanner
if err := json.Unmarshal(offlineJSON, &offlineRes); err != nil {
t.Fatalf("Failed to unmarshal offline golden JSON: %v", err)
}
var onlineLibs, offlineLibs int
for _, s := range onlineRes {
onlineLibs += len(s.Libs)
}
for _, s := range offlineRes {
offlineLibs += len(s.Libs)
}
if onlineLibs <= offlineLibs {
t.Errorf("Online mode should resolve more dependencies than offline mode.\nOnline libs: %d\nOffline libs: %d",
onlineLibs, offlineLibs)
}
}

// normalizeResult produces a stable, comparable representation of the scan result.
// It sorts libraries by name+version to avoid ordering-dependent diffs.
type goldenLibraryScanner struct {
Type string `json:"type"`
LockfilePath string `json:"lockfilePath"`
Libs []goldenLibrary `json:"libs"`
}

type goldenLibrary struct {
Name string `json:"name"`
Version string `json:"version"`
PURL string `json:"purl,omitempty"`
FilePath string `json:"filePath,omitempty"`
Digest string `json:"digest,omitempty"`
Dev bool `json:"dev,omitempty"`
}

func normalizeResult(scanners []models.LibraryScanner) []goldenLibraryScanner {
result := make([]goldenLibraryScanner, 0, len(scanners))
for _, s := range scanners {
gs := goldenLibraryScanner{
Type: string(s.Type),
LockfilePath: s.LockfilePath,
Libs: make([]goldenLibrary, 0, len(s.Libs)),
}
for _, lib := range s.Libs {
gs.Libs = append(gs.Libs, goldenLibrary{
Name: lib.Name,
Version: lib.Version,
PURL: lib.PURL,
FilePath: lib.FilePath,
Digest: lib.Digest,
Dev: lib.Dev,
})
}
slices.SortFunc(gs.Libs, func(a, b goldenLibrary) int {
return cmp.Or(
cmp.Compare(a.Name, b.Name),
cmp.Compare(a.Version, b.Version),
cmp.Compare(a.PURL, b.PURL),
cmp.Compare(a.FilePath, b.FilePath),
cmp.Compare(a.Digest, b.Digest),
func() int {
switch {
case !a.Dev && b.Dev:
return -1
case a.Dev && !b.Dev:
return +1
default:
return 0
}
}(),
)
})
result = append(result, gs)
}
slices.SortFunc(result, func(a, b goldenLibraryScanner) int {
return cmp.Or(
cmp.Compare(a.Type, b.Type),
cmp.Compare(a.LockfilePath, b.LockfilePath),
)
})
return result
}
Loading
Loading