diff --git a/internal/catalog/cataloger.go b/internal/catalog/cataloger.go index ab49024..ca4d6be 100644 --- a/internal/catalog/cataloger.go +++ b/internal/catalog/cataloger.go @@ -55,8 +55,16 @@ func catalogByGlob(resolver file.Resolver, root, glob, label string, parse fileP i, loc := i, loc g.Go(func() error { pkgs, err := parse(filepath.Join(root, loc.RealPath), loc) - if err != nil || len(pkgs) == 0 { - return nil // per-file errors are non-fatal, as before + if err != nil { + // Non-fatal, but surface it: a swallowed uv/npm failure (e.g. + // the host Python is too old to resolve the pins) otherwise + // looks identical to "nothing found". Warn to stderr — the SBOM + // goes to stdout, so this never corrupts --local output. + fmt.Fprintf(os.Stderr, "ossprey: %s cataloger: %v\n", label, err) + return nil + } + if len(pkgs) == 0 { + return nil } mu.Lock() results = append(results, result{idx: i, pkgs: pkgs}) diff --git a/internal/catalog/requirements_cataloger.go b/internal/catalog/requirements_cataloger.go new file mode 100644 index 0000000..245d77a --- /dev/null +++ b/internal/catalog/requirements_cataloger.go @@ -0,0 +1,47 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" +) + +// RequirementsCataloger resolves transitive Python deps from a requirements.txt +// by invoking `uv pip compile`. Syft's built-in cataloger reads the file +// literally (direct deps only); this fills in the transitive closure, pinning +// every dependency to a concrete version resolved against PyPI. +type RequirementsCataloger struct { + root string +} + +func NewRequirementsCataloger(root string) *RequirementsCataloger { + return &RequirementsCataloger{root: root} +} + +func (c *RequirementsCataloger) Name() string { return "ossprey-requirements-cataloger" } + +func (c *RequirementsCataloger) Catalog(ctx context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) { + uv, err := exec.LookPath("uv") + if err != nil { + return nil, nil, nil // no uv on PATH — silently skip + } + cache, err := os.MkdirTemp("", "ossprey-uv-cache-") + if err != nil { + return nil, nil, fmt.Errorf("uv cache: %w", err) + } + defer os.RemoveAll(cache) + + parse := func(absPath string, loc file.Location) ([]pkg.Package, error) { + dir := filepath.Dir(absPath) + args := []string{"pip", "compile", "--universal", "--no-progress", absPath} + return runUV(ctx, uv, cache, dir, args, loc) + } + out, err := catalogByGlob(resolver, c.root, "**/requirements.txt", "requirements", parse) + return out, nil, err +} diff --git a/internal/catalog/syft.go b/internal/catalog/syft.go index 160800d..dbacd3d 100644 --- a/internal/catalog/syft.go +++ b/internal/catalog/syft.go @@ -61,6 +61,9 @@ func Catalog(ctx context.Context, path string) ([]Package, error) { // Custom: resolve transitives from setup.py when pyproject is absent // or lacks a [project] table (legacy setuptools projects). NewSetupPyCataloger(absRoot), + // Custom: resolve transitives from requirements.txt via uv (syft's + // built-in reads the file literally — direct deps only). + NewRequirementsCataloger(absRoot), // Custom: direct-deps fallback for pyproject.toml when uv is missing. NewPyProjectCataloger(absRoot), // Custom: resolve npm ranges to concrete versions via `npm install @@ -188,6 +191,7 @@ func isOspreyCataloger(name string) bool { switch name { case "ossprey-uv-cataloger", "ossprey-setuppy-cataloger", + "ossprey-requirements-cataloger", "ossprey-pyproject-cataloger", "ossprey-npm-cataloger", "ossprey-packagejson-cataloger":