diff --git a/cmd/osv-scanner/__snapshots__/main_test.snap b/cmd/osv-scanner/__snapshots__/main_test.snap index b27e4c8ab42..b9fdcc49258 100755 --- a/cmd/osv-scanner/__snapshots__/main_test.snap +++ b/cmd/osv-scanner/__snapshots__/main_test.snap @@ -1,10 +1,25 @@ [TestRun/#00 - 1] +NAME: + osv-scanner scan - scans projects and container images for dependencies, and checks them against the OSV database. + +USAGE: + osv-scanner scan command [command options] + +DESCRIPTION: + scans projects and container images for dependencies, and checks them against the OSV database. + +COMMANDS: + source scans a source project's dependencies for known vulnerabilities using the OSV database. + image detects vulnerabilities in a container image's dependencies, pulling the image if it's not found locally + help, h Shows a list of commands or help for one command + +OPTIONS: + --help, -h show help --- [TestRun/#00 - 2] -No package sources found, --help for usage information. --- @@ -971,7 +986,7 @@ Scanned /fixtures/locks-many/package-lock.json file and found 1 package --- [TestRun/output_format:_unsupported - 2] -unsupported output format "unknown" - must be one of: table, vertical, json, markdown, sarif, gh-annotations, cyclonedx-1-4, cyclonedx-1-5 +unsupported output format "unknown" - must be one of: table, html, vertical, json, markdown, sarif, gh-annotations, cyclonedx-1-4, cyclonedx-1-5 --- @@ -1109,6 +1124,7 @@ Checking if docker image ("alpine:non-existent-tag") exists locally... --- [TestRun_Docker/Fake_alpine_image - 2] +Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `scan` is assumed to be a subcommand here. If you intended for `scan` to be an argument to `scan`, you must specify `scan scan` in your command line. Docker command exited with code ("/usr/bin/docker pull -q alpine:non-existent-tag"): 1 STDERR: > Error response from daemon: manifest for alpine:non-existent-tag not found: manifest unknown: manifest unknown @@ -1122,6 +1138,7 @@ Checking if docker image ("this-image-definitely-does-not-exist-abcde") exists l --- [TestRun_Docker/Fake_image_entirely - 2] +Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `scan` is assumed to be a subcommand here. If you intended for `scan` to be an argument to `scan`, you must specify `scan scan` in your command line. Docker command exited with code ("/usr/bin/docker pull -q this-image-definitely-does-not-exist-abcde"): 1 STDERR: > Error response from daemon: pull access denied for this-image-definitely-does-not-exist-abcde, repository does not exist or may require 'docker login': denied: requested access to the resource is denied @@ -1133,6 +1150,7 @@ failed to pull container image: failed to run docker command Checking if docker image ("alpine:3.18.9") exists locally... Saving docker image ("alpine:3.18.9") to temporary file... Scanning image "alpine:3.18.9" + Container Scanning Result (Alpine Linux v3.18): Total 1 packages affected by 1 vulnerabilities (0 Critical, 0 High, 0 Medium, 0 Low, 1 Unknown) from 1 ecosystems. 1 vulnerabilities have fixes available. @@ -1146,12 +1164,13 @@ Alpine:v3.18 | openssl | 3.1.7-r0 | Fix Available | 1 | # 0 Layer | alpine | +---------+-------------------+---------------+------------+------------------+---------------+ -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. +For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner scan image --serve `. +You can also view the full vulnerability list in your terminal with: `osv-scanner scan image --format vertical `. --- [TestRun_Docker/Real_Alpine_image - 2] +Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `scan` is assumed to be a subcommand here. If you intended for `scan` to be an argument to `scan`, you must specify `scan scan` in your command line. --- @@ -1163,6 +1182,7 @@ Scanning image "hello-world" --- [TestRun_Docker/Real_empty_image - 2] +Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `scan` is assumed to be a subcommand here. If you intended for `scan` to be an argument to `scan`, you must specify `scan scan` in your command line. No package sources found, --help for usage information. --- @@ -1175,6 +1195,7 @@ Scanning image "hello-world:linux" --- [TestRun_Docker/Real_empty_image_with_tag - 2] +Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `scan` is assumed to be a subcommand here. If you intended for `scan` to be an argument to `scan`, you must specify `scan scan` in your command line. No package sources found, --help for usage information. --- @@ -1359,6 +1380,14 @@ Warning: `scan` exists as both a subcommand of OSV-Scanner and as a file on the --- +[TestRun_InsertDefaultCommand - 15] + +--- + +[TestRun_InsertDefaultCommand - 16] + +--- + [TestRun_Licenses/Licenses_in_summary_mode_json - 1] { "results": [ @@ -2879,267 +2908,6 @@ Scanned /fixtures/maven-transitive/pom.xml file and found 3 packages --- -[TestRun_OCIImage/Alpine_3.10_image_tar_with_3.18_version_file - 1] -Scanning local image tarball "../../internal/image/fixtures/test-alpine.tar" -Container Scanning Result (Alpine Linux v3.18): -Total 2 packages affected by 40 vulnerabilities (2 Critical, 17 High, 14 Medium, 0 Low, 7 Unknown) from 1 ecosystems. -40 vulnerabilities have fixes available. - -Alpine:v3.18 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| openssl | 1.1.1k-r0 | Fix Available | 38 | # 3 Layer | -- | -| zlib | 1.2.11-r1 | Fix Available | 2 | # 3 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/Alpine_3.10_image_tar_with_3.18_version_file - 2] - ---- - -[TestRun_OCIImage/Invalid_path - 1] -Scanning local image tarball "./fixtures/oci-image/no-file-here.tar" - ---- - -[TestRun_OCIImage/Invalid_path - 2] -failed to load image from tarball with path "./fixtures/oci-image/no-file-here.tar": open ./fixtures/oci-image/no-file-here.tar: no such file or directory - ---- - -[TestRun_OCIImage/scanning_image_with_go_binary - 1] -Scanning local image tarball "../../internal/image/fixtures/test-package-tracing.tar" -Container Scanning Result (Alpine Linux v3.20): -Total 7 packages affected by 27 vulnerabilities (0 Critical, 0 High, 0 Medium, 0 Low, 27 Unknown) from 2 ecosystems. -27 vulnerabilities have fixes available. - -Go -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/more-vuln-overwrite-less-vuln | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 9 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/ptf-1.2.0 | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 2 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/ptf-1.3.0 | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 4 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/ptf-1.3.0-moved | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 3 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/ptf-1.4.0 | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 2 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -+---------------------------------------------------------------------------------------------+ -| Source:lockfile:go/bin/ptf-vulnerable | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| stdlib | 1.22.4 | Fix Available | 4 | # 7 Layer | -- | -+---------+-------------------+---------------+------------+------------------+---------------+ -Alpine:v3.20 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| openssl | 3.3.1-r0 | Fix Available | 3 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_image_with_go_binary - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_npm_with_no_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-npm-empty.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 2 packages affected by 10 vulnerabilities (0 Critical, 0 High, 4 Medium, 0 Low, 6 Unknown) from 1 ecosystems. -10 vulnerabilities have fixes available. - -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_npm_with_no_packages - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_npm_with_some_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-npm-full.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 4 packages affected by 13 vulnerabilities (2 Critical, 0 High, 5 Medium, 0 Low, 6 Unknown) from 2 ecosystems. -12 vulnerabilities have fixes available. - -npm -+-------------------------------------------------------------------------------------------------+ -| Source:lockfile:prod/app/node_modules/.package-lock.json | -+----------+-------------------+------------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+----------+-------------------+------------------+------------+------------------+---------------+ -| cryo | 0.0.6 | No fix available | 1 | # 14 Layer | -- | -| minimist | 0.0.8 | Fix Available | 2 | # 13 Layer | -- | -+----------+-------------------+------------------+------------+------------------+---------------+ -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_npm_with_some_packages - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_pnpm_with_no_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-pnpm-empty.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 2 packages affected by 10 vulnerabilities (0 Critical, 0 High, 4 Medium, 0 Low, 6 Unknown) from 1 ecosystems. -10 vulnerabilities have fixes available. - -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_pnpm_with_no_packages - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_pnpm_with_some_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-pnpm-full.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 2 packages affected by 10 vulnerabilities (0 Critical, 0 High, 4 Medium, 0 Low, 6 Unknown) from 1 ecosystems. -10 vulnerabilities have fixes available. - -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_pnpm_with_some_packages - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_yarn_with_no_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-yarn-empty.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 2 packages affected by 10 vulnerabilities (0 Critical, 0 High, 4 Medium, 0 Low, 6 Unknown) from 1 ecosystems. -10 vulnerabilities have fixes available. - -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_yarn_with_no_packages - 2] - ---- - -[TestRun_OCIImage/scanning_node_modules_using_yarn_with_some_packages - 1] -Scanning local image tarball "../../internal/image/fixtures/test-node_modules-yarn-full.tar" -Container Scanning Result (Alpine Linux v3.19): -Total 2 packages affected by 10 vulnerabilities (0 Critical, 0 High, 4 Medium, 0 Low, 6 Unknown) from 1 ecosystems. -10 vulnerabilities have fixes available. - -Alpine:v3.19 -+---------------------------------------------------------------------------------------------+ -| Source:os:lib/apk/db/installed | -+---------+-------------------+---------------+------------+------------------+---------------+ -| PACKAGE | INSTALLED VERSION | FIX AVAILABLE | VULN COUNT | INTRODUCED LAYER | IN BASE IMAGE | -+---------+-------------------+---------------+------------+------------------+---------------+ -| busybox | 1.36.1-r15 | Fix Available | 4 | # 0 Layer | alpine | -| openssl | 3.1.4-r5 | Fix Available | 6 | # 0 Layer | alpine | -+---------+-------------------+---------------+------------+------------------+---------------+ - -For the most comprehensive scan results, we recommend using the HTML output: `osv-scanner --format html --output results.html`. -You can also view the full vulnerability list in your terminal with: `osv-scanner --format vertical`. - ---- - -[TestRun_OCIImage/scanning_node_modules_using_yarn_with_some_packages - 2] - ---- - [TestRun_SubCommands/scan_with_a_flag - 1] Scanning dir ./fixtures/locks-one-with-nested Scanned /fixtures/locks-one-with-nested/nested/composer.lock file and found 1 package diff --git a/cmd/osv-scanner/scan/callanalysis_parser.go b/cmd/osv-scanner/internal/helper/callanalysis_parser.go similarity index 91% rename from cmd/osv-scanner/scan/callanalysis_parser.go rename to cmd/osv-scanner/internal/helper/callanalysis_parser.go index 055a4ce1ee8..78a94e9719f 100644 --- a/cmd/osv-scanner/scan/callanalysis_parser.go +++ b/cmd/osv-scanner/internal/helper/callanalysis_parser.go @@ -1,4 +1,4 @@ -package scan +package helper var stableCallAnalysisStates = map[string]bool{ "go": true, @@ -6,7 +6,7 @@ var stableCallAnalysisStates = map[string]bool{ } // Creates a map to record if languages are enabled or disabled for call analysis. -func createCallAnalysisStates(enabledCallAnalysis []string, disabledCallAnalysis []string) map[string]bool { +func CreateCallAnalysisStates(enabledCallAnalysis []string, disabledCallAnalysis []string) map[string]bool { callAnalysisStates := make(map[string]bool) for _, language := range enabledCallAnalysis { diff --git a/cmd/osv-scanner/scan/callanalysis_parser_test.go b/cmd/osv-scanner/internal/helper/callanalysis_parser_test.go similarity index 94% rename from cmd/osv-scanner/scan/callanalysis_parser_test.go rename to cmd/osv-scanner/internal/helper/callanalysis_parser_test.go index edfe3e21195..68ee6b90443 100644 --- a/cmd/osv-scanner/scan/callanalysis_parser_test.go +++ b/cmd/osv-scanner/internal/helper/callanalysis_parser_test.go @@ -1,4 +1,4 @@ -package scan +package helper import ( "reflect" @@ -55,7 +55,7 @@ func TestCreateCallAnalysisStates(t *testing.T) { } for _, testCase := range testCases { - actualCallAnalysisStates := createCallAnalysisStates(testCase.enabledCallAnalysis, testCase.disabledCallAnalysis) + actualCallAnalysisStates := CreateCallAnalysisStates(testCase.enabledCallAnalysis, testCase.disabledCallAnalysis) if !reflect.DeepEqual(actualCallAnalysisStates, testCase.expectedCallAnalysisStates) { t.Errorf("expected call analysis states to be %v, but got %v", testCase.expectedCallAnalysisStates, actualCallAnalysisStates) diff --git a/cmd/osv-scanner/internal/helper/helper.go b/cmd/osv-scanner/internal/helper/helper.go new file mode 100644 index 00000000000..c068d26dc79 --- /dev/null +++ b/cmd/osv-scanner/internal/helper/helper.go @@ -0,0 +1,145 @@ +package helper + +import ( + "fmt" + "net/http" + "os/exec" + "runtime" + "slices" + "strings" + "time" + + "github.com/google/osv-scanner/pkg/reporter" + "github.com/urfave/cli/v2" +) + +// flags that require network access and values to disable them. +var OfflineFlags = map[string]string{ + "skip-git": "true", + "experimental-offline-vulnerabilities": "true", + "experimental-no-resolve": "true", + "experimental-licenses-summary": "false", + // "experimental-licenses": "", // StringSliceFlag has to be manually cleared. +} + +var GlobalScanFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Usage: "set/override config file", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Usage: "sets the output format; value can be: " + strings.Join(reporter.Format(), ", "), + Value: "table", + Action: func(_ *cli.Context, s string) error { + if slices.Contains(reporter.Format(), s) { + return nil + } + + return fmt.Errorf("unsupported output format \"%s\" - must be one of: %s", s, strings.Join(reporter.Format(), ", ")) + }, + }, + &cli.BoolFlag{ + Name: "serve", + Usage: "output as HTML result and serve it locally", + }, + &cli.StringFlag{ + Name: "output", + Usage: "saves the result to the given file path", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "verbosity", + Usage: "specify the level of information that should be provided during runtime; value can be: " + strings.Join(reporter.VerbosityLevels(), ", "), + Value: "info", + }, + &cli.BoolFlag{ + Name: "experimental-offline", + Usage: "run in offline mode, disabling any features requiring network access", + Action: func(ctx *cli.Context, b bool) error { + if !b { + return nil + } + // Disable the features requiring network access. + for flag, value := range OfflineFlags { + // TODO(michaelkedar): do something if the flag was already explicitly set. + if err := ctx.Set(flag, value); err != nil { + panic(fmt.Sprintf("failed setting offline flag %s to %s: %v", flag, value, err)) + } + } + + return nil + }, + }, + &cli.BoolFlag{ + Name: "experimental-offline-vulnerabilities", + Usage: "checks for vulnerabilities using local databases that are already cached", + }, + &cli.BoolFlag{ + Name: "experimental-download-offline-databases", + Usage: "downloads vulnerability databases for offline comparison", + }, + &cli.BoolFlag{ + Name: "experimental-no-resolve", + Usage: "disable transitive dependency resolution of manifest files", + }, + &cli.StringFlag{ + Name: "experimental-local-db-path", + Usage: "sets the path that local databases should be stored", + Hidden: true, + }, + &cli.BoolFlag{ + Name: "experimental-all-packages", + Usage: "when json output is selected, prints all packages", + }, + &cli.BoolFlag{ + Name: "experimental-licenses-summary", + Usage: "report a license summary, implying the --experimental-all-packages flag", + }, + &cli.StringSliceFlag{ + Name: "experimental-licenses", + Usage: "report on licenses based on an allowlist", + }, +} + +// openHTML opens the outputted HTML file. +func OpenHTML(r reporter.Reporter, outputPath string) { + // Open the outputted HTML file in the default browser. + r.Infof("Opening %s...\n", outputPath) + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", outputPath).Start() + case "windows": + err = exec.Command("start", "", outputPath).Start() + case "darwin": // macOS + err = exec.Command("open", outputPath).Start() + default: + r.Infof("Unsupported OS.\n") + } + + if err != nil { + r.Errorf("Failed to open: %s.\n Please manually open the outputted HTML file: %s\n", err, outputPath) + } +} + +// ServeHTML serves the single HTML file for remote accessing. +// The program will keep running to serve the HTML report on localhost +// until the user manually terminates it (e.g. using Ctrl+C). +func ServeHTML(r reporter.Reporter, outputPath string) { + servePort := "8000" + localhostURL := fmt.Sprintf("http://localhost:%s/", servePort) + r.Infof("Serving HTML report at %s.\nIf you are accessing remotely, use the following SSH command:\n`ssh -L local_port:destination_server_ip:%s ssh_server_hostname`\n", localhostURL, servePort) + server := &http.Server{ + Addr: ":" + servePort, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, outputPath) + }), + ReadHeaderTimeout: 3 * time.Second, + } + if err := server.ListenAndServe(); err != nil { + r.Errorf("Failed to start server: %v\n", err) + } +} diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go index 379aa09a3df..4cde9b9e82f 100644 --- a/cmd/osv-scanner/main.go +++ b/cmd/osv-scanner/main.go @@ -42,6 +42,7 @@ func run(args []string, stdout, stderr io.Writer) int { fix.Command(stdout, stderr, &r), update.Command(stdout, stderr, &r), }, + CustomAppHelpTemplate: getCustomHelpTemplate(), } // If ExitErrHandler is not set, cli will use the default cli.HandleExitCoder. @@ -84,6 +85,41 @@ func run(args []string, stdout, stderr io.Writer) int { return 0 } +func getCustomHelpTemplate() string { + return ` +NAME: + {{.Name}} - {{.Usage}} + +USAGE: + {{.Name}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} + +EXAMPLES: + # Scan a source directory + $ {{.Name}} scan source -r + + # Scan a container image + $ {{.Name}} scan image + + # Scan a local image archive (e.g. a tar file) and generate HTML output + $ {{.Name}} scan image --serve --archive + + # Fix vulnerabilities in a manifest file and lockfile (non-interactive mode) + $ {{.Name}} fix --non-interactive -M -L + + For full usage details, please refer to the help command of each subcommand (e.g. {{.Name}} scan --help). + +VERSION: + {{.Version}} + +COMMANDS: +{{range .Commands}}{{if and (not .HideHelp) (not .Hidden)}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}} +{{if .VisibleFlags}} +GLOBAL OPTIONS: + {{range .VisibleFlags}} {{.}}{{end}} +{{end}} +` +} + // Gets all valid commands and global options for OSV-Scanner. func getAllCommands(commands []*cli.Command) []string { // Adding all subcommands @@ -108,24 +144,60 @@ func getAllCommands(commands []*cli.Command) []string { return allCommands } +// warnIfCommandAmbiguous warns the user if the command they are trying to run +// exists as both a subcommand and as a file on the filesystem. +// If this is the case, the command is assumed to be a subcommand. +func warnIfCommandAmbiguous(command string, stdout, stderr io.Writer) { + if _, err := os.Stat(command); err == nil { + r := reporter.NewJSONReporter(stdout, stderr, reporter.InfoLevel) + r.Warnf("Warning: `%[1]s` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `%[1]s` is assumed to be a subcommand here. If you intended for `%[1]s` to be an argument to `%[1]s`, you must specify `%[1]s %[1]s` in your command line.\n", command) + } +} + // Inserts the default command to args if no command is specified. func insertDefaultCommand(args []string, commands []*cli.Command, defaultCommand string, stdout, stderr io.Writer) []string { + // Do nothing if no command or file name is provided. if len(args) < 2 { return args } allCommands := getAllCommands(commands) - if !slices.Contains(allCommands, args[1]) { + command := args[1] + // If no command is provided, use the default command and subcommand. + if !slices.Contains(allCommands, command) { // Avoids modifying args in-place, as some unit tests rely on its original value for multiple calls. - argsTmp := make([]string, len(args)+1) - copy(argsTmp[2:], args[1:]) + argsTmp := make([]string, len(args)+2) + copy(argsTmp[3:], args[1:]) argsTmp[1] = defaultCommand + // Set the default subCommand of Scan + argsTmp[2] = scan.DefaultSubcommand // Executes the cli app with the new args. return argsTmp - } else if _, err := os.Stat(args[1]); err == nil { - r := reporter.NewJSONReporter(stdout, stderr, reporter.InfoLevel) - r.Warnf("Warning: `%[1]s` exists as both a subcommand of OSV-Scanner and as a file on the filesystem. `%[1]s` is assumed to be a subcommand here. If you intended for `%[1]s` to be an argument to `%[1]s`, you must specify `%[1]s %[1]s` in your command line.\n", args[1]) + } + + warnIfCommandAmbiguous(command, stdout, stderr) + + // If only the default command is provided without its subcommand, append the subcommand. + if command == defaultCommand { + if len(args) < 3 { + // Indicates that only "osv-scanner scan" was provided, without a subcommand or filename + return args + } + + subcommand := args[2] + // Default to the "source" subcommand if none is provided. + if !slices.Contains(scan.Subcommands, subcommand) { + argsTmp := make([]string, len(args)+1) + copy(argsTmp[3:], args[2:]) + argsTmp[1] = defaultCommand + argsTmp[2] = scan.DefaultSubcommand + + return argsTmp + } + + // Print a warning message if subcommand exist on the filesystem. + warnIfCommandAmbiguous(subcommand, stdout, stderr) } return args diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go index 62bba6b7448..5f33ee062f3 100644 --- a/cmd/osv-scanner/main_test.go +++ b/cmd/osv-scanner/main_test.go @@ -3,7 +3,6 @@ package main import ( "bytes" - "errors" "os" "path/filepath" "reflect" @@ -165,7 +164,7 @@ func TestRun(t *testing.T) { { name: "", args: []string{""}, - exit: 128, + exit: 0, }, { name: "version", @@ -781,28 +780,28 @@ func TestRun_Docker(t *testing.T) { tests := []cliTestCase{ { name: "Fake alpine image", - args: []string{"", "--docker", "alpine:non-existent-tag"}, + args: []string{"", "scan", "image", "alpine:non-existent-tag"}, exit: 127, }, { name: "Fake image entirely", - args: []string{"", "--docker", "this-image-definitely-does-not-exist-abcde"}, + args: []string{"", "scan", "image", "this-image-definitely-does-not-exist-abcde"}, exit: 127, }, // TODO: How to prevent these snapshots from changing constantly { name: "Real empty image", - args: []string{"", "--docker", "hello-world"}, + args: []string{"", "scan", "image", "hello-world"}, exit: 128, // No packages found }, { name: "Real empty image with tag", - args: []string{"", "--docker", "hello-world:linux"}, + args: []string{"", "scan", "image", "hello-world:linux"}, exit: 128, // No package found }, { name: "Real Alpine image", - args: []string{"", "--docker", "alpine:3.18.9"}, + args: []string{"", "scan", "image", "alpine:3.18.9"}, exit: 1, }, } @@ -818,76 +817,6 @@ func TestRun_Docker(t *testing.T) { } } -func TestRun_OCIImage(t *testing.T) { - t.Parallel() - - testutility.SkipIfNotAcceptanceTesting(t, "Not consistent on MacOS/Windows") - - tests := []cliTestCase{ - { - name: "Invalid path", - args: []string{"", "--experimental-oci-image", "./fixtures/oci-image/no-file-here.tar"}, - exit: 127, - }, - { - name: "Alpine 3.10 image tar with 3.18 version file", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-alpine.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using npm with no packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-npm-empty.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using npm with some packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-npm-full.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using yarn with no packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-yarn-empty.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using yarn with some packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-yarn-full.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using pnpm with no packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-pnpm-empty.tar"}, - exit: 1, - }, - { - name: "scanning node_modules using pnpm with some packages", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-node_modules-pnpm-full.tar"}, - exit: 1, - }, - { - name: "scanning image with go binary", - args: []string{"", "--experimental-oci-image", "../../internal/image/fixtures/test-package-tracing.tar"}, - exit: 1, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // point out that we need the images to be built and saved separately - for _, arg := range tt.args { - if strings.HasPrefix(arg, "../../internal/image/fixtures/") && strings.HasSuffix(arg, ".tar") { - if _, err := os.Stat(arg); errors.Is(err, os.ErrNotExist) { - t.Fatalf("%s does not exist - have you run scripts/build_test_images.sh?", arg) - } - } - } - - testCli(t, tt) - }) - } -} - // Tests all subcommands here. func TestRun_SubCommands(t *testing.T) { t.Parallel() @@ -936,22 +865,27 @@ func TestRun_InsertDefaultCommand(t *testing.T) { // test when default command is specified { originalArgs: []string{"", "default", "file"}, - wantArgs: []string{"", "default", "file"}, + wantArgs: []string{"", "default", "source", "file"}, }, // test when command is not specified { originalArgs: []string{"", "file"}, - wantArgs: []string{"", "default", "file"}, + wantArgs: []string{"", "default", "source", "file"}, }, // test when command is also a filename { originalArgs: []string{"", "scan"}, // `scan` exists as a file on filesystem (`./cmd/osv-scanner/scan`) wantArgs: []string{"", "scan"}, }, + // test when subcommand is also a filename + { + originalArgs: []string{"", "default", "image"}, + wantArgs: []string{"", "default", "image"}, + }, // test when command is not valid { originalArgs: []string{"", "invalid"}, - wantArgs: []string{"", "default", "invalid"}, + wantArgs: []string{"", "default", "source", "invalid"}, }, // test when command is a built-in option { diff --git a/cmd/osv-scanner/scan/image/main.go b/cmd/osv-scanner/scan/image/main.go new file mode 100644 index 00000000000..7a90817d7a2 --- /dev/null +++ b/cmd/osv-scanner/scan/image/main.go @@ -0,0 +1,119 @@ +package image + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/google/osv-scanner/cmd/osv-scanner/internal/helper" + "github.com/google/osv-scanner/pkg/models" + "github.com/google/osv-scanner/pkg/osvscanner" + "github.com/google/osv-scanner/pkg/reporter" + "golang.org/x/term" + + "github.com/urfave/cli/v2" +) + +var imageScanFlags = []cli.Flag{ + &cli.BoolFlag{ + Name: "archive", + Usage: "input a local archive image (e.g. a tar file)", + }, +} + +func Command(stdout, stderr io.Writer, r *reporter.Reporter) *cli.Command { + return &cli.Command{ + Name: "image", + Usage: "detects vulnerabilities in a container image's dependencies, pulling the image if it's not found locally", + Description: "detects vulnerabilities in a container image's dependencies, pulling the image if it's not found locally", + Flags: append(imageScanFlags, helper.GlobalScanFlags...), + ArgsUsage: "[image imageName]", + Action: func(c *cli.Context) error { + var err error + *r, err = action(c, stdout, stderr) + + return err + }, + } +} + +func action(context *cli.Context, stdout, stderr io.Writer) (reporter.Reporter, error) { + format := context.String("format") + + outputPath := context.String("output") + serve := context.Bool("serve") + if serve { + format = "html" + if outputPath == "" { + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "osv-scanner-result") + if err != nil { + return nil, fmt.Errorf("failed creating temporary directory: %w\n"+ + "Please use `--output result.html` to specify the output path", err) + } + + // Remove the created temporary directory after + defer os.RemoveAll(tmpDir) + outputPath = filepath.Join(tmpDir, "index.html") + } + } + + termWidth := 0 + var err error + if outputPath != "" { // Output is definitely a file + stdout, err = os.Create(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to create output file: %w", err) + } + } else { // Output might be a terminal + if stdoutAsFile, ok := stdout.(*os.File); ok { + termWidth, _, err = term.GetSize(int(stdoutAsFile.Fd())) + if err != nil { // If output is not a terminal, + termWidth = 0 + } + } + } + + verbosityLevel, err := reporter.ParseVerbosityLevel(context.String("verbosity")) + if err != nil { + return nil, err + } + r, err := reporter.New(format, stdout, stderr, verbosityLevel, termWidth) + if err != nil { + return r, err + } + + if context.Args().Len() == 0 { + return r, errors.New("please provide an image name or see the help document") + } + scannerAction := osvscanner.ScannerActions{ + Image: context.Args().First(), + ConfigOverridePath: context.String("config"), + IsImageArchive: context.Bool("archive"), + } + + var vulnResult models.VulnerabilityResults + vulnResult, err = osvscanner.DoContainerScan(scannerAction, r) + + if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) { + return r, err + } + + if errPrint := r.PrintResult(&vulnResult); errPrint != nil { + return r, fmt.Errorf("failed to write output: %w", errPrint) + } + + // Auto-open outputted HTML file for users. + if outputPath != "" { + if serve { + helper.ServeHTML(r, outputPath) + } else if format == "html" { + helper.OpenHTML(r, outputPath) + } + } + + // This may be nil. + return r, err +} diff --git a/cmd/osv-scanner/scan/main.go b/cmd/osv-scanner/scan/main.go index abbd8e34d4a..9f0e7ebeca2 100644 --- a/cmd/osv-scanner/scan/main.go +++ b/cmd/osv-scanner/scan/main.go @@ -1,383 +1,29 @@ package scan import ( - "errors" - "fmt" "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "strings" - "time" - "github.com/google/osv-scanner/internal/spdx" - "github.com/google/osv-scanner/pkg/models" - "github.com/google/osv-scanner/pkg/osvscanner" + "github.com/google/osv-scanner/cmd/osv-scanner/scan/image" + "github.com/google/osv-scanner/cmd/osv-scanner/scan/source" "github.com/google/osv-scanner/pkg/reporter" - "golang.org/x/term" "github.com/urfave/cli/v2" ) -// flags that require network access and values to disable them. -var offlineFlags = map[string]string{ - "skip-git": "true", - "experimental-offline-vulnerabilities": "true", - "experimental-no-resolve": "true", - "experimental-licenses-summary": "false", - // "experimental-licenses": "", // StringSliceFlag has to be manually cleared. -} +const sourceSubCommand = "source" + +const DefaultSubcommand = sourceSubCommand + +var Subcommands = []string{sourceSubCommand, "image"} func Command(stdout, stderr io.Writer, r *reporter.Reporter) *cli.Command { return &cli.Command{ Name: "scan", - Usage: "scans various mediums for dependencies and matches it against the OSV database", - Description: "scans various mediums for dependencies and matches it against the OSV database", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "docker", - Aliases: []string{"D"}, - Usage: "scan docker image with this name. This is a convenience function which runs `docker save` before scanning the saved image using --oci-image", - TakesFile: false, - }, - &cli.StringSliceFlag{ - Name: "lockfile", - Aliases: []string{"L"}, - Usage: "scan package lockfile on this path", - TakesFile: true, - }, - &cli.StringSliceFlag{ - Name: "sbom", - Aliases: []string{"S"}, - Usage: "scan sbom file on this path", - TakesFile: true, - }, - &cli.StringFlag{ - Name: "config", - Usage: "set/override config file", - TakesFile: true, - }, - &cli.StringFlag{ - Name: "format", - Aliases: []string{"f"}, - Usage: "sets the output format; value can be: " + strings.Join(reporter.Format(), ", "), - Value: "table", - Action: func(_ *cli.Context, s string) error { - if slices.Contains(reporter.Format(), s) { - return nil - } - - // Supporting html output format without showing it in the help command. - // TODO(gongh@): add html to reporter.Format() - if s == "html" { - return nil - } - - return fmt.Errorf("unsupported output format \"%s\" - must be one of: %s", s, strings.Join(reporter.Format(), ", ")) - }, - }, - &cli.BoolFlag{ - Name: "serve", - Usage: "output as HTML result and serve it locally", - }, - &cli.BoolFlag{ - Name: "json", - Usage: "sets output to json (deprecated, use --format json instead)", - }, - &cli.StringFlag{ - Name: "output", - Usage: "saves the result to the given file path", - TakesFile: true, - }, - &cli.BoolFlag{ - Name: "skip-git", - Usage: "skip scanning git repositories", - Value: false, - }, - &cli.BoolFlag{ - Name: "recursive", - Aliases: []string{"r"}, - Usage: "check subdirectories", - Value: false, - }, - &cli.BoolFlag{ - Name: "experimental-call-analysis", - Usage: "[Deprecated] attempt call analysis on code to detect only active vulnerabilities", - Value: false, - }, - &cli.BoolFlag{ - Name: "no-ignore", - Usage: "also scan files that would be ignored by .gitignore", - Value: false, - }, - &cli.StringSliceFlag{ - Name: "call-analysis", - Usage: "attempt call analysis on code to detect only active vulnerabilities", - }, - &cli.StringSliceFlag{ - Name: "no-call-analysis", - Usage: "disables call graph analysis", - }, - &cli.StringFlag{ - Name: "verbosity", - Usage: "specify the level of information that should be provided during runtime; value can be: " + strings.Join(reporter.VerbosityLevels(), ", "), - Value: "info", - }, - &cli.BoolFlag{ - Name: "experimental-offline", - Usage: "run in offline mode, disabling any features requiring network access", - Action: func(ctx *cli.Context, b bool) error { - if !b { - return nil - } - // Disable the features requiring network access. - for flag, value := range offlineFlags { - // TODO(michaelkedar): do something if the flag was already explicitly set. - if err := ctx.Set(flag, value); err != nil { - panic(fmt.Sprintf("failed setting offline flag %s to %s: %v", flag, value, err)) - } - } - - return nil - }, - }, - &cli.BoolFlag{ - Name: "experimental-offline-vulnerabilities", - Usage: "checks for vulnerabilities using local databases that are already cached", - }, - &cli.BoolFlag{ - Name: "experimental-download-offline-databases", - Usage: "downloads vulnerability databases for offline comparison", - }, - &cli.StringFlag{ - Name: "experimental-local-db-path", - Usage: "sets the path that local databases should be stored", - Hidden: true, - }, - &cli.BoolFlag{ - Name: "experimental-all-packages", - Usage: "when json output is selected, prints all packages", - }, - &cli.BoolFlag{ - Name: "experimental-licenses-summary", - Usage: "report a license summary, implying the --experimental-all-packages flag", - }, - &cli.StringSliceFlag{ - Name: "experimental-licenses", - Usage: "report on licenses based on an allowlist", - }, - &cli.StringFlag{ - Name: "experimental-oci-image", - Usage: "scan an exported *docker* container image archive (exported using `docker save` command) file", - TakesFile: true, - Hidden: true, - }, - &cli.BoolFlag{ - Name: "experimental-no-resolve", - Usage: "disable transitive dependency resolution of manifest files", - }, - &cli.StringFlag{ - Name: "experimental-resolution-data-source", - Usage: "source to fetch package information from; value can be: deps.dev, native", - Value: "deps.dev", - Action: func(_ *cli.Context, s string) error { - if s != "deps.dev" && s != "native" { - return fmt.Errorf("unsupported data-source \"%s\" - must be one of: deps.dev, native", s) - } - - return nil - }, - }, - &cli.StringFlag{ - Name: "experimental-maven-registry", - Usage: "URL of the default registry to fetch Maven metadata", - }, + Usage: "scans projects and container images for dependencies, and checks them against the OSV database.", + Description: "scans projects and container images for dependencies, and checks them against the OSV database.", + Subcommands: []*cli.Command{ + source.Command(stdout, stderr, r), + image.Command(stdout, stderr, r), }, - ArgsUsage: "[directory1 directory2...]", - Action: func(c *cli.Context) error { - var err error - *r, err = action(c, stdout, stderr) - - return err - }, - } -} - -func action(context *cli.Context, stdout, stderr io.Writer) (reporter.Reporter, error) { - format := context.String("format") - - if context.Bool("json") { - format = "json" - } - - outputPath := context.String("output") - serve := context.Bool("serve") - if serve { - format = "html" - if outputPath == "" { - // Create a temporary directory - tmpDir, err := os.MkdirTemp("", "osv-scanner-result") - if err != nil { - return nil, fmt.Errorf("failed creating temporary directory: %w\n"+ - "Please use `--output result.html` to specify the output path", err) - } - - // Remove the created temporary directory after - defer os.RemoveAll(tmpDir) - outputPath = filepath.Join(tmpDir, "index.html") - } - } - - termWidth := 0 - var err error - if outputPath != "" { // Output is definitely a file - stdout, err = os.Create(outputPath) - if err != nil { - return nil, fmt.Errorf("failed to create output file: %w", err) - } - } else { // Output might be a terminal - if stdoutAsFile, ok := stdout.(*os.File); ok { - termWidth, _, err = term.GetSize(int(stdoutAsFile.Fd())) - if err != nil { // If output is not a terminal, - termWidth = 0 - } - } - } - - if context.Bool("experimental-licenses-summary") && context.IsSet("experimental-licenses") { - return nil, errors.New("--experimental-licenses-summary and --experimental-licenses flags cannot be set") - } - allowlist := context.StringSlice("experimental-licenses") - if context.IsSet("experimental-licenses") { - if len(allowlist) == 0 || - (len(allowlist) == 1 && allowlist[0] == "") { - return nil, errors.New("--experimental-licenses requires at least one value") - } - if unrecognized := spdx.Unrecognized(allowlist); len(unrecognized) > 0 { - return nil, fmt.Errorf("--experimental-licenses requires comma-separated spdx licenses. The following license(s) are not recognized as spdx: %s", strings.Join(unrecognized, ",")) - } - } - - verbosityLevel, err := reporter.ParseVerbosityLevel(context.String("verbosity")) - if err != nil { - return nil, err - } - r, err := reporter.New(format, stdout, stderr, verbosityLevel, termWidth) - if err != nil { - return r, err - } - - var callAnalysisStates map[string]bool - if context.IsSet("experimental-call-analysis") { - callAnalysisStates = createCallAnalysisStates([]string{"all"}, context.StringSlice("no-call-analysis")) - r.Infof("Warning: the experimental-call-analysis flag has been replaced. Please use the call-analysis and no-call-analysis flags instead.\n") - } else { - callAnalysisStates = createCallAnalysisStates(context.StringSlice("call-analysis"), context.StringSlice("no-call-analysis")) - } - - scanLicensesAllowlist := context.StringSlice("experimental-licenses") - if context.Bool("experimental-offline") { - scanLicensesAllowlist = []string{} - } - - scannerAction := osvscanner.ScannerActions{ - LockfilePaths: context.StringSlice("lockfile"), - SBOMPaths: context.StringSlice("sbom"), - Image: context.String("docker"), - Recursive: context.Bool("recursive"), - SkipGit: context.Bool("skip-git"), - NoIgnore: context.Bool("no-ignore"), - ConfigOverridePath: context.String("config"), - DirectoryPaths: context.Args().Slice(), - CallAnalysisStates: callAnalysisStates, - ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{ - LocalDBPath: context.String("experimental-local-db-path"), - DownloadDatabases: context.Bool("experimental-download-offline-databases"), - CompareOffline: context.Bool("experimental-offline-vulnerabilities"), - // License summary mode causes all - // packages to appear in the json as - // every package has a license - even - // if it's just the UNKNOWN license. - ShowAllPackages: context.Bool("experimental-all-packages") || - context.Bool("experimental-licenses-summary"), - ScanLicensesSummary: context.Bool("experimental-licenses-summary"), - ScanLicensesAllowlist: scanLicensesAllowlist, - ScanOCIImage: context.String("experimental-oci-image"), - TransitiveScanningActions: osvscanner.TransitiveScanningActions{ - Disabled: context.Bool("experimental-no-resolve"), - NativeDataSource: context.String("experimental-resolution-data-source") == "native", - MavenRegistry: context.String("experimental-maven-registry"), - }, - }, - } - - var vulnResult models.VulnerabilityResults - if context.String("docker") != "" || context.String("experimental-oci-image") != "" { - vulnResult, err = osvscanner.DoContainerScan(scannerAction, r) - } else { - vulnResult, err = osvscanner.DoScan(scannerAction, r) - } - - if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) { - return r, err - } - - if errPrint := r.PrintResult(&vulnResult); errPrint != nil { - return r, fmt.Errorf("failed to write output: %w", errPrint) - } - - // Auto-open outputted HTML file for users. - if outputPath != "" { - if serve { - serveHTML(r, outputPath) - } else if format == "html" { - openHTML(r, outputPath) - } - } - - // This may be nil. - return r, err -} - -// openHTML opens the outputted HTML file. -func openHTML(r reporter.Reporter, outputPath string) { - // Open the outputted HTML file in the default browser. - r.Infof("Opening %s...\n", outputPath) - var err error - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", outputPath).Start() - case "windows": - err = exec.Command("start", "", outputPath).Start() - case "darwin": // macOS - err = exec.Command("open", outputPath).Start() - default: - r.Infof("Unsupported OS.\n") - } - - if err != nil { - r.Errorf("Failed to open: %s.\n Please manually open the outputted HTML file: %s\n", err, outputPath) - } -} - -// Serve the single HTML file for remote accessing. -// The program will keep running to serve the HTML report on localhost -// until the user manually terminates it (e.g. using Ctrl+C). -func serveHTML(r reporter.Reporter, outputPath string) { - servePort := "8000" - localhostURL := fmt.Sprintf("http://localhost:%s/", servePort) - r.Infof("Serving HTML report at %s.\nIf you are accessing remotely, use the following SSH command:\n`ssh -L local_port:destination_server_ip:%s ssh_server_hostname`\n", localhostURL, servePort) - server := &http.Server{ - Addr: ":" + servePort, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, outputPath) - }), - ReadHeaderTimeout: 3 * time.Second, - } - if err := server.ListenAndServe(); err != nil { - r.Errorf("Failed to start server: %v\n", err) } } diff --git a/cmd/osv-scanner/scan/source/main.go b/cmd/osv-scanner/scan/source/main.go new file mode 100644 index 00000000000..c708101504a --- /dev/null +++ b/cmd/osv-scanner/scan/source/main.go @@ -0,0 +1,228 @@ +package source + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/osv-scanner/cmd/osv-scanner/internal/helper" + "github.com/google/osv-scanner/internal/spdx" + "github.com/google/osv-scanner/pkg/models" + "github.com/google/osv-scanner/pkg/osvscanner" + "github.com/google/osv-scanner/pkg/reporter" + "github.com/urfave/cli/v2" + "golang.org/x/term" +) + +var projectScanFlags = []cli.Flag{ + &cli.StringSliceFlag{ + Name: "lockfile", + Aliases: []string{"L"}, + Usage: "scan package lockfile on this path", + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: "sbom", + Aliases: []string{"S"}, + Usage: "scan sbom file on this path", + TakesFile: true, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "sets output to json (deprecated, use --format json instead)", + }, + &cli.BoolFlag{ + Name: "skip-git", + Usage: "skip scanning git repositories", + Value: false, + }, + &cli.BoolFlag{ + Name: "recursive", + Aliases: []string{"r"}, + Usage: "check subdirectories", + Value: false, + }, + &cli.BoolFlag{ + Name: "experimental-call-analysis", + Usage: "[Deprecated] attempt call analysis on code to detect only active vulnerabilities", + Value: false, + }, + &cli.BoolFlag{ + Name: "no-ignore", + Usage: "also scan files that would be ignored by .gitignore", + Value: false, + }, + &cli.StringSliceFlag{ + Name: "call-analysis", + Usage: "attempt call analysis on code to detect only active vulnerabilities", + }, + &cli.StringSliceFlag{ + Name: "no-call-analysis", + Usage: "disables call graph analysis", + }, + &cli.StringFlag{ + Name: "experimental-resolution-data-source", + Usage: "source to fetch package information from; value can be: deps.dev, native", + Value: "deps.dev", + Action: func(_ *cli.Context, s string) error { + if s != "deps.dev" && s != "native" { + return fmt.Errorf("unsupported data-source \"%s\" - must be one of: deps.dev, native", s) + } + + return nil + }, + }, + &cli.StringFlag{ + Name: "experimental-maven-registry", + Usage: "URL of the default registry to fetch Maven metadata", + }, +} + +func Command(stdout, stderr io.Writer, r *reporter.Reporter) *cli.Command { + return &cli.Command{ + Name: "source", + Usage: "scans a source project's dependencies for known vulnerabilities using the OSV database.", + Description: "scans a source project's dependencies for known vulnerabilities using the OSV database.", + Flags: append(projectScanFlags, helper.GlobalScanFlags...), + ArgsUsage: "[directory1 directory2...]", + Action: func(c *cli.Context) error { + var err error + *r, err = Action(c, stdout, stderr) + + return err + }, + } +} + +func Action(context *cli.Context, stdout, stderr io.Writer) (reporter.Reporter, error) { + format := context.String("format") + + if context.Bool("json") { + format = "json" + } + + outputPath := context.String("output") + serve := context.Bool("serve") + if serve { + format = "html" + if outputPath == "" { + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "osv-scanner-result") + if err != nil { + return nil, fmt.Errorf("failed creating temporary directory: %w\n"+ + "Please use `--output result.html` to specify the output path", err) + } + + // Remove the created temporary directory after + defer os.RemoveAll(tmpDir) + outputPath = filepath.Join(tmpDir, "index.html") + } + } + + termWidth := 0 + var err error + if outputPath != "" { // Output is definitely a file + stdout, err = os.Create(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to create output file: %w", err) + } + } else { // Output might be a terminal + if stdoutAsFile, ok := stdout.(*os.File); ok { + termWidth, _, err = term.GetSize(int(stdoutAsFile.Fd())) + if err != nil { // If output is not a terminal, + termWidth = 0 + } + } + } + + if context.Bool("experimental-licenses-summary") && context.IsSet("experimental-licenses") { + return nil, errors.New("--experimental-licenses-summary and --experimental-licenses flags cannot be set") + } + allowlist := context.StringSlice("experimental-licenses") + if context.IsSet("experimental-licenses") { + if len(allowlist) == 0 || + (len(allowlist) == 1 && allowlist[0] == "") { + return nil, errors.New("--experimental-licenses requires at least one value") + } + if unrecognized := spdx.Unrecognized(allowlist); len(unrecognized) > 0 { + return nil, fmt.Errorf("--experimental-licenses requires comma-separated spdx licenses. The following license(s) are not recognized as spdx: %s", strings.Join(unrecognized, ",")) + } + } + + verbosityLevel, err := reporter.ParseVerbosityLevel(context.String("verbosity")) + if err != nil { + return nil, err + } + r, err := reporter.New(format, stdout, stderr, verbosityLevel, termWidth) + if err != nil { + return r, err + } + + var callAnalysisStates map[string]bool + if context.IsSet("experimental-call-analysis") { + callAnalysisStates = helper.CreateCallAnalysisStates([]string{"all"}, context.StringSlice("no-call-analysis")) + r.Infof("Warning: the experimental-call-analysis flag has been replaced. Please use the call-analysis and no-call-analysis flags instead.\n") + } else { + callAnalysisStates = helper.CreateCallAnalysisStates(context.StringSlice("call-analysis"), context.StringSlice("no-call-analysis")) + } + + scanLicensesAllowlist := context.StringSlice("experimental-licenses") + if context.Bool("experimental-offline") { + scanLicensesAllowlist = []string{} + } + + scannerAction := osvscanner.ScannerActions{ + LockfilePaths: context.StringSlice("lockfile"), + SBOMPaths: context.StringSlice("sbom"), + Recursive: context.Bool("recursive"), + SkipGit: context.Bool("skip-git"), + NoIgnore: context.Bool("no-ignore"), + ConfigOverridePath: context.String("config"), + DirectoryPaths: context.Args().Slice(), + CallAnalysisStates: callAnalysisStates, + ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{ + LocalDBPath: context.String("experimental-local-db-path"), + DownloadDatabases: context.Bool("experimental-download-offline-databases"), + CompareOffline: context.Bool("experimental-offline-vulnerabilities"), + // License summary mode causes all + // packages to appear in the json as + // every package has a license - even + // if it's just the UNKNOWN license. + ShowAllPackages: context.Bool("experimental-all-packages") || + context.Bool("experimental-licenses-summary"), + ScanLicensesSummary: context.Bool("experimental-licenses-summary"), + ScanLicensesAllowlist: scanLicensesAllowlist, + TransitiveScanningActions: osvscanner.TransitiveScanningActions{ + Disabled: context.Bool("experimental-no-resolve"), + NativeDataSource: context.String("experimental-resolution-data-source") == "native", + MavenRegistry: context.String("experimental-maven-registry"), + }, + }, + } + + var vulnResult models.VulnerabilityResults + vulnResult, err = osvscanner.DoScan(scannerAction, r) + + if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) { + return r, err + } + + if errPrint := r.PrintResult(&vulnResult); errPrint != nil { + return r, fmt.Errorf("failed to write output: %w", errPrint) + } + + // Auto-open outputted HTML file for users. + if outputPath != "" { + if serve { + helper.ServeHTML(r, outputPath) + } else if format == "html" { + helper.OpenHTML(r, outputPath) + } + } + + // This may be nil. + return r, err +} diff --git a/go.mod b/go.mod index 654a62e6014..809b1f499ea 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.13.1 github.com/google/go-cmp v0.6.0 - github.com/google/osv-scalibr v0.1.6-0.20250120233754-46a5374f26ee + github.com/google/osv-scalibr v0.1.6-0.20250123155336-85f39dea4c05 github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd github.com/jedib0t/go-pretty/v6 v6.6.5 github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index 73d39591f5a..1d9d56315d6 100644 --- a/go.sum +++ b/go.sum @@ -182,8 +182,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= -github.com/google/osv-scalibr v0.1.6-0.20250120233754-46a5374f26ee h1:DLmJQTqn0F3vANcvMixe+DElmDHZZy7Hgn6RK+ItVWE= -github.com/google/osv-scalibr v0.1.6-0.20250120233754-46a5374f26ee/go.mod h1:nikSO3CqGGRQY05sGgzsgf4+84p5xCmPWOiaSomkuAU= +github.com/google/osv-scalibr v0.1.6-0.20250123155336-85f39dea4c05 h1:47dObbqXVFPmg39yLeRWfKZYw2xR6O2BJVLmgC6Zygw= +github.com/google/osv-scalibr v0.1.6-0.20250123155336-85f39dea4c05/go.mod h1:nikSO3CqGGRQY05sGgzsgf4+84p5xCmPWOiaSomkuAU= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/output/__snapshots__/output_result_test.snap b/internal/output/__snapshots__/output_result_test.snap index bcaf655a1fa..08392afe31b 100755 --- a/internal/output/__snapshots__/output_result_test.snap +++ b/internal/output/__snapshots__/output_result_test.snap @@ -4301,7 +4301,6 @@ "GHSA-123" ], "Aliases": [ - "OSV-1", "GHSA-123" ], "IsFixable": false, @@ -4469,7 +4468,6 @@ "GHSA-123" ], "Aliases": [ - "OSV-1", "GHSA-123" ], "IsFixable": false, diff --git a/internal/output/html/report_template.gohtml b/internal/output/html/report_template.gohtml index 0c0e3850e86..062b64050ab 100644 --- a/internal/output/html/report_template.gohtml +++ b/internal/output/html/report_template.gohtml @@ -14,9 +14,14 @@ {{ template "script.html" }}
- -
-

Open Source Vulnerabilities

+
+ +
+

Open Source Vulnerabilities

+
+
+ Feedback +
diff --git a/internal/output/html/style.html b/internal/output/html/style.html index a7e08555187..10e43224868 100644 --- a/internal/output/html/style.html +++ b/internal/output/html/style.html @@ -47,25 +47,45 @@ header { display: flex; - align-items: center; margin-bottom: 50px; + justify-content: space-between; + } + + #header-left { + display: flex; + align-items: center; } .logo { height: 20px; } - header .vl { + #header-left .vl { border-left: 2px solid #fff; height: 25px; margin-left: 20px; margin-right: 20px; } - header h1 { + #header-left h1 { font-size: 23px; } + #header-right { + display: flex; + align-items: center; + } + + #header-right ::after { + display: inline-block; + content: " "; + background-image: url(https://osv.dev/static/img/external-link.svg); + width: 16px; + height: 16px; + margin-left: 3px; + vertical-align: middle; + } + .material-icons { vertical-align: middle; user-select: none; diff --git a/internal/output/html/vuln_table_template.gohtml b/internal/output/html/vuln_table_template.gohtml index 6f8a080a807..cb5b4179876 100644 --- a/internal/output/html/vuln_table_template.gohtml +++ b/internal/output/html/vuln_table_template.gohtml @@ -10,8 +10,8 @@ {{ $index := uniqueID }} - {{ if eq (len $element.GroupIDs) 0 }} - {{ $element.ID }} + {{ if eq (len $element.GroupIDs) 1 }} +
{{ $element.ID }}
{{ else }}
{{ $element.ID }}
@@ -49,11 +49,11 @@ {{ $index := uniqueID }} - {{ if eq (len $element.GroupIDs) 0 }} - {{ $element.ID }} + {{ if eq (len $element.GroupIDs) 1 }} +
{{ $element.ID }}
{{ else }}
- {{ $element.ID }} +
{{ $element.ID }}
Group IDs: {{ join $element.GroupIDs ", " }}
{{ end }} diff --git a/internal/output/output_result.go b/internal/output/output_result.go index f61f973c8fe..e1e7abc17e6 100644 --- a/internal/output/output_result.go +++ b/internal/output/output_result.go @@ -427,11 +427,15 @@ func processVulnGroups(vulnPkg models.PackageVulns) (map[string]VulnResult, map[ slices.SortFunc(group.Aliases, identifiers.IDSortFunc) representID := group.IDs[0] + aliases := group.Aliases + if len(group.Aliases) > 0 && group.Aliases[0] == representID { + aliases = aliases[1:] + } vuln := VulnResult{ ID: representID, GroupIDs: group.IDs, - Aliases: group.Aliases, + Aliases: aliases, } vuln.SeverityScore = group.MaxSeverity diff --git a/internal/output/table.go b/internal/output/table.go index 40e3d0fa32e..e61b26d9910 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -100,6 +100,8 @@ func tableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResu } func printContainerScanningResult(result Result, outputWriter io.Writer, terminalWidth int) { + // Add a newline to separate results from logs. + fmt.Fprintln(outputWriter) fmt.Fprintf(outputWriter, "Container Scanning Result (%s):\n", result.ImageInfo.OS) summary := fmt.Sprintf( "Total %[1]d packages affected by %[2]d vulnerabilities (%[3]d Critical, %[4]d High, %[5]d Medium, %[6]d Low, %[7]d Unknown) from %[8]d ecosystems.\n"+ @@ -182,9 +184,9 @@ func printContainerScanningResult(result Result, outputWriter io.Writer, termina fmt.Fprintln(outputWriter) const promptMessage = "For the most comprehensive scan results, we recommend using the HTML output: " + - "`osv-scanner --format html --output results.html`.\n" + + "`osv-scanner scan image --serve `.\n" + "You can also view the full vulnerability list in your terminal with: " + - "`osv-scanner --format vertical`." + "`osv-scanner scan image --format vertical `." fmt.Fprintln(outputWriter, promptMessage) } diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go index 177ee51a115..1f03c83aa63 100644 --- a/pkg/osvscanner/osvscanner.go +++ b/pkg/osvscanner/osvscanner.go @@ -42,6 +42,7 @@ type ScannerActions struct { SkipGit bool NoIgnore bool Image string + IsImageArchive bool ConfigOverridePath string CallAnalysisStates map[string]bool @@ -54,7 +55,6 @@ type ExperimentalScannerActions struct { ShowAllPackages bool ScanLicensesSummary bool ScanLicensesAllowlist []string - ScanOCIImage string LocalDBPath string TransitiveScanningActions @@ -133,7 +133,7 @@ func initializeExternalAccessors(r reporter.Reporter, actions ScannerActions) (E } // --- Base Image Matcher --- - if actions.Image != "" || actions.ScanOCIImage != "" { + if actions.Image != "" { externalAccessors.BaseImageMatcher = &baseimagematcher.DepsDevBaseImageMatcher{ HTTPClient: *http.DefaultClient, Config: baseimagematcher.DefaultConfig(), @@ -288,24 +288,10 @@ func DoContainerScan(actions ScannerActions, r reporter.Reporter) (models.Vulner // --- Initialize Image To Scan ---' - getLocalPathOrEmpty := func() string { - if actions.ScanOCIImage != "" { - return actions.ScanOCIImage - } - - if strings.Contains(actions.Image, ".tar") { - if _, err := os.Stat(actions.Image); err == nil { - return actions.Image - } - } - - return "" - } - var img *image.Image - if localPath := getLocalPathOrEmpty(); localPath != "" { - r.Infof("Scanning local image tarball %q\n", localPath) - img, err = image.FromTarball(localPath, image.DefaultConfig()) + if actions.IsImageArchive { + r.Infof("Scanning local image tarball %q\n", actions.Image) + img, err = image.FromTarball(actions.Image, image.DefaultConfig()) } else if actions.Image != "" { path, exportErr := imagehelpers.ExportDockerImage(r, actions.Image) if exportErr != nil { diff --git a/pkg/reporter/format.go b/pkg/reporter/format.go index f502077e061..2178b0738c6 100644 --- a/pkg/reporter/format.go +++ b/pkg/reporter/format.go @@ -7,7 +7,7 @@ import ( "github.com/google/osv-scanner/pkg/models" ) -var format = []string{"table", "vertical", "json", "markdown", "sarif", "gh-annotations", "cyclonedx-1-4", "cyclonedx-1-5"} +var format = []string{"table", "html", "vertical", "json", "markdown", "sarif", "gh-annotations", "cyclonedx-1-4", "cyclonedx-1-5"} func Format() []string { return format