diff --git a/README.md b/README.md index 4248547..e493b8b 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ any are flagged, the install is blocked (exit `1`) and the real package manager is never invoked; otherwise the command is forwarded unchanged. ```sh -ossprey npm install foo@1.2.3 +ossprey npm install foo@1.2.3 bar@2.0.0 # checks each named package ossprey yarn add foo@1.2.3 ossprey pip install foo==1.2.3 ossprey poetry add foo @@ -152,12 +152,25 @@ Supported managers: `npm`, `yarn`, `pip`, `poetry`, `uv`. Non-install subcommands (`npm run`, `pip list`, …) are forwarded straight through with no check. -**Scope:** only the packages named on the command line are checked — transitive -dependencies are **not** resolved here. Run `ossprey scan` after install for -full-tree coverage. Tokens with no parseable package name (`pip install -r -requirements.txt`, VCS/URL installs) are forwarded unchecked with a warning. If -the registry can't be reached to resolve an unpinned version, that package is -skipped (fail-open) so a registry outage never blocks development. +**Two modes, picked automatically:** + +- **Named packages** (`ossprey npm install foo bar`, `ossprey pip install + foo==1 bar`): every package named on the command line is checked. Multiple + packages, flags, flag-values, local paths, archives and VCS/URL targets are + all handled — only the real registry packages are checked, the rest are noted + and forwarded. Transitive dependencies are **not** resolved here; run `ossprey + scan` after install for full-tree coverage. +- **Manifest install** (bare `ossprey npm install`, `npm ci`, `yarn install`, + `poetry install`, `uv sync`, or `pip install -r requirements.txt`): no + packages are named, so the manager installs from the project's + manifest/lockfile. The forwarder scans the current directory and checks every + declared dependency before forwarding — it does **not** fall through + unchecked. + +If the registry can't be reached to resolve an unpinned named version, that +package is skipped (fail-open) so a registry outage never blocks development. +An install whose only targets are local paths or URLs (nothing checkable and no +manifest to scan) is forwarded with a warning. Configuration comes from the environment (flag parsing is disabled so every argument reaches the real manager): diff --git a/internal/forward/forward.go b/internal/forward/forward.go index 158d738..9732d40 100644 --- a/internal/forward/forward.go +++ b/internal/forward/forward.go @@ -18,15 +18,18 @@ import ( "strings" "github.com/ossprey/ossprey-cli/internal/check" + "github.com/ossprey/ossprey-cli/internal/ossbom" "github.com/ossprey/ossprey-cli/internal/registry" "github.com/ossprey/ossprey-cli/internal/scan" + "github.com/ossprey/ossprey-cli/internal/submit" ) // Test seams: overridable in tests so Run's decision logic can be exercised // without a real package manager on PATH or a live API. var ( - execFn = Exec - checkFn = check.Run + execFn = Exec + checkFn = check.Run + scanProjectFn = scanProject ) // ErrBlocked is returned by Run when malware is found and the install was @@ -42,13 +45,17 @@ type Manager struct { installAt func(args []string) (specStart int, ok bool) } -// managers is the registry of supported forwarders. +// managers is the registry of supported forwarders. Install verbs include both +// the package-adding forms (`npm install `, `yarn add `) and the +// manifest-installing forms with no named packages (`npm install`, `npm ci`, +// `yarn install`, `poetry install`, `uv sync`); the latter trigger a project +// manifest scan instead of falling through unchecked (OSS-1284). var managers = map[string]*Manager{ "npm": {Bin: "npm", Ecosystem: "npm", installAt: verbAt(0, "install", "i", "add", "ci")}, - "yarn": {Bin: "yarn", Ecosystem: "npm", installAt: verbAt(0, "add")}, + "yarn": {Bin: "yarn", Ecosystem: "npm", installAt: verbAt(0, "add", "install")}, "pip": {Bin: "pip", Ecosystem: "pypi", installAt: verbAt(0, "install")}, - "poetry": {Bin: "poetry", Ecosystem: "pypi", installAt: verbAt(0, "add")}, - // uv: both `uv add ` and `uv pip install `. + "poetry": {Bin: "poetry", Ecosystem: "pypi", installAt: verbAt(0, "add", "install")}, + // uv: `uv add `, `uv sync`, and `uv pip install `. "uv": {Bin: "uv", Ecosystem: "pypi", installAt: uvInstallAt}, } @@ -81,9 +88,9 @@ func verbAt(idx int, verbs ...string) func([]string) (int, bool) { } } -// uvInstallAt matches `uv add ...` and `uv pip install ...`. +// uvInstallAt matches `uv add ...`, `uv sync`, and `uv pip install ...`. func uvInstallAt(args []string) (int, bool) { - if len(args) >= 1 && args[0] == "add" { + if len(args) >= 1 && (args[0] == "add" || args[0] == "sync") { return 1, true } if len(args) >= 2 && args[0] == "pip" && args[1] == "install" { @@ -105,9 +112,13 @@ type Options struct { // Run executes the forwarder flow: // 1. If the command is not an install, exec the real manager unchanged. -// 2. Parse named package specs, resolving latest versions where unpinned. -// 3. Check them against the API; block (ErrBlocked) on malware. -// 4. Otherwise exec the real manager with the original args. +// 2. If the install names packages, check exactly those (resolving unpinned +// versions); block (ErrBlocked) on malware. +// 3. If the install names no packages (bare `npm install`, `npm ci`, `pip +// install -r req.txt`, `yarn install`, `poetry install`, `uv sync`), it +// installs from the project manifest/lockfile — so scan that project and +// check every dependency it declares (OSS-1284). Blocks on malware. +// 4. Otherwise (only un-checkable local/URL targets) exec the real manager. // // The returned error is ErrBlocked on malware, an *exec.ExitError when the real // manager exits non-zero, or any setup/API error. @@ -128,15 +139,50 @@ func Run(ctx context.Context, opts Options) error { return execFn(ctx, m.Bin, opts.Args) } - specs := ParseSpecs(m, opts.Args[start:]) - if len(specs) == 0 { - fmt.Fprintf(os.Stderr, "ossprey: no checkable packages found in `%s %s`; forwarding without a scan\n", + parsed := ParseSpecs(m, opts.Args[start:]) + + switch { + case len(parsed.Specs) > 0: + // Explicit packages named — check exactly those. + if other := slices.Concat(parsed.NonPackages, parsed.ReqFiles); len(other) > 0 { + fmt.Fprintf(os.Stderr, "ossprey: not checking non-registry install targets: %s (run `ossprey scan` for full coverage)\n", + strings.Join(other, ", ")) + } + resolved := resolveSpecs(ctx, resolve, parsed.Specs) + if len(resolved) == 0 { + fmt.Fprintln(os.Stderr, "ossprey: nothing left to check after version resolution; forwarding") + return execFn(ctx, m.Bin, opts.Args) + } + sbom, err := checkFn(ctx, check.Options{Specs: resolved, APIURL: opts.APIURL, APIKey: opts.APIKey}) + if err != nil { + return err + } + return reportAndForward(ctx, m, opts, sbom) + + case manifestInstall(parsed): + // No packages named — the manager installs from the project manifest / + // lockfile. Scan the project and check every declared dependency rather + // than falling through unchecked. + fmt.Fprintf(os.Stderr, "ossprey: no packages named; scanning project manifest before `%s %s`\n", m.Bin, strings.Join(opts.Args, " ")) + sbom, err := scanProjectFn(ctx, ".", opts.APIURL, opts.APIKey) + if err != nil { + return err + } + return reportAndForward(ctx, m, opts, sbom) + + default: + // Only un-checkable explicit targets (local paths, archives, URLs, VCS + // refs). Can't verify them against a registry — forward with a warning. + fmt.Fprintf(os.Stderr, "ossprey: not checking non-registry install targets: %s; forwarding (run `ossprey scan` after install)\n", + strings.Join(parsed.NonPackages, ", ")) return execFn(ctx, m.Bin, opts.Args) } +} - // Resolve latest versions for unpinned packages. Fail open: a registry - // outage must not block the developer — warn and skip checking that one. +// resolveSpecs fills concrete versions for unpinned specs. Fail open: a registry +// outage must not block the developer — warn and drop that one from the check. +func resolveSpecs(ctx context.Context, resolve func(context.Context, string, string) (string, error), specs []check.Spec) []check.Spec { resolved := make([]check.Spec, 0, len(specs)) for _, s := range specs { if s.Version == "" { @@ -150,21 +196,12 @@ func Run(ctx context.Context, opts Options) error { } resolved = append(resolved, s) } + return resolved +} - if len(resolved) == 0 { - fmt.Fprintln(os.Stderr, "ossprey: nothing left to check after version resolution; forwarding") - return execFn(ctx, m.Bin, opts.Args) - } - - sbom, err := checkFn(ctx, check.Options{ - Specs: resolved, - APIURL: opts.APIURL, - APIKey: opts.APIKey, - }) - if err != nil { - return err - } - +// reportAndForward blocks (ErrBlocked) if sbom carries malware, else execs the +// real manager with the original args. +func reportAndForward(ctx context.Context, m *Manager, opts Options, sbom *ossbom.SBOM) error { if reports, hasMalware := scan.MalwareReports(sbom); hasMalware { for _, msg := range reports { fmt.Fprintln(os.Stderr, "Error: "+msg) @@ -172,26 +209,187 @@ func Run(ctx context.Context, opts Options) error { fmt.Fprintf(os.Stderr, "ossprey: blocked `%s %s`\n", m.Bin, strings.Join(opts.Args, " ")) return ErrBlocked } - fmt.Fprintln(os.Stderr, "ossprey: no malware found, forwarding to "+m.Bin) return execFn(ctx, m.Bin, opts.Args) } -// ParseSpecs extracts package specs from the install arguments (everything -// after the install verb). Flags (tokens starting with '-') are skipped. -func ParseSpecs(m *Manager, args []string) []check.Spec { - var specs []check.Spec - for _, a := range args { - if a == "" || strings.HasPrefix(a, "-") { +// manifestInstall reports whether an install with no explicitly named packages +// pulls its packages from the project manifest/lockfile — i.e. a bare install +// (`npm install`, `npm ci`, `yarn install`, `poetry install`, `uv sync`) or an +// install driven by a requirements file (`pip install -r req.txt`). In both +// cases the project should be scanned. An install whose only targets are local +// paths / URLs is NOT a manifest install. +func manifestInstall(p installArgs) bool { + if len(p.Specs) > 0 { + return false + } + return len(p.ReqFiles) > 0 || len(p.NonPackages) == 0 +} + +// scanProject catalogs dir, submits the resulting SBOM to the Ossprey API, and +// returns it with any vulnerabilities applied. It is the default scanProjectFn +// seam. When the directory has no catalogable dependencies it returns the empty +// SBOM without an API call so a bare install in a non-project dir forwards. +func scanProject(ctx context.Context, dir, apiURL, apiKey string) (*ossbom.SBOM, error) { + sbom, err := scan.Run(ctx, scan.Options{Path: dir}) + if err != nil { + return nil, err + } + if len(sbom.Components) == 0 { + return sbom, nil // nothing declared to check + } + if err := submit.Validate(ctx, sbom, apiURL, apiKey); err != nil { + return nil, err + } + return sbom, nil +} + +// installArgs is the classification of an install command's arguments +// (everything after the install verb). +type installArgs struct { + // Specs are registry packages named on the command line, to check individually. + Specs []check.Spec + // NonPackages are explicit targets that can't be checked against a registry: + // local paths, archive files, URLs, VCS refs. + NonPackages []string + // ReqFiles are requirements files referenced via -r/--requirement. Their + // packages live in the file, not on the command line. + ReqFiles []string +} + +// ParseSpecs classifies install arguments. A real-world multi-package install +// interleaves package names with flags, flag-values, paths and URLs — e.g. +// +// pip install requests -r extra.txt -t ./vendor flask ./local.whl +// +// so naively treating every non-flag token as a package produces bogus specs. +// ParseSpecs therefore (a) consumes the values of value-taking flags, (b) tracks +// requirements-file values separately, and (c) structurally separates tokens +// that can't be a registry package from the real package specs. +func ParseSpecs(m *Manager, args []string) installArgs { + valFlags := valueFlags[m.Bin] + reqFlags := requirementFileFlags[m.Bin] + var out installArgs + + for i := 0; i < len(args); i++ { + a := args[i] + if a == "" { + continue + } + + if strings.HasPrefix(a, "-") { + flag, inlineVal, hasInline := splitFlagValue(a) + switch { + case reqFlags[flag]: + // Requirements file: track it; its packages are scanned, not parsed here. + if hasInline { + out.ReqFiles = append(out.ReqFiles, inlineVal) + } else if i+1 < len(args) { + out.ReqFiles = append(out.ReqFiles, args[i+1]) + i++ + } + case valFlags[flag] && !hasInline && i+1 < len(args): + i++ // consume the flag's value so it isn't read as a package + } continue } + + // Local paths, archives, URLs and VCS refs aren't registry packages. + if isNonPackageToken(a) { + out.NonPackages = append(out.NonPackages, a) + continue + } + s, err := check.ParseSpec(m.Ecosystem, a) if err != nil { + out.NonPackages = append(out.NonPackages, a) continue } - specs = append(specs, s) + out.Specs = append(out.Specs, s) } - return specs + return out +} + +// splitFlagValue splits "--flag=value" into ("--flag", "value", true). A flag +// with no inline value returns (flag, "", false). +func splitFlagValue(arg string) (flag, value string, hasInline bool) { + if eq := strings.IndexByte(arg, '='); eq >= 0 { + return arg[:eq], arg[eq+1:], true + } + return arg, "", false +} + +// isNonPackageToken reports whether token is an install target that can't be +// resolved against a package registry: a local path, a local archive file, a +// URL, or a VCS ref. +func isNonPackageToken(token string) bool { + // URLs and VCS refs. + if strings.Contains(token, "://") { + return true + } + for _, p := range []string{"git+", "git:", "http:", "https:", "file:", "ssh:"} { + if strings.HasPrefix(token, p) { + return true + } + } + // Local paths (POSIX and Windows). An npm scoped name like "@scope/pkg" + // also contains '/', so match path *prefixes* rather than any '/'. + switch { + case token == "." || token == "..": + return true + case strings.HasPrefix(token, "./") || strings.HasPrefix(token, "../"): + return true + case strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`): + return true + case strings.HasPrefix(token, "/") || strings.HasPrefix(token, "~"): + return true + } + // Local archive files. + for _, ext := range []string{".tgz", ".tar.gz", ".tar.bz2", ".tar.xz", ".tar", ".tbz2", ".whl", ".zip"} { + if strings.HasSuffix(token, ext) { + return true + } + } + return false +} + +// flagSet builds a lookup set from flag names. +func flagSet(flags ...string) map[string]bool { + m := make(map[string]bool, len(flags)) + for _, f := range flags { + m[f] = true + } + return m +} + +// valueFlags lists, per manager binary, the flags whose following argument is a +// value (a path, URL, name, etc.) rather than a package to check. Both short +// and long forms are listed. Boolean flags (e.g. npm --save-dev) are absent so +// the package after them is still read. The structural isNonPackageToken check +// is the backstop for value flags not listed here whose value is a URL or path. +var valueFlags = map[string]map[string]bool{ + "npm": flagSet("--registry", "--prefix", "-C", "--cache", "--userconfig", + "--globalconfig", "--tag", "--otp", "-w", "--workspace", "--omit", "--include"), + "yarn": flagSet("--registry", "--cache-folder", "--modules-folder", "--cwd"), + "pip": flagSet("-t", "--target", "-e", "--editable", "-i", "--index-url", + "--extra-index-url", "-f", "--find-links", "-c", "--constraint", "--prefix", + "--root", "--src", "--python", "--cache-dir", "--log", "--no-binary", + "--only-binary", "--platform", "--python-version", "--implementation", + "--abi", "--progress-bar", "--report"), + "poetry": flagSet("--source", "-G", "--group", "--python", "-P", "--project", "-C"), + // uv covers both `uv add` (uv-native flags) and `uv pip install` (pip-style flags). + "uv": flagSet("-i", "--index-url", "--extra-index-url", "--index", "--default-index", + "-f", "--find-links", "--cache-dir", "-p", "--python", "--project", "-c", + "--constraint", "-o", "--override", "--group", "--index-strategy", + "-t", "--target", "--prefix", "-e", "--editable", "--optional", "--extra"), +} + +// requirementFileFlags name the flags whose value is a requirements/constraints +// file. The packages it lists are NOT checked by the forwarder (use `ossprey +// scan` for full coverage), so the value is reported as skipped to warn the user. +var requirementFileFlags = map[string]map[string]bool{ + "pip": flagSet("-r", "--requirement"), + "uv": flagSet("-r", "--requirement"), } // Exec runs the real package manager, inheriting stdio. The child's exit code diff --git a/internal/forward/forward_test.go b/internal/forward/forward_test.go index 992ccd2..86315c7 100644 --- a/internal/forward/forward_test.go +++ b/internal/forward/forward_test.go @@ -34,14 +34,15 @@ func TestInstallDetection(t *testing.T) { {"npm", []string{"ci"}, 1, true}, {"npm", []string{"run", "build"}, 0, false}, {"yarn", []string{"add", "react"}, 1, true}, - {"yarn", []string{"install"}, 0, false}, + {"yarn", []string{"install"}, 1, true}, // bare manifest install {"pip", []string{"install", "requests"}, 1, true}, {"pip", []string{"list"}, 0, false}, {"poetry", []string{"add", "flask"}, 1, true}, + {"poetry", []string{"install"}, 1, true}, // bare manifest install {"uv", []string{"add", "httpx"}, 1, true}, {"uv", []string{"pip", "install", "httpx"}, 2, true}, {"uv", []string{"pip", "list"}, 0, false}, - {"uv", []string{"sync"}, 0, false}, + {"uv", []string{"sync"}, 1, true}, // lockfile-based manifest install } for _, tt := range tests { m, _ := Lookup(tt.bin) @@ -56,18 +57,21 @@ func TestInstallDetection(t *testing.T) { func TestParseSpecs(t *testing.T) { npm, _ := Lookup("npm") pip, _ := Lookup("pip") + uvm, _ := Lookup("uv") tests := []struct { - name string - m *Manager - args []string - want []check.Spec + name string + m *Manager + args []string + wantSpecs []check.Spec + wantNonPkgs []string + wantReqFiles []string }{ { name: "npm mixed with flags", m: npm, args: []string{"lodash@4.17.21", "--save-dev", "@babel/core@7.0.0", "react"}, - want: []check.Spec{ + wantSpecs: []check.Spec{ {Ecosystem: "npm", Name: "lodash", Version: "4.17.21"}, {Ecosystem: "npm", Name: "@babel/core", Version: "7.0.0"}, {Ecosystem: "npm", Name: "react", Version: ""}, @@ -77,23 +81,92 @@ func TestParseSpecs(t *testing.T) { name: "pip with == and bare", m: pip, args: []string{"requests==2.31.0", "flask"}, - want: []check.Spec{ + wantSpecs: []check.Spec{ {Ecosystem: "pypi", Name: "requests", Version: "2.31.0"}, {Ecosystem: "pypi", Name: "flask", Version: ""}, }, }, { - name: "all flags -> none", + name: "all flags -> none", + m: pip, + args: []string{"-U", "--quiet"}, + wantSpecs: nil, + }, + { + name: "npm value-flag does not swallow its value as a package", + m: npm, + args: []string{"react", "--registry", "https://r.example.com", "lodash"}, + wantSpecs: []check.Spec{ + {Ecosystem: "npm", Name: "react", Version: ""}, + {Ecosystem: "npm", Name: "lodash", Version: ""}, + }, + }, + { + name: "pip target flag value is not a package", m: pip, - args: []string{"-U", "--quiet"}, - want: nil, + args: []string{"requests", "-t", "/opt/libs", "flask"}, + wantSpecs: []check.Spec{ + {Ecosystem: "pypi", Name: "requests", Version: ""}, + {Ecosystem: "pypi", Name: "flask", Version: ""}, + }, + }, + { + name: "pip requirements file is tracked separately", + m: pip, + args: []string{"-r", "requirements.txt"}, + wantSpecs: nil, + wantReqFiles: []string{"requirements.txt"}, + }, + { + name: "npm local tarball and vcs ref are non-packages, real package kept", + m: npm, + args: []string{"./local-tarball.tgz", "git+https://github.com/x/y.git", "lodash"}, + wantSpecs: []check.Spec{ + {Ecosystem: "npm", Name: "lodash", Version: ""}, + }, + wantNonPkgs: []string{"./local-tarball.tgz", "git+https://github.com/x/y.git"}, + }, + { + name: "pip editable local path is consumed by -e", + m: pip, + args: []string{"-e", ".", "requests"}, + wantSpecs: []check.Spec{{Ecosystem: "pypi", Name: "requests", Version: ""}}, + }, + { + name: "pip index-url value is not a package", + m: pip, + args: []string{"requests", "--index-url", "https://pypi.org/simple"}, + wantSpecs: []check.Spec{{Ecosystem: "pypi", Name: "requests", Version: ""}}, + }, + { + name: "pip inline --requirement= tracks the file", + m: pip, + args: []string{"--requirement=dev-requirements.txt", "requests"}, + wantSpecs: []check.Spec{{Ecosystem: "pypi", Name: "requests", Version: ""}}, + wantReqFiles: []string{"dev-requirements.txt"}, + }, + { + name: "uv pip install: target value and requirements file handled", + m: uvm, + args: []string{"httpx==0.27.0", "-t", "vendor", "-r", "reqs.txt", "rich"}, + wantSpecs: []check.Spec{ + {Ecosystem: "pypi", Name: "httpx", Version: "0.27.0"}, + {Ecosystem: "pypi", Name: "rich", Version: ""}, + }, + wantReqFiles: []string{"reqs.txt"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ParseSpecs(tt.m, tt.args) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParseSpecs = %+v, want %+v", got, tt.want) + if !reflect.DeepEqual(got.Specs, tt.wantSpecs) { + t.Errorf("Specs = %+v, want %+v", got.Specs, tt.wantSpecs) + } + if !reflect.DeepEqual(got.NonPackages, tt.wantNonPkgs) { + t.Errorf("NonPackages = %+v, want %+v", got.NonPackages, tt.wantNonPkgs) + } + if !reflect.DeepEqual(got.ReqFiles, tt.wantReqFiles) { + t.Errorf("ReqFiles = %+v, want %+v", got.ReqFiles, tt.wantReqFiles) } }) } @@ -111,12 +184,27 @@ func (s *stubExec) fn(_ context.Context, bin string, args []string) error { return nil } -// swap replaces the package test seams and returns a restore func. +// swap replaces the exec + check seams and restores them after the test. The +// scan-project seam is replaced with a stub that fails the test if Run reaches +// the project-scan path unexpectedly; tests that exercise it call swapScan. func swap(t *testing.T, exec func(context.Context, string, []string) error, chk func(context.Context, check.Options) (*ossbom.SBOM, error)) { t.Helper() - oe, oc := execFn, checkFn + oe, oc, os := execFn, checkFn, scanProjectFn execFn, checkFn = exec, chk - t.Cleanup(func() { execFn, checkFn = oe, oc }) + scanProjectFn = func(context.Context, string, string, string) (*ossbom.SBOM, error) { + t.Error("scanProjectFn called unexpectedly") + return ossbom.New(ossbom.Environment{}), nil + } + t.Cleanup(func() { execFn, checkFn, scanProjectFn = oe, oc, os }) +} + +// swapScan replaces the project-scan seam for tests that exercise the bare / +// manifest-install path. +func swapScan(t *testing.T, fn func(context.Context, string, string, string) (*ossbom.SBOM, error)) { + t.Helper() + old := scanProjectFn + scanProjectFn = fn + t.Cleanup(func() { scanProjectFn = old }) } func cleanSBOM(_ context.Context, _ check.Options) (*ossbom.SBOM, error) { @@ -149,6 +237,129 @@ func TestRun_NonInstall_ForwardsWithoutCheck(t *testing.T) { } } +// OSS-1284: a bare `ossprey npm install` (no named packages) must NOT fall +// through unchecked — it should scan the project's manifest/lockfile. +func TestRun_BareInstall_ScansProjectManifest(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) + var scanCalled bool + var scanDir string + swapScan(t, func(_ context.Context, dir, _, _ string) (*ossbom.SBOM, error) { + scanCalled, scanDir = true, dir + return ossbom.New(ossbom.Environment{}), nil + }) + + err := Run(context.Background(), Options{Bin: "npm", Args: []string{"install"}}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !scanCalled { + t.Error("bare `npm install` must scan the project manifest, not fall through unchecked") + } + if scanDir != "." { + t.Errorf("scan dir = %q, want \".\"", scanDir) + } + if !ex.called || !reflect.DeepEqual(ex.args, []string{"install"}) { + t.Errorf("clean scan must forward original args; got called=%v args=%v", ex.called, ex.args) + } +} + +func TestRun_BareInstall_MalwareInManifestBlocks(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) + swapScan(t, func(_ context.Context, _, _, _ string) (*ossbom.SBOM, error) { + s := ossbom.New(ossbom.Environment{}) + s.AddVulnerability(ossbom.NewMalwareVulnerability("V1", "pkg:npm/evil@1.0.0", "bad")) + return s, nil + }) + + err := Run(context.Background(), Options{Bin: "npm", Args: []string{"install"}}) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("err: got %v, want ErrBlocked", err) + } + if ex.called { + t.Error("exec must NOT be called when the manifest scan finds malware") + } +} + +func TestRun_RequirementsFile_ScansProject(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) + var scanCalled bool + swapScan(t, func(_ context.Context, _, _, _ string) (*ossbom.SBOM, error) { + scanCalled = true + return ossbom.New(ossbom.Environment{}), nil + }) + + err := Run(context.Background(), Options{Bin: "pip", Args: []string{"install", "-r", "requirements.txt"}}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !scanCalled { + t.Error("`pip install -r requirements.txt` must scan the project, not fall through") + } + if !ex.called { + t.Error("clean scan must forward to pip") + } +} + +func TestRun_NamedPackages_DoNotScanProject(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) // swap's scanProjectFn fails the test if called + err := Run(context.Background(), Options{Bin: "npm", Args: []string{"install", "lodash@4.17.21"}}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !ex.called { + t.Error("expected forward after checking named package") + } +} + +func TestRun_OnlyLocalTarget_ForwardsWithoutScan(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) // scanProjectFn fails the test if called + err := Run(context.Background(), Options{Bin: "npm", Args: []string{"install", "./local.tgz"}}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !ex.called || !reflect.DeepEqual(ex.args, []string{"install", "./local.tgz"}) { + t.Errorf("local-only install should forward unchanged; got %v", ex.args) + } +} + +func TestRun_ManifestInstallVerbs_ScanProject(t *testing.T) { + cases := []struct { + bin string + args []string + }{ + {"npm", []string{"ci"}}, + {"yarn", []string{"install"}}, + {"poetry", []string{"install"}}, + {"uv", []string{"sync"}}, + {"uv", []string{"pip", "install", "-r", "requirements.txt"}}, + } + for _, tc := range cases { + t.Run(tc.bin+" "+tc.args[0], func(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, cleanSBOM) + var scanCalled bool + swapScan(t, func(_ context.Context, _, _, _ string) (*ossbom.SBOM, error) { + scanCalled = true + return ossbom.New(ossbom.Environment{}), nil + }) + if err := Run(context.Background(), Options{Bin: tc.bin, Args: tc.args}); err != nil { + t.Fatalf("Run: %v", err) + } + if !scanCalled { + t.Errorf("`%s %v` must scan the project manifest", tc.bin, tc.args) + } + if !ex.called { + t.Error("clean scan must forward") + } + }) + } +} + func TestRun_Clean_Forwards(t *testing.T) { ex := &stubExec{} swap(t, ex.fn, cleanSBOM) @@ -162,6 +373,73 @@ func TestRun_Clean_Forwards(t *testing.T) { } } +func TestRun_MultiPackage_ChecksAllAndForwards(t *testing.T) { + ex := &stubExec{} + var gotSpecs []check.Spec + swap(t, ex.fn, func(_ context.Context, o check.Options) (*ossbom.SBOM, error) { + gotSpecs = o.Specs + return ossbom.New(ossbom.Environment{}), nil + }) + + args := []string{"install", "lodash@4.17.21", "react@18.2.0", "@babel/core@7.0.0"} + err := Run(context.Background(), Options{Bin: "npm", Args: args}) + if err != nil { + t.Fatalf("Run: %v", err) + } + want := []check.Spec{ + {Ecosystem: "npm", Name: "lodash", Version: "4.17.21"}, + {Ecosystem: "npm", Name: "react", Version: "18.2.0"}, + {Ecosystem: "npm", Name: "@babel/core", Version: "7.0.0"}, + } + if !reflect.DeepEqual(gotSpecs, want) { + t.Errorf("checked specs = %+v, want %+v", gotSpecs, want) + } + if !ex.called || !reflect.DeepEqual(ex.args, args) { + t.Errorf("exec args = %v, want %v (called=%v)", ex.args, args, ex.called) + } +} + +func TestRun_MultiPackage_MalwareInAnyBlocks(t *testing.T) { + ex := &stubExec{} + swap(t, ex.fn, malwareSBOM) + + err := Run(context.Background(), Options{ + Bin: "npm", + Args: []string{"install", "lodash@4.17.21", "evil@1.0.0", "react@18.2.0"}, + }) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("err: got %v, want ErrBlocked", err) + } + if ex.called { + t.Error("exec must NOT be called when any package is malware") + } +} + +func TestRun_MultiPackage_SkipsNonPackagesChecksRest(t *testing.T) { + ex := &stubExec{} + var gotSpecs []check.Spec + swap(t, ex.fn, func(_ context.Context, o check.Options) (*ossbom.SBOM, error) { + gotSpecs = o.Specs + return ossbom.New(ossbom.Environment{}), nil + }) + + args := []string{"install", "requests==2.31.0", "-t", "./vendor", "./local.whl", "flask==3.0.0"} + err := Run(context.Background(), Options{Bin: "pip", Args: args}) + if err != nil { + t.Fatalf("Run: %v", err) + } + want := []check.Spec{ + {Ecosystem: "pypi", Name: "requests", Version: "2.31.0"}, + {Ecosystem: "pypi", Name: "flask", Version: "3.0.0"}, + } + if !reflect.DeepEqual(gotSpecs, want) { + t.Errorf("checked specs = %+v, want %+v", gotSpecs, want) + } + if !ex.called || !reflect.DeepEqual(ex.args, args) { + t.Errorf("exec must forward original args unchanged; got %v", ex.args) + } +} + func TestRun_Malware_Blocks(t *testing.T) { ex := &stubExec{} swap(t, ex.fn, malwareSBOM)