Skip to content

Commit bf9c614

Browse files
authored
feat: add support for diffing existing scan results (#1274)
Signed-off-by: egibs <[email protected]>
1 parent f4d1c94 commit bf9c614

File tree

6 files changed

+241
-48
lines changed

6 files changed

+241
-48
lines changed

cmd/mal/mal.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ var (
5050
allFlag bool
5151
concurrencyFlag int
5252
diffImageFlag bool
53+
diffReportFlag bool
5354
exitExtractionFlag bool
5455
exitFirstHitFlag bool
5556
exitFirstMissFlag bool
@@ -491,6 +492,13 @@ func main() {
491492
Usage: "Scan an image",
492493
Destination: &diffImageFlag,
493494
},
495+
&cli.BoolFlag{
496+
Name: "report",
497+
Aliases: []string{"r"},
498+
Value: false,
499+
Usage: "Diff existing analyze/scan reports",
500+
Destination: &diffReportFlag,
501+
},
494502
&cli.BoolFlag{
495503
Name: "score-all",
496504
Value: false,
@@ -512,6 +520,9 @@ func main() {
512520
if c.Bool("image") {
513521
mc.OCI = true
514522
}
523+
if c.Bool("report") {
524+
mc.Report = true
525+
}
515526

516527
res, err = action.Diff(ctx, mc, log)
517528
if err != nil {

pkg/action/diff.go

Lines changed: 111 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package action
66
import (
77
"context"
88
"fmt"
9+
"io"
910
"log/slog"
1011
"maps"
1112
"os"
@@ -20,6 +21,7 @@ import (
2021
"github.com/chainguard-dev/malcontent/pkg/archive"
2122
"github.com/chainguard-dev/malcontent/pkg/malcontent"
2223
"github.com/chainguard-dev/malcontent/pkg/programkind"
24+
"github.com/chainguard-dev/malcontent/pkg/report"
2325
orderedmap "github.com/wk8/go-ordered-map/v2"
2426
"golang.org/x/sync/errgroup"
2527
)
@@ -234,8 +236,9 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent
234236
// If diffing images, use their temporary directories as scan paths
235237
// Flip c.OCI to false when finished to block other image code paths
236238
var (
237-
err error
238-
isImage bool
239+
err error
240+
isImage bool
241+
isReport bool
239242
)
240243

241244
if c.OCI {
@@ -250,44 +253,86 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent
250253
isImage, c.OCI = true, false
251254
}
252255

253-
var g errgroup.Group
254-
255256
srcCh, destCh := make(chan ScanResult, 1), make(chan ScanResult, 1)
256-
257257
srcIsArchive, destIsArchive := programkind.IsSupportedArchive(ctx, srcPath), programkind.IsSupportedArchive(ctx, destPath)
258+
srcResult, destResult := ScanResult{}, ScanResult{}
259+
260+
// If diffing existing reports, we just need to unmarshal them into a ScanResult and run the diff
261+
// Only JSON or YAML reports are supported, however
262+
switch c.Report {
263+
case true:
264+
isReport = true
265+
srcFile, err := os.Open(srcPath)
266+
if err != nil {
267+
return nil, err
268+
}
269+
defer srcFile.Close()
270+
src, err := io.ReadAll(srcFile)
271+
if err != nil {
272+
return nil, err
273+
}
274+
srcFiles, err := report.Load(src)
275+
srcResult.err = err
276+
srcResult.files = srcFiles.FileReports
258277

259-
g.Go(func() error {
260-
files, base, err := relFileReport(ctx, c, srcPath, isImage)
261-
res := ScanResult{files: files, base: base, err: err}
262-
if isImage {
263-
res.imageURI, res.tmpRoot = c.ScanPaths[0], srcPath
278+
// Extract image URI and temp root from the report's file paths
279+
srcResult.imageURI = report.ExtractImageURI(srcResult.files)
280+
srcResult.tmpRoot = report.ExtractTmpRoot(srcResult.files)
281+
srcResult.base = filepath.Base(srcPath)
282+
283+
destFile, err := os.Open(destPath)
284+
if err != nil {
285+
return nil, err
286+
}
287+
defer destFile.Close()
288+
dst, err := io.ReadAll(destFile)
289+
if err != nil {
290+
return nil, err
264291
}
265-
srcCh <- res
266-
return err
267-
})
268292

269-
srcResult := <-srcCh
270-
if srcResult.err != nil {
271-
return nil, fmt.Errorf("source scan error: %w", srcResult.err)
272-
}
293+
destFiles, err := report.Load(dst)
294+
destResult.err = err
295+
destResult.files = destFiles.FileReports
273296

274-
g.Go(func() error {
275-
files, base, err := relFileReport(ctx, c, destPath, isImage)
276-
res := ScanResult{files: files, base: base, err: err}
277-
if isImage {
278-
res.imageURI, res.tmpRoot = c.ScanPaths[1], destPath
297+
// Extract image URI and temp root from the report's file paths
298+
destResult.imageURI = report.ExtractImageURI(destResult.files)
299+
destResult.tmpRoot = report.ExtractTmpRoot(destResult.files)
300+
default:
301+
var g errgroup.Group
302+
303+
g.Go(func() error {
304+
files, base, err := relFileReport(ctx, c, srcPath, isImage)
305+
res := ScanResult{files: files, base: base, err: err}
306+
if isImage {
307+
res.imageURI, res.tmpRoot = c.ScanPaths[0], srcPath
308+
}
309+
srcCh <- res
310+
return err
311+
})
312+
313+
srcResult = <-srcCh
314+
if srcResult.err != nil {
315+
return nil, fmt.Errorf("source scan error: %w", srcResult.err)
279316
}
280-
destCh <- res
281-
return err
282-
})
283317

284-
destResult := <-destCh
285-
if destResult.err != nil {
286-
return nil, fmt.Errorf("destination scan error: %w", destResult.err)
287-
}
318+
g.Go(func() error {
319+
files, base, err := relFileReport(ctx, c, destPath, isImage)
320+
res := ScanResult{files: files, base: base, err: err}
321+
if isImage {
322+
res.imageURI, res.tmpRoot = c.ScanPaths[1], destPath
323+
}
324+
destCh <- res
325+
return err
326+
})
288327

289-
if err := g.Wait(); err != nil {
290-
return nil, err
328+
destResult = <-destCh
329+
if destResult.err != nil {
330+
return nil, fmt.Errorf("destination scan error: %w", destResult.err)
331+
}
332+
333+
if err := g.Wait(); err != nil {
334+
return nil, err
335+
}
291336
}
292337

293338
d := &malcontent.DiffReport{
@@ -310,11 +355,11 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent
310355
// and employ add/delete for files that are not the same
311356
// When scanning two files, do a 1:1 comparison and
312357
// consider the source -> destination as a change rather than an add/delete
313-
shouldHandleDir := ((srcInfo.IsDir() && destInfo.IsDir()) || (srcIsArchive && destIsArchive)) || isImage
358+
shouldHandleDir := ((srcInfo.IsDir() && destInfo.IsDir()) || (srcIsArchive && destIsArchive)) || isImage || isReport
314359
archiveOrImage := (srcIsArchive && destIsArchive) || isImage
315360

316361
if shouldHandleDir {
317-
handleDir(ctx, c, srcResult, destResult, d, archiveOrImage)
362+
handleDir(ctx, c, srcResult, destResult, d, archiveOrImage, isReport)
318363
} else {
319364
srcFile := selectPrimaryFile(srcResult.files)
320365
destFile := selectPrimaryFile(destResult.files)
@@ -325,14 +370,14 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent
325370
d.Removed.Set(removed, srcFile)
326371
d.Added.Set(added, destFile)
327372
} else {
328-
handleFile(ctx, c, srcFile, destFile, removed, added, d, srcResult, destResult, archiveOrImage)
373+
handleFile(ctx, c, srcFile, destFile, removed, added, d, srcResult, destResult, archiveOrImage, isReport)
329374
}
330375
}
331376
}
332377

333378
// infer moves only if there are entries in both Added and Removed
334379
if d.Added.Len() > 0 && d.Removed.Len() > 0 {
335-
inferMoves(ctx, c, d, srcResult, destResult, archiveOrImage)
380+
inferMoves(ctx, c, d, srcResult, destResult, archiveOrImage, isReport)
336381
}
337382

338383
defer func() {
@@ -343,7 +388,7 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent
343388
return &malcontent.Report{Diff: d}, nil
344389
}
345390

346-
func handleDir(ctx context.Context, c malcontent.Config, src, dest ScanResult, d *malcontent.DiffReport, archiveOrImage bool) {
391+
func handleDir(ctx context.Context, c malcontent.Config, src, dest ScanResult, d *malcontent.DiffReport, archiveOrImage, isReport bool) {
347392
if ctx.Err() != nil {
348393
return
349394
}
@@ -380,17 +425,27 @@ func handleDir(ctx context.Context, c malcontent.Config, src, dest ScanResult, d
380425
// These files are considered removals from the destination
381426
for _, name := range srcKeys {
382427
srcFr := srcFiles[name]
383-
removed := formatKey(src, CleanPath(srcFr.Path, src.tmpRoot))
428+
var removed string
429+
if isReport {
430+
removed = report.FormatReportKey(srcFr.Path, src.tmpRoot, src.imageURI)
431+
} else {
432+
removed = formatKey(src, CleanPath(srcFr.Path, src.tmpRoot))
433+
}
384434
if destFr, exists := destFiles[name]; exists {
385-
added := formatKey(dest, CleanPath(destFr.Path, dest.tmpRoot))
435+
var added string
436+
if isReport {
437+
added = report.FormatReportKey(destFr.Path, dest.tmpRoot, dest.imageURI)
438+
} else {
439+
added = formatKey(dest, CleanPath(destFr.Path, dest.tmpRoot))
440+
}
386441
if filterDiff(ctx, c, srcFr, destFr) {
387442
continue
388443
}
389444
if c.ScoreAll || scoreFile(srcFr, destFr) {
390445
d.Removed.Set(removed, srcFr)
391446
d.Added.Set(added, destFr)
392447
} else {
393-
handleFile(ctx, c, srcFr, destFr, removed, added, d, src, dest, archiveOrImage)
448+
handleFile(ctx, c, srcFr, destFr, removed, added, d, src, dest, archiveOrImage, isReport)
394449
}
395450
} else {
396451
d.Removed.Set(removed, srcFr)
@@ -401,14 +456,19 @@ func handleDir(ctx context.Context, c malcontent.Config, src, dest ScanResult, d
401456
// These files are considered additions to the destination
402457
for _, name := range destKeys {
403458
destFr := destFiles[name]
404-
added := formatKey(dest, CleanPath(destFr.Path, dest.tmpRoot))
459+
var added string
460+
if isReport {
461+
added = report.FormatReportKey(destFr.Path, dest.tmpRoot, dest.imageURI)
462+
} else {
463+
added = formatKey(dest, CleanPath(destFr.Path, dest.tmpRoot))
464+
}
405465
if _, exists := srcFiles[name]; !exists {
406466
d.Added.Set(added, destFr)
407467
}
408468
}
409469
}
410470

411-
func handleFile(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileReport, removed, added string, d *malcontent.DiffReport, _, dest ScanResult, archiveOrImage bool) {
471+
func handleFile(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileReport, removed, added string, d *malcontent.DiffReport, _, dest ScanResult, archiveOrImage, isReport bool) {
412472
if ctx.Err() != nil {
413473
return
414474
}
@@ -450,7 +510,9 @@ func handleFile(ctx context.Context, c malcontent.Config, fr, tr *malcontent.Fil
450510
return rbs.Behaviors[i].ID < rbs.Behaviors[j].ID
451511
})
452512

453-
if archiveOrImage {
513+
if isReport {
514+
rbs.Path = report.FormatReportKey(rbs.Path, dest.tmpRoot, dest.imageURI)
515+
} else if archiveOrImage {
454516
rbs.Path = CleanPath(rbs.Path, "/private")
455517
rbs.Path = formatKey(dest, CleanPath(rbs.Path, dest.tmpRoot))
456518
}
@@ -571,17 +633,17 @@ func combineReports(ctx context.Context, c malcontent.Config, removed, added *or
571633
return combined
572634
}
573635

574-
func inferMoves(ctx context.Context, c malcontent.Config, d *malcontent.DiffReport, src, dest ScanResult, archiveOrImage bool) {
636+
func inferMoves(ctx context.Context, c malcontent.Config, d *malcontent.DiffReport, src, dest ScanResult, archiveOrImage, isReport bool) {
575637
if ctx.Err() != nil {
576638
return
577639
}
578640

579641
for _, cr := range combineReports(ctx, c, d.Removed, d.Added) {
580-
fileMove(ctx, c, cr.RemovedFR, cr.AddedFR, cr.Removed, cr.Added, d, cr.Score, src, dest, archiveOrImage)
642+
fileMove(ctx, c, cr.RemovedFR, cr.AddedFR, cr.Removed, cr.Added, d, cr.Score, src, dest, archiveOrImage, isReport)
581643
}
582644
}
583645

584-
func fileMove(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileReport, rpath, apath string, d *malcontent.DiffReport, score float64, src ScanResult, dest ScanResult, archiveOrImage bool) {
646+
func fileMove(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileReport, rpath, apath string, d *malcontent.DiffReport, score float64, src ScanResult, dest ScanResult, archiveOrImage, isReport bool) {
585647
if ctx.Err() != nil {
586648
return
587649
}
@@ -634,7 +696,10 @@ func fileMove(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileR
634696
return abs.Behaviors[i].ID < abs.Behaviors[j].ID
635697
})
636698

637-
if archiveOrImage {
699+
if isReport {
700+
abs.Path = report.FormatReportKey(abs.Path, dest.tmpRoot, dest.imageURI)
701+
abs.PreviousPath = report.FormatReportKey(abs.PreviousPath, src.tmpRoot, src.imageURI)
702+
} else if archiveOrImage {
638703
abs.Path = CleanPath(abs.Path, "/private")
639704
abs.PreviousPath = CleanPath(abs.PreviousPath, "/private")
640705
abs.Path = formatKey(dest, CleanPath(abs.Path, dest.tmpRoot))

pkg/malcontent/malcontent.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Config struct {
3838
Processes bool
3939
QuantityIncreasesRisk bool
4040
Renderer Renderer
41+
Report bool
4142
RuleFS []fs.FS
4243
Rules *yarax.Rules
4344
Scan bool
@@ -117,6 +118,10 @@ type DiffReport struct {
117118
Modified *orderedmap.OrderedMap[string, *FileReport] `json:",omitempty" yaml:",omitempty"`
118119
}
119120

121+
type ScanResult struct {
122+
FileReports map[string]*FileReport `json:"Files,omitempty" yaml:"Files,omitempty"`
123+
}
124+
120125
type Report struct {
121126
Files sync.Map
122127
Diff *DiffReport

pkg/render/markdown.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (r Markdown) Full(ctx context.Context, _ *malcontent.Config, rep *malconten
118118

119119
var title string
120120
switch {
121-
case modified.Value.PreviousRelPath != "" && modified.Value.PreviousRelPathScore >= 0.9:
121+
case modified.Value.PreviousPath != "" && modified.Value.PreviousRelPathScore >= 0.9:
122122
title = fmt.Sprintf("## Moved: %s -> %s (similarity: %0.2f)", modified.Value.PreviousPath, modified.Value.Path, modified.Value.PreviousRelPathScore)
123123
default:
124124
title = fmt.Sprintf("## Changed (%d added, %d removed): %s", added, removed, modified.Value.Path)

pkg/render/terminal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (r Terminal) Full(ctx context.Context, _ *malcontent.Config, rep *malconten
138138

139139
var moved bool
140140
var title string
141-
if modified.Value.PreviousRelPath != "" && modified.Value.PreviousRelPathScore >= 0.9 {
141+
if modified.Value.PreviousPath != "" && modified.Value.PreviousRelPathScore >= 0.9 {
142142
moved = true
143143
title = fmt.Sprintf(riskColor(modified.Value.PreviousRiskLevel, "Moved: %s -> %s (score: %f)"), modified.Value.PreviousPath, modified.Value.Path, modified.Value.PreviousRelPathScore)
144144
} else if modified.Value.RiskScore != modified.Value.PreviousRiskScore {

0 commit comments

Comments
 (0)