From e228d9fff59f4f567f21f436658a9071bb896819 Mon Sep 17 00:00:00 2001 From: Dan Luhring Date: Mon, 15 Jan 2024 14:56:55 -0500 Subject: [PATCH] feat: new command "scan-source" Signed-off-by: Dan Luhring --- pkg/cli/commands.go | 1 + pkg/cli/scan-source.go | 66 ++++++++++++++++++++ pkg/git/git.go | 59 ++++++++++++++++++ pkg/scan/govulncheck.go | 57 +++++++++++++++++ pkg/scan/source.go | 131 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 314 insertions(+) create mode 100644 pkg/cli/scan-source.go create mode 100644 pkg/scan/source.go diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 5e283b2e..83850b7e 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -27,6 +27,7 @@ func New() *cobra.Command { cmdLs(), cmdSBOM(), cmdScan(), + cmdScanSource(), cmdUpdate(), cmdVEX(), cmdWithdraw(), diff --git a/pkg/cli/scan-source.go b/pkg/cli/scan-source.go new file mode 100644 index 00000000..5aaf0b06 --- /dev/null +++ b/pkg/cli/scan-source.go @@ -0,0 +1,66 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wolfi-dev/wolfictl/pkg/configs/build" + rwos "github.com/wolfi-dev/wolfictl/pkg/configs/rwfs/os" + "github.com/wolfi-dev/wolfictl/pkg/scan" +) + +func cmdScanSource() *cobra.Command { + p := &scanSourceParams{} + cmd := &cobra.Command{ + Use: "scan-source", + Short: "Scan a package's source code for vulnerabilities", + Args: cobra.MinimumNArgs(1), + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + logger := newLogger(p.verbosity) + + distroDir := p.distroDir + if distroDir == "" { + distroDir = "." + } + + fsys := rwos.DirFS(distroDir) + index, err := build.NewIndex(fsys) + if err != nil { + return fmt.Errorf("failed to create index of package configurations: %w", err) + } + + first, err := index.Select().WhereName(args[0]).First() + if err != nil { + return fmt.Errorf("failed to find configuration for package %q: %w", args[0], err) + } + cfg := first.Configuration() + + logger.Info("scanning source code used in melange configuration", "package", cfg.Name()) + + scanResults, err := scan.Sources(cmd.Context(), logger, cfg) + if err != nil { + return fmt.Errorf("failed to scan sources for %q: %w", cfg.Name(), err) + } + + for _, r := range scanResults { + fmt.Print(r) + } + + return nil + }, + } + + p.addFlagsTo(cmd) + return cmd +} + +type scanSourceParams struct { + distroDir string + verbosity int +} + +func (p *scanSourceParams) addFlagsTo(cmd *cobra.Command) { + addDistroDirFlag(&p.distroDir, cmd) + addVerboseFlag(&p.verbosity, cmd) +} diff --git a/pkg/git/git.go b/pkg/git/git.go index 934632f9..05418cfd 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -205,6 +205,65 @@ func TempClone(gitURL, hash string, useAuth bool) (repoDir string, err error) { return dir, nil } +// TempCloneTag is like TempClone, but clones the repo at the provided tag. +func TempCloneTag(gitURL, tag string, useAuth bool) (repoDir string, err error) { + if tag == "" { + return "", fmt.Errorf("tag must be provided") + } + + dir, err := os.MkdirTemp("", "wolfictl-git-clone-*") + if err != nil { + return dir, fmt.Errorf("unable to create temp directory for git clone: %w", err) + } + + var auth transport.AuthMethod + if useAuth { + auth = GetGitAuth() + } + + repo, err := git.PlainClone(dir, false, &git.CloneOptions{ + Auth: auth, + URL: gitURL, + }) + if err != nil { + return dir, fmt.Errorf("unable to clone repo %q to temp directory: %w", gitURL, err) + } + + tags, err := repo.Tags() + if err != nil { + return "", fmt.Errorf("failed to get tags: %w", err) + } + + var tagRef *plumbing.Reference + err = tags.ForEach(func(ref *plumbing.Reference) error { + if ref.Name().Short() == tag { // replace with your tag + tagRef = ref + return storer.ErrStop + } + return nil + }) + if err != nil { + return "", fmt.Errorf("unable to find tag %q for repo %q: %w", tag, gitURL, err) + } + + if tagRef == nil { + return "", fmt.Errorf("tag %q not found", tag) + } + + w, err := repo.Worktree() + if err != nil { + return "", fmt.Errorf("unable to get worktree for repo %q: %w", gitURL, err) + } + err = w.Checkout(&git.CheckoutOptions{ + Branch: tagRef.Name(), + }) + if err != nil { + return "", fmt.Errorf("unable to checkout tag %q for repo %q: %w", tag, gitURL, err) + } + + return dir, nil +} + // FindForkPoint finds the fork point between the local branch and the upstream // branch. // diff --git a/pkg/scan/govulncheck.go b/pkg/scan/govulncheck.go index 2ed75624..9b4b153f 100644 --- a/pkg/scan/govulncheck.go +++ b/pkg/scan/govulncheck.go @@ -8,8 +8,10 @@ import ( "net/http" "time" + "golang.org/x/tools/go/packages" "golang.org/x/vuln/pkg/client" "golang.org/x/vuln/pkg/govulncheck" + "golang.org/x/vuln/pkg/osv" vulnscan "golang.org/x/vuln/pkg/scan" "golang.org/x/vuln/pkg/vulncheck" ) @@ -41,6 +43,61 @@ func runGovulncheck(ctx context.Context, exe io.ReaderAt) (*vulncheck.Result, er return result, nil } +func runGovulncheckSource(ctx context.Context, dir string) (*vulncheck.Result, error) { + c, err := client.NewClient(govulncheckDB, nil) + if err != nil { + return nil, fmt.Errorf("creating DB client: %w", err) + } + + cfg := &govulncheck.Config{ + ScanLevel: "symbol", + } + + patterns := []string{"./..."} + + graph := vulncheck.NewPackageGraph("") + pkgConfig := &packages.Config{ + Dir: dir, + Tests: false, // Don't scan tests. We can revisit this later if needed. + Env: nil, // TODO: This will use the current environment, but we should probably make sure to use the build environment. + } + + var pkgs []*packages.Package + pkgs, err = graph.LoadPackages(pkgConfig, nil, patterns) + if err != nil { + return nil, fmt.Errorf("govulncheck: loading packages: %w", err) + } + + result, err := vulncheck.Source(ctx, nopVulncheckHandler{}, pkgs, cfg, c, graph) + if err != nil { + return nil, err + } + + result.Vulns = vulnscan.UniqueVulns(result.Vulns) + + return result, nil +} + +var _ govulncheck.Handler = (*nopVulncheckHandler)(nil) + +type nopVulncheckHandler struct{} + +func (g nopVulncheckHandler) Config(_ *govulncheck.Config) error { + return nil +} + +func (g nopVulncheckHandler) Progress(_ *govulncheck.Progress) error { + return nil +} + +func (g nopVulncheckHandler) OSV(_ *osv.Entry) error { + return nil +} + +func (g nopVulncheckHandler) Finding(_ *govulncheck.Finding) error { + return nil +} + type GoVulnDBIndex struct { index map[string]GoVulnDBIndexEntry } diff --git a/pkg/scan/source.go b/pkg/scan/source.go new file mode 100644 index 00000000..606fdabd --- /dev/null +++ b/pkg/scan/source.go @@ -0,0 +1,131 @@ +package scan + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "chainguard.dev/melange/pkg/config" + "github.com/wolfi-dev/wolfictl/pkg/git" + "golang.org/x/vuln/pkg/vulncheck" +) + +func Sources(ctx context.Context, logger *slog.Logger, cfg *config.Configuration) ([]SourceResult, error) { + // TODO: handle "fetch" pipelines + + const gitCheckoutPipelineName = "git-checkout" + + var targets []gitCloneTarget + for i := range cfg.Pipeline { + step := cfg.Pipeline[i] + + if step.Uses != gitCheckoutPipelineName { + continue + } + + // TODO: Ideally there's an elegant way to do this by leaning on Melange's code, + // but that code is pretty messy at the moment. I would've expected these + // substitutions that don't depend on runtime information to be done at parse + // time, but they're not. + step.With["tag"] = strings.ReplaceAll(step.With["tag"], config.SubstitutionPackageVersion, cfg.Package.Version) + + t := gitCloneTarget{ + url: step.With["repository"], + tag: step.With["tag"], + } + targets = append(targets, t) + + logger.Debug( + "found git checkout step", + "stepIndex", + i, + "repository", + t.url, + "tag", + t.tag, + ) + } + + logger.Debug("finished finding git checkout steps", "total", len(targets)) + + var results []SourceResult + for _, t := range targets { + r, err := t.cloneAndRunGovulncheckSourceScan(ctx, logger) + if err != nil { + return nil, err + } + + results = append(results, SourceResult{ + Name: cfg.Name(), + GitRepository: t, + VulncheckResult: r, + }) + } + + return results, nil +} + +type gitCloneTarget struct { + url string + tag string +} + +func (t gitCloneTarget) cloneAndRunGovulncheckSourceScan(ctx context.Context, logger *slog.Logger) (*vulncheck.Result, error) { + logger.Debug("beginning git clone", "url", t.url, "tag", t.tag) + tempDir, err := git.TempCloneTag(t.url, t.tag, false) + defer os.RemoveAll(tempDir) + if err != nil { + return nil, fmt.Errorf("unable to clone repo %q: %w", t.url, err) + } + logger.Debug("finished git clone", "url", t.url, "tag", t.tag) + + logger.Debug("beginning govulncheck source scan", "repo", t.url, "tag", t.tag, "tempDir", tempDir) + result, err := runGovulncheckSource(ctx, tempDir) + if err != nil { + return nil, fmt.Errorf("unable to run govulncheck on %q: %w", t.url, err) + } + logger.Debug("finished govulncheck source scan", "target", t) + + return result, nil +} + +// SourceResult is the result of scanning a package's source code for +// vulnerabilities using govulncheck. +type SourceResult struct { + // The Name of the package described in the Melange configuration. + Name string + + // GitRepository describes the repository that was scanned. + GitRepository gitCloneTarget + + // VulncheckResult is the result of running govulncheck on the repository. + VulncheckResult *vulncheck.Result +} + +// String returns a human-readable representation of the result. +func (r SourceResult) String() string { + s := fmt.Sprintf( + "%s %s %s\n", + r.Name, + r.GitRepository.url, + r.GitRepository.tag, + ) + + if len(r.VulncheckResult.Vulns) == 0 { + s += " No vulnerabilities found\n" + return s + } + + for _, v := range r.VulncheckResult.Vulns { + s += fmt.Sprintf( + " %s %s %s\n", + v.OSV.ID, + v.ImportSink.String(), + v.Symbol, + ) + } + + return s +}