hub/internal/scanner/scanner.go

219 lines
6.4 KiB
Go

package scanner
import (
"bytes"
"context"
"crypto/sha512"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strings"
trivyreport "github.com/aquasecurity/trivy/pkg/report"
"github.com/artifacthub/hub/internal/hub"
"github.com/google/go-containerregistry/pkg/name"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
var (
// ErrImageNotFound indicates that the image provided was not found in the
// registry.
ErrImageNotFound = errors.New("image not found")
// ErrSchemaV1NotSupported indicates that the image provided is using a v1
// schema which is not supported.
ErrSchemaV1NotSupported = errors.New("schema v1 manifest not supported by trivy")
)
// ImageScanner describes the methods an ImageScanner implementation must
// provide. An image scanner is responsible of scanning a container image for
// security vulnerabilities.
type ImageScanner interface {
// ScanImage scans the provided image for security vulnerabilities,
// returning a report in json format.
ScanImage(image string) ([]byte, error)
}
// Scanner is in charge of scanning packages' snapshots for security
// vulnerabilities. It relies on an image scanner to scan all the containers
// images listed on the snapshot.
type Scanner struct {
is ImageScanner
ec hub.ErrorsCollector
}
// New creates a new Scanner instance.
func New(
ctx context.Context,
cfg *viper.Viper,
ec hub.ErrorsCollector,
opts ...func(s *Scanner),
) *Scanner {
if cfg.GetString("scanner.trivyURL") == "" {
log.Fatal().Msg("trivy url not set")
}
s := &Scanner{
is: &TrivyScanner{
ctx: ctx,
cfg: cfg,
},
ec: ec,
}
for _, o := range opts {
o(s)
}
return s
}
// WithImageScanner allows providing a specific ImageScanner implementation for
// a Scanner instance.
func WithImageScanner(is ImageScanner) func(s *Scanner) {
return func(s *Scanner) {
s.is = is
}
}
// Scan scans the provided package's snapshot for security vulnerabilities
// returning a report with the results.
func (s *Scanner) Scan(sn *hub.SnapshotToScan) (*hub.SnapshotSecurityReport, error) {
s.ec.Init(sn.RepositoryID)
report := &hub.SnapshotSecurityReport{
PackageID: sn.PackageID,
Version: sn.Version,
}
imagesReports := make(map[string]*trivyreport.Report)
for _, image := range sn.ContainersImages {
imageReportJSON, err := s.is.ScanImage(image.Image)
if err != nil {
err := fmt.Errorf("error scanning image %s: %w (package %s:%s)", image.Image, err, sn.PackageName, sn.Version)
s.ec.Append(sn.RepositoryID, err.Error())
return report, err
}
var imageReport *trivyreport.Report
if err := json.Unmarshal(imageReportJSON, &imageReport); err != nil {
return report, fmt.Errorf("error unmarshalling image %s report: %w", image.Image, err)
}
if imageReport != nil && len(imageReport.Results) > 0 {
imagesReports[image.Image] = imageReport
}
}
if len(imagesReports) > 0 {
report.ImagesReports = imagesReports
report.Summary = generateSummary(imagesReports)
report.AlertDigest = generateAlertDigest(imagesReports)
}
return report, nil
}
// generateSummary generates a summary of the security report from the images
// reports.
func generateSummary(imagesReports map[string]*trivyreport.Report) *hub.SecurityReportSummary {
summary := &hub.SecurityReportSummary{}
for _, imageReport := range imagesReports {
for _, result := range imageReport.Results {
for _, vulnerability := range result.Vulnerabilities {
switch vulnerability.Severity {
case "CRITICAL":
summary.Critical++
case "HIGH":
summary.High++
case "MEDIUM":
summary.Medium++
case "LOW":
summary.Low++
case "UNKNOWN":
summary.Unknown++
}
}
}
}
return summary
}
// generateAlertDigest generates an alert digest of the security report from
// the images reports. At the moment the digest is based on the vulnerabilities
// with a severity of high or critical.
func generateAlertDigest(imagesReports map[string]*trivyreport.Report) string {
var vs []string
for _, imageReport := range imagesReports {
for _, result := range imageReport.Results {
for _, v := range result.Vulnerabilities {
if v.Severity == "HIGH" || v.Severity == "CRITICAL" {
vs = append(vs, fmt.Sprintf("[%s:%s]", v.Severity, v.VulnerabilityID))
}
}
}
}
var digest string
if len(vs) > 0 {
sort.Strings(vs)
digest = fmt.Sprintf("%x", sha512.Sum512([]byte(strings.Join(vs, ""))))
}
return digest
}
// TrivyScanner is an ImageScanner implementation that uses Trivy to scan
// containers images for security vulnerabilities.
type TrivyScanner struct {
ctx context.Context
cfg *viper.Viper
}
// ScanImage implements the ImageScanner interface.
func (s *TrivyScanner) ScanImage(image string) ([]byte, error) {
// Setup trivy command
trivyURL := s.cfg.GetString("scanner.trivyURL")
cmd := exec.CommandContext(s.ctx, "trivy", "--quiet", "client", "--remote", trivyURL, "-f", "json", image) // #nosec
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"),
"USER=" + os.Getenv("USER"),
"HOME=" + os.Getenv("HOME"),
"TRIVY_CACHE_DIR=" + os.Getenv("TRIVY_CACHE_DIR"),
"TRIVY_NEW_JSON_SCHEMA=true", // Not needed in Trivy >= 0.20.0
}
// If the registry is the Docker Hub, include credentials to avoid rate
// limiting issues. Empty registry names will also match this check as the
// registry name will be set to index.docker.io when parsing the reference.
ref, err := name.ParseReference(image)
if err != nil {
return nil, fmt.Errorf("error parsing image %s ref: %w", image, err)
}
if strings.HasSuffix(ref.Context().Registry.Name(), "docker.io") {
cmd.Env = append(cmd.Env,
"TRIVY_USERNAME="+s.cfg.GetString("creds.dockerUsername"),
"TRIVY_PASSWORD="+s.cfg.GetString("creds.dockerPassword"),
)
}
// Run trivy command
if err := cmd.Run(); err != nil {
if strings.Contains(stderr.String(), "MANIFEST_UNKNOWN") {
return nil, ErrImageNotFound
}
if strings.Contains(stderr.String(), "UNAUTHORIZED") {
return nil, ErrImageNotFound
}
if strings.Contains(stderr.String(), `unsupported MediaType: "application/vnd.docker.distribution.manifest.v1+prettyjws"`) {
return nil, ErrSchemaV1NotSupported
}
trivyError := stderr.String()
parts := strings.Split(stderr.String(), "podman/podman.sock: no such file or directory")
if len(parts) > 1 {
trivyError = strings.TrimSpace(parts[1])
}
return nil, fmt.Errorf("error running trivy on image %s: %s", image, trivyError)
}
return stdout.Bytes(), nil
}