Skip to content

Commit 0e88d4f

Browse files
authored
feat(cmd): add source and image subcommands to scan (#1519)
**This change does not break any existing behaviour.** - Creates `source` and `image` subcommands for `scan`. - Inserts `source` as the default subcommand if none is provided. - Removes the `experimental-oci-image` flag and its tests. - Adds a feedback link to HTML output For project scanning, users can use the following commands: - `osv-scanner <file_name>` - `osv-scanner scan <file_name>` - `osv-scanner scan source <file_name>` For docker scanning, users can use the following commands: - `osv-scanner scan image <docker_image>` - `osv-scanner scan image --archive <docker_image.tar>` Help command: ``` NAME: osv-scanner - scans various mediums for dependencies and checks them against the OSV database USAGE: osv-scanner [global options] command [command options] EXAMPLES: # Scan a source directory $ osv-scanner scan source -r <source_directory> # Scan a container image $ osv-scanner scan image <image_name> # Scan a local image archive (e.g. a tar file) and generate HTML output $ osv-scanner scan image --serve --archive <image_name.tar> # Fix vulnerabilities in a manifest file and lockfile (non-interactive mode) $ osv-scanner fix --non-interactive -M <manifest_file> -L <lockfile> For full usage details, please refer to the help command of each subcommand (e.g. osv-scanner scan --help). VERSION: 1.9.1 COMMANDS: scan scans projects and container images for dependencies, and checks them against the OSV database. fix [EXPERIMENTAL] scans a manifest and/or lockfile for vulnerabilities and suggests changes for remediating them help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --help, -h show help --version, -v print the version ```
1 parent 0b7a102 commit 0e88d4f

File tree

19 files changed

+687
-760
lines changed

19 files changed

+687
-760
lines changed

Diff for: cmd/osv-scanner/__snapshots__/main_test.snap

+33-265
Large diffs are not rendered by default.

Diff for: cmd/osv-scanner/scan/callanalysis_parser.go renamed to cmd/osv-scanner/internal/helper/callanalysis_parser.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
package scan
1+
package helper
22

33
var stableCallAnalysisStates = map[string]bool{
44
"go": true,
55
"rust": false,
66
}
77

88
// Creates a map to record if languages are enabled or disabled for call analysis.
9-
func createCallAnalysisStates(enabledCallAnalysis []string, disabledCallAnalysis []string) map[string]bool {
9+
func CreateCallAnalysisStates(enabledCallAnalysis []string, disabledCallAnalysis []string) map[string]bool {
1010
callAnalysisStates := make(map[string]bool)
1111

1212
for _, language := range enabledCallAnalysis {

Diff for: cmd/osv-scanner/scan/callanalysis_parser_test.go renamed to cmd/osv-scanner/internal/helper/callanalysis_parser_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package scan
1+
package helper
22

33
import (
44
"reflect"
@@ -55,7 +55,7 @@ func TestCreateCallAnalysisStates(t *testing.T) {
5555
}
5656

5757
for _, testCase := range testCases {
58-
actualCallAnalysisStates := createCallAnalysisStates(testCase.enabledCallAnalysis, testCase.disabledCallAnalysis)
58+
actualCallAnalysisStates := CreateCallAnalysisStates(testCase.enabledCallAnalysis, testCase.disabledCallAnalysis)
5959

6060
if !reflect.DeepEqual(actualCallAnalysisStates, testCase.expectedCallAnalysisStates) {
6161
t.Errorf("expected call analysis states to be %v, but got %v", testCase.expectedCallAnalysisStates, actualCallAnalysisStates)

Diff for: cmd/osv-scanner/internal/helper/helper.go

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package helper
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"os/exec"
7+
"runtime"
8+
"slices"
9+
"strings"
10+
"time"
11+
12+
"github.com/google/osv-scanner/pkg/reporter"
13+
"github.com/urfave/cli/v2"
14+
)
15+
16+
// flags that require network access and values to disable them.
17+
var OfflineFlags = map[string]string{
18+
"skip-git": "true",
19+
"experimental-offline-vulnerabilities": "true",
20+
"experimental-no-resolve": "true",
21+
"experimental-licenses-summary": "false",
22+
// "experimental-licenses": "", // StringSliceFlag has to be manually cleared.
23+
}
24+
25+
var GlobalScanFlags = []cli.Flag{
26+
&cli.StringFlag{
27+
Name: "config",
28+
Usage: "set/override config file",
29+
TakesFile: true,
30+
},
31+
&cli.StringFlag{
32+
Name: "format",
33+
Aliases: []string{"f"},
34+
Usage: "sets the output format; value can be: " + strings.Join(reporter.Format(), ", "),
35+
Value: "table",
36+
Action: func(_ *cli.Context, s string) error {
37+
if slices.Contains(reporter.Format(), s) {
38+
return nil
39+
}
40+
41+
return fmt.Errorf("unsupported output format \"%s\" - must be one of: %s", s, strings.Join(reporter.Format(), ", "))
42+
},
43+
},
44+
&cli.BoolFlag{
45+
Name: "serve",
46+
Usage: "output as HTML result and serve it locally",
47+
},
48+
&cli.StringFlag{
49+
Name: "output",
50+
Usage: "saves the result to the given file path",
51+
TakesFile: true,
52+
},
53+
&cli.StringFlag{
54+
Name: "verbosity",
55+
Usage: "specify the level of information that should be provided during runtime; value can be: " + strings.Join(reporter.VerbosityLevels(), ", "),
56+
Value: "info",
57+
},
58+
&cli.BoolFlag{
59+
Name: "experimental-offline",
60+
Usage: "run in offline mode, disabling any features requiring network access",
61+
Action: func(ctx *cli.Context, b bool) error {
62+
if !b {
63+
return nil
64+
}
65+
// Disable the features requiring network access.
66+
for flag, value := range OfflineFlags {
67+
// TODO(michaelkedar): do something if the flag was already explicitly set.
68+
if err := ctx.Set(flag, value); err != nil {
69+
panic(fmt.Sprintf("failed setting offline flag %s to %s: %v", flag, value, err))
70+
}
71+
}
72+
73+
return nil
74+
},
75+
},
76+
&cli.BoolFlag{
77+
Name: "experimental-offline-vulnerabilities",
78+
Usage: "checks for vulnerabilities using local databases that are already cached",
79+
},
80+
&cli.BoolFlag{
81+
Name: "experimental-download-offline-databases",
82+
Usage: "downloads vulnerability databases for offline comparison",
83+
},
84+
&cli.BoolFlag{
85+
Name: "experimental-no-resolve",
86+
Usage: "disable transitive dependency resolution of manifest files",
87+
},
88+
&cli.StringFlag{
89+
Name: "experimental-local-db-path",
90+
Usage: "sets the path that local databases should be stored",
91+
Hidden: true,
92+
},
93+
&cli.BoolFlag{
94+
Name: "experimental-all-packages",
95+
Usage: "when json output is selected, prints all packages",
96+
},
97+
&cli.BoolFlag{
98+
Name: "experimental-licenses-summary",
99+
Usage: "report a license summary, implying the --experimental-all-packages flag",
100+
},
101+
&cli.StringSliceFlag{
102+
Name: "experimental-licenses",
103+
Usage: "report on licenses based on an allowlist",
104+
},
105+
}
106+
107+
// openHTML opens the outputted HTML file.
108+
func OpenHTML(r reporter.Reporter, outputPath string) {
109+
// Open the outputted HTML file in the default browser.
110+
r.Infof("Opening %s...\n", outputPath)
111+
var err error
112+
switch runtime.GOOS {
113+
case "linux":
114+
err = exec.Command("xdg-open", outputPath).Start()
115+
case "windows":
116+
err = exec.Command("start", "", outputPath).Start()
117+
case "darwin": // macOS
118+
err = exec.Command("open", outputPath).Start()
119+
default:
120+
r.Infof("Unsupported OS.\n")
121+
}
122+
123+
if err != nil {
124+
r.Errorf("Failed to open: %s.\n Please manually open the outputted HTML file: %s\n", err, outputPath)
125+
}
126+
}
127+
128+
// ServeHTML serves the single HTML file for remote accessing.
129+
// The program will keep running to serve the HTML report on localhost
130+
// until the user manually terminates it (e.g. using Ctrl+C).
131+
func ServeHTML(r reporter.Reporter, outputPath string) {
132+
servePort := "8000"
133+
localhostURL := fmt.Sprintf("http://localhost:%s/", servePort)
134+
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)
135+
server := &http.Server{
136+
Addr: ":" + servePort,
137+
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138+
http.ServeFile(w, r, outputPath)
139+
}),
140+
ReadHeaderTimeout: 3 * time.Second,
141+
}
142+
if err := server.ListenAndServe(); err != nil {
143+
r.Errorf("Failed to start server: %v\n", err)
144+
}
145+
}

Diff for: cmd/osv-scanner/main.go

+78-6
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func run(args []string, stdout, stderr io.Writer) int {
4242
fix.Command(stdout, stderr, &r),
4343
update.Command(stdout, stderr, &r),
4444
},
45+
CustomAppHelpTemplate: getCustomHelpTemplate(),
4546
}
4647

4748
// 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 {
8485
return 0
8586
}
8687

88+
func getCustomHelpTemplate() string {
89+
return `
90+
NAME:
91+
{{.Name}} - {{.Usage}}
92+
93+
USAGE:
94+
{{.Name}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}}
95+
96+
EXAMPLES:
97+
# Scan a source directory
98+
$ {{.Name}} scan source -r <source_directory>
99+
100+
# Scan a container image
101+
$ {{.Name}} scan image <image_name>
102+
103+
# Scan a local image archive (e.g. a tar file) and generate HTML output
104+
$ {{.Name}} scan image --serve --archive <image_name.tar>
105+
106+
# Fix vulnerabilities in a manifest file and lockfile (non-interactive mode)
107+
$ {{.Name}} fix --non-interactive -M <manifest_file> -L <lockfile>
108+
109+
For full usage details, please refer to the help command of each subcommand (e.g. {{.Name}} scan --help).
110+
111+
VERSION:
112+
{{.Version}}
113+
114+
COMMANDS:
115+
{{range .Commands}}{{if and (not .HideHelp) (not .Hidden)}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}
116+
{{if .VisibleFlags}}
117+
GLOBAL OPTIONS:
118+
{{range .VisibleFlags}} {{.}}{{end}}
119+
{{end}}
120+
`
121+
}
122+
87123
// Gets all valid commands and global options for OSV-Scanner.
88124
func getAllCommands(commands []*cli.Command) []string {
89125
// Adding all subcommands
@@ -108,24 +144,60 @@ func getAllCommands(commands []*cli.Command) []string {
108144
return allCommands
109145
}
110146

147+
// warnIfCommandAmbiguous warns the user if the command they are trying to run
148+
// exists as both a subcommand and as a file on the filesystem.
149+
// If this is the case, the command is assumed to be a subcommand.
150+
func warnIfCommandAmbiguous(command string, stdout, stderr io.Writer) {
151+
if _, err := os.Stat(command); err == nil {
152+
r := reporter.NewJSONReporter(stdout, stderr, reporter.InfoLevel)
153+
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)
154+
}
155+
}
156+
111157
// Inserts the default command to args if no command is specified.
112158
func insertDefaultCommand(args []string, commands []*cli.Command, defaultCommand string, stdout, stderr io.Writer) []string {
159+
// Do nothing if no command or file name is provided.
113160
if len(args) < 2 {
114161
return args
115162
}
116163

117164
allCommands := getAllCommands(commands)
118-
if !slices.Contains(allCommands, args[1]) {
165+
command := args[1]
166+
// If no command is provided, use the default command and subcommand.
167+
if !slices.Contains(allCommands, command) {
119168
// Avoids modifying args in-place, as some unit tests rely on its original value for multiple calls.
120-
argsTmp := make([]string, len(args)+1)
121-
copy(argsTmp[2:], args[1:])
169+
argsTmp := make([]string, len(args)+2)
170+
copy(argsTmp[3:], args[1:])
122171
argsTmp[1] = defaultCommand
172+
// Set the default subCommand of Scan
173+
argsTmp[2] = scan.DefaultSubcommand
123174

124175
// Executes the cli app with the new args.
125176
return argsTmp
126-
} else if _, err := os.Stat(args[1]); err == nil {
127-
r := reporter.NewJSONReporter(stdout, stderr, reporter.InfoLevel)
128-
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])
177+
}
178+
179+
warnIfCommandAmbiguous(command, stdout, stderr)
180+
181+
// If only the default command is provided without its subcommand, append the subcommand.
182+
if command == defaultCommand {
183+
if len(args) < 3 {
184+
// Indicates that only "osv-scanner scan" was provided, without a subcommand or filename
185+
return args
186+
}
187+
188+
subcommand := args[2]
189+
// Default to the "source" subcommand if none is provided.
190+
if !slices.Contains(scan.Subcommands, subcommand) {
191+
argsTmp := make([]string, len(args)+1)
192+
copy(argsTmp[3:], args[2:])
193+
argsTmp[1] = defaultCommand
194+
argsTmp[2] = scan.DefaultSubcommand
195+
196+
return argsTmp
197+
}
198+
199+
// Print a warning message if subcommand exist on the filesystem.
200+
warnIfCommandAmbiguous(subcommand, stdout, stderr)
129201
}
130202

131203
return args

0 commit comments

Comments
 (0)