Skip to content
Open
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
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ $ helm template \

If vulnerability scanning (see below) is performed when the mappings are being
written, the CVEs that exist in an image (but are below the max CVSS score, or
explicitly ignored), are included in the image. This makes it easy to audit
CVEs for a specific image.
explicitly ignored), are included in the image mapping information. This makes
it easy to audit CVEs for a specific image.

The following flags control mappings usage

Expand All @@ -128,29 +128,38 @@ The following flags control mappings usage
```
# Grafeas Vulnerability Checking

Alternatively, reimage can execute any command compatible with trivy's image scanning
JSON output to scan images.
reimage can execute any command compatible with the grype or trivy image scanning JSON
output to scan images.

alternatively trivy can check for Grafeas Discovery occurrences containing CVE checks for
Alternatively trivy can check for Grafeas Discovery occurrences containing CVE checks for
the discovered images. If discovery checking is enabled, but no completed discovery
has occurred, reimage will wait for a configurable time. Vulnerability checking
is disabled by default, and can be enabled by setting `-vulncheck-max-cvss`. If you
want to scan, but ignore all CVEs, use `-vulncheck-max-cvss 11`
has occurred, reimage will wait for a configurable time.

Vulnerability checking is disabled by default, and can be enabled by setting
`-vulncheck-max-cvss`. If you want to scan, but ignore all CVEs, use
`-vulncheck-max-cvss 11`. Since CVSS itself can be difficult to work with, reimage
also allows you to acceptable CVEs by grype's risk score via
`-vulncheck-max-risk` (currently only supported if you are using the grype-json
output format, that may change in future).


```
-grafeas-parent string
value for the parent of the grafeas client (e.g. "project/my-project-id" for GCP
-trivy-command string
the command to run to retrieve vulnerability scans in trivy's JSON format (the image id will be added as an additional arg (default "trivy image -f json")
-vulncheck-command string
the command to run to retrieve vulnerability scans in trivy's JSON format (the image id will be added as an additional arg (default "grype --by-cve -o json")
-vulncheck-format string
the output format of the vulncheck-command (trivy-json,grype-json) (default "grype-json")
-vulncheck-method string
force the vulnerability check method, (trivy or grafeas) (default "trivy")
force the vulnerability check method, (exec or grafeas) (default "exec")
-vulncheck-ignore-cve-list string
comma separated list of vulnerabilities to ignore
-vulncheck-ignore-images string
regexp of images to skip for CVE checks
-vulncheck-max-cvss float
maximum CVSS vulnerabitility score
-vulncheck-max-risk float
maximum grype risk vulnerabitility score
-vulncheck-timeout duration
how long to wait for vulnerability scanning to complete (default 5m0s)
```
Expand Down
221 changes: 178 additions & 43 deletions cmd/reimage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"maps"
"os"
"os/exec"
"path"
"regexp"
"slices"
"strings"
Expand All @@ -26,6 +27,7 @@

containeranalysis "cloud.google.com/go/containeranalysis/apiv1"
kms "cloud.google.com/go/kms/apiv1"
"github.com/Masterminds/sprig/v3"
"github.com/buildkite/shellwords"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
Expand Down Expand Up @@ -72,8 +74,12 @@
VulnCheckFormat string
VulnCheckIgnoreCVESpec *reimage.VulnCheckIgnoreCVESpec
VulnCheckMaxCVSS float64
VulnCheckMaxRisk float64
VulnCheckTimeout time.Duration
VulnCheckMaxRetries int
VulnCheckReportTmpl string
vulncheckReportTmpl *template.Template
VulnCheckReportOutput string
Version bool
VerifyStaticMappings bool
DryRun bool
Expand Down Expand Up @@ -118,8 +124,11 @@
flag.IntVar(&a.VulnCheckMaxRetries, "vulncheck-max-retries", defaultVulcheckRetries, "max number of attempts to check for vulnerabilitie")
flag.Var(a.VulnCheckIgnoreCVESpec, "vulncheck-ignore-cve-list", "comma separated list of vulnerabilities to ignore")
flag.Float64Var(&a.VulnCheckMaxCVSS, "vulncheck-max-cvss", 0.0, "maximum CVSS vulnerabitility score")
flag.Float64Var(&a.VulnCheckMaxRisk, "vulncheck-max-risk", 0.0, "maximum vulnerabitility risk score")
flag.StringVar(&a.VulnCheckIgnoreImages, "vulncheck-ignore-images", "", "regexp of images to skip for CVE checks")
flag.StringVar(&a.VulnCheckMethod, "vulncheck-method", "exec", "force the vulnerability check method, (exec or grafeas)")
flag.StringVar(&a.VulnCheckReportTmpl, "vulncheck-report-template", "", "a file containing a template to render for vulncheck reporting")
flag.StringVar(&a.VulnCheckReportOutput, "vulncheck-report-output", "", "a file to write the vulncheck report to")

flag.StringVar(&a.GrafeasParent, "grafeas-parent", "", "value for the parent of the grafeas client (e.g. \"project/my-project-id\" for GCP")

Expand Down Expand Up @@ -204,6 +213,20 @@
return &a, errors.New("invalid input type, should be k8s or yaml")
}

if a.VulnCheckReportTmpl != "" {
a.vulncheckReportTmpl, err = template.New(path.Base(a.VulnCheckReportTmpl)).
Funcs(sprig.TxtFuncMap()).
ParseFiles(a.VulnCheckReportTmpl)
if err != nil {
return &a, fmt.Errorf("failed parsing vulncheck report template, %w", err)
}
}

if a.vulncheckReportTmpl != nil && a.VulnCheckReportOutput == "" ||
a.vulncheckReportTmpl == nil && a.VulnCheckReportOutput != "" {
return &a, fmt.Errorf("vulncheck reporting requires both the template and output to be set")

Check failure on line 227 in cmd/reimage/main.go

View workflow job for this annotation

GitHub Actions / PR test

error-format: fmt.Errorf can be replaced with errors.New (perfsprint)
}

return &a, nil
}

Expand Down Expand Up @@ -294,6 +317,11 @@
}

func (a *app) writeMappings(ctx context.Context, mappings map[string]reimage.QualifiedImage) (err error) {
if a.WriteMappings == "" && a.WriteMappingsImg == "" {
a.log.DebugContext(ctx, "skipping writing of mappings, no location requested")
return nil
}

bs, err := json.Marshal(mappings)
if err != nil {
return fmt.Errorf("could not marshal mappings, %w", err)
Expand All @@ -304,7 +332,6 @@
return nil
}

a.log.InfoContext(ctx, "writing mappings file", "file", a.WriteMappings)
if a.WriteMappings != "" {
a.log.InfoContext(ctx, "writing mappings file", "file", a.WriteMappings)
err = os.WriteFile(a.WriteMappings, bs, defaultFSMask)
Expand All @@ -314,6 +341,7 @@
}

if a.WriteMappingsImg != "" {
a.log.InfoContext(ctx, "writing mappings image", "file", a.WriteMappingsImg)
cnt := map[string][]byte{
"reimage-mapping.json": bs,
}
Expand Down Expand Up @@ -400,8 +428,8 @@

// checkVulns most of this should move into the main package.
func (a *app) checkVulns(ctx context.Context, imgs map[string]reimage.QualifiedImage) error {
if a.VulnCheckMaxCVSS == 0 {
a.log.InfoContext(ctx, "skipping vulnerability checks (max CVSS is set to 0)")
if a.VulnCheckMaxCVSS == 0 && a.VulnCheckMaxRisk == 0 {
a.log.InfoContext(ctx, "skipping vulnerability checks (max CVSS and risk are set to 0)")
return nil
}

Expand Down Expand Up @@ -441,6 +469,7 @@
Getter: vget,
IgnoreImages: a.vulnCheckIgnoreImages,
MaxCVSS: float32(a.VulnCheckMaxCVSS),
MaxRisk: float32(a.VulnCheckMaxRisk),
CVEIgnoreConfig: a.VulnCheckIgnoreCVESpec,
}

Expand All @@ -458,7 +487,7 @@
a.log.DebugContext(ctx, "start checks on", "img", img.Tag)
ref, err := name.ParseReference(img.Tag)
if err != nil {
errs[i] = fmt.Errorf("could not parse ref %q, %w", img, err)
errs[i] = fmt.Errorf("could not parse ref %q, %w", img.Tag, err)
return
}

Expand All @@ -472,8 +501,9 @@

resLock.Lock()
defer resLock.Unlock()
img.FoundCVEs = cres.Found
img.IgnoredCVEs = cres.Ignored
img.VulnCheckResult.Accepted = cres.Accepted
img.VulnCheckResult.Ignored = cres.Ignored
img.VulnCheckResult.Rejected = cres.Rejected
res[src] = img
}(src, img, i)

Expand Down Expand Up @@ -560,7 +590,7 @@
for _, img := range imgs {
ref, ierr := name.ParseReference(img.Tag)
if ierr != nil {
errs[i] = fmt.Errorf("could not parse ref %q, %w", img, ierr)
errs[i] = fmt.Errorf("could not parse ref %q, %w", img.Tag, ierr)
continue
}

Expand Down Expand Up @@ -598,48 +628,120 @@
return errors.Join(errs...)
}

func logVulnCheckErrs(ctx context.Context, log *slog.Logger, err error) {
if errsI, ok := err.(interface{ Unwrap() []error }); ok {
for _, err := range errsI.Unwrap() {
if err, ok := errors.AsType[*reimage.ImageCheckError](err); ok && err != nil {
for _, cve := range slices.Sorted(maps.Keys(err.CVEs)) {
log.ErrorContext(
ctx,
"Unacceptable vulnerability",
slog.String("image", err.Image),
slog.String("cve", cve),
slog.Float64("cvss", float64(err.CVEs[cve].Score)),
slog.String("cve_desc", err.CVEs[cve].Desc),
)
}
continue
func reportUnusedIgnores(ctx context.Context, log *slog.Logger, unused []string) {

Check failure on line 631 in cmd/reimage/main.go

View workflow job for this annotation

GitHub Actions / PR test

func reportUnusedIgnores is unused (unused)
if len(unused) == 0 {
return
}
log.InfoContext(
ctx,
"unused CVE ignores",
slog.String("cves", strings.Join(unused, ", ")),
)
}

func findUnusedIgnores(spec *reimage.VulnCheckIgnoreCVESpec, mappings map[string]reimage.QualifiedImage) []string {
usedIgnores := map[string]struct{}{}

for _, qi := range mappings {
for _, ii := range qi.VulnCheckResult.Ignored {
usedIgnores[ii.ID] = struct{}{}
}
}

var unusedIgnores []string
for _, id := range spec.AllCVEs() {
if _, ok := usedIgnores[id]; ok {
continue
}
unusedIgnores = append(unusedIgnores, id)
}

return unusedIgnores
}

func (a *app) reportVulnCheckResult(ctx context.Context, mappings map[string]reimage.QualifiedImage) error {
unusedIgnores := findUnusedIgnores(a.VulnCheckIgnoreCVESpec, mappings)

var vcrd = reimage.VulnCheckReportData{

Check failure on line 665 in cmd/reimage/main.go

View workflow job for this annotation

GitHub Actions / PR test

File is not properly formatted (gofumpt)
UnusedIgnores: unusedIgnores,
Mappings: mappings,
Rejections: make(map[string]reimage.VulnCheckReportDataRejection),
}

defer func() {
if len(unusedIgnores) != 0 {
a.log.InfoContext(
ctx,
"some of the CVE ignores were not relevant to any image",
slog.String("cves", strings.Join(unusedIgnores, ", ")),
)
}

if a.VulnCheckReportOutput == "" {
return
}

tout, terr := os.Create(a.VulnCheckReportOutput)
if terr != nil {
a.log.ErrorContext(
ctx,
"failed to open vulncheck report file",
slog.String("err", terr.Error()),
)
return
}
defer tout.Close()

terr = a.vulncheckReportTmpl.Execute(tout, vcrd)
if terr != nil {
a.log.ErrorContext(
ctx,
"failed to render vulncheck report",
slog.String("err", terr.Error()),
)
}
}()

for imgName, img := range mappings {
for _, cve := range img.VulnCheckResult.Rejected {
cved, ok := vcrd.Rejections[cve.ID]
if !ok {
cved.RejectedCVE = cve
}
if err, ok := errors.AsType[*reimage.ExecVulncheckCommandError](err); ok && err != nil {
attrs := []any{
slog.String("err", err.Error()),
slog.String("image", err.Image),
slog.String("cmd", strings.Join(err.Command, " ")),
}
execErr := &exec.ExitError{}
if errors.As(err.Err, &execErr) {
attrs = append(attrs, slog.String("stderr", string(execErr.Stderr)))
}
cved.Images = append(cved.Images, imgName)
vcrd.Rejections[cve.ID] = cved
}
}

log.ErrorContext(
ctx,
"vulncheck exec failed",
attrs...,
)
continue
if len(vcrd.Rejections) > 0 {
for _, id := range slices.Sorted(maps.Keys(vcrd.Rejections)) {
rej := vcrd.Rejections[id]
slices.Sort(rej.Images)
slices.Compact(rej.Images)

Check failure on line 720 in cmd/reimage/main.go

View workflow job for this annotation

GitHub Actions / PR test

unusedresult: result of slices.Compact call not used (govet)
rej.Images = slices.DeleteFunc(rej.Images, func(s string) bool {
return s == ""
})

vcrd.Rejections[id] = rej

args := []any{
slog.String("cve", id),
slog.Float64("cvss", float64(rej.CVSS)),
slog.Float64("risk", float64(rej.Risk)),
slog.String("cve_desc", rej.Desc),
slog.String("images", strings.Join(rej.Images, ",")),
}

log.ErrorContext(ctx, fmt.Errorf("vulncheck failed, %w", err).Error())
a.log.ErrorContext(
ctx,
"Unacceptable vulnerability",
args...,
)
}

return
return errors.New("found unacceptable CVEs")
}

log.ErrorContext(ctx, fmt.Errorf("vulncheck failed, %w", err).Error())
return nil
}

func main() {
Expand Down Expand Up @@ -702,8 +804,41 @@
}

err = app.checkVulns(ctx, mappings)
if err != nil {

Check failure on line 807 in cmd/reimage/main.go

View workflow job for this annotation

GitHub Actions / PR test

`if err != nil` has complex nested blocks (complexity: 7) (nestif)
logVulnCheckErrs(ctx, app.log, err)
// TODO(tcm): should probably factor this out to a func
if errsI, ok := err.(interface{ Unwrap() []error }); ok {
for _, err := range errsI.Unwrap() {
if err, ok := errors.AsType[*reimage.ExecVulncheckCommandError](err); ok && err != nil {
attrs := []any{
slog.String("err", err.Error()),
slog.String("image", err.Image),
slog.String("cmd", strings.Join(err.Command, " ")),
}
execErr := &exec.ExitError{}
if errors.As(err.Err, &execErr) {
attrs = append(attrs, slog.String("stderr", string(execErr.Stderr)))
}

app.log.ErrorContext(
ctx,
"vulncheck exec failed",
attrs...,
)
}
}
} else {
app.log.ErrorContext(
ctx,
"vulncheck failed",
slog.String("err", err.Error()),
)
}

os.Exit(1)
}

err = app.reportVulnCheckResult(ctx, mappings)
if err != nil {
os.Exit(1)
}

Expand Down
Loading
Loading