Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cmd): add source and image subcommands to scan #1519

Merged
merged 21 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
469 changes: 204 additions & 265 deletions cmd/osv-scanner/__snapshots__/main_test.snap

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package scan
package helper

var stableCallAnalysisStates = map[string]bool{
"go": true,
"rust": false,
}

// 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package scan
package helper

import (
"reflect"
Expand Down Expand Up @@ -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)
Expand Down
145 changes: 145 additions & 0 deletions cmd/osv-scanner/internal/helper/helper.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
84 changes: 78 additions & 6 deletions cmd/osv-scanner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <source_directory>

# Scan a container image
$ {{.Name}} scan image <image_name>

# Scan a local image archive (e.g. a tar file) and generate HTML output
$ {{.Name}} scan image --serve --archive <image_name.tar>

# Fix vulnerabilities in a manifest file and lockfile (non-interactive mode)
$ {{.Name}} fix --non-interactive -M <manifest_file> -L <lockfile>

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
Expand All @@ -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
Expand Down
Loading
Loading