Skip to content

Commit f9c6d22

Browse files
magerstamgithub-actions[bot]arodage
authored
Support to extract and compare image SBOM (#458)
* Added support for SBOM extraction and compare. * Support for ommitting SBOM output filename * lint issue * Added additional unit-tests * addressed a possible panic issue, and added some additional logging to help with debugging SBOM inspection issues. * Updated CLI documentation for inspect and compare * Addition SBOM and text-rendering unit tests * chore: auto-update coverage threshold to 65.9% (was 65.5%) * Update cmd/os-image-composer/inspect_cmd.go Co-authored-by: Alpesh <alpesh.ramesh.rodage@intel.com> * Lint issue --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Alpesh <alpesh.ramesh.rodage@intel.com>
1 parent 63c408e commit f9c6d22

File tree

16 files changed

+2215
-31
lines changed

16 files changed

+2215
-31
lines changed

.coverage-threshold

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
65.5
1+
65.9

cmd/os-image-composer/compare_cmd.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
var (
1515
prettyDiffJSON bool = true // Pretty-print JSON output
1616
outFormat string // "text" | "json"
17-
outMode string = "" // "full" | "diff" | "summary"
17+
outMode string = "" // "full" | "diff" | "summary" | "spdx"
1818
hashImages bool = false // Skip hashing during inspection
1919
)
2020

@@ -39,7 +39,7 @@ func createCompareCommand() *cobra.Command {
3939
compareCmd.Flags().StringVar(&outFormat, "format", "text",
4040
"Output format: text or json")
4141
compareCmd.Flags().StringVar(&outMode, "mode", "",
42-
"Output mode: full, diff, or summary (default: diff for text, full for json)")
42+
"Output mode: full, diff, summary, or spdx (default: diff for text, full for json)")
4343
compareCmd.Flags().BoolVar(&hashImages, "hash-images", false,
4444
"Compute SHA256 hash of images during inspection (slower but enables binary identity verification")
4545
return compareCmd
@@ -49,6 +49,10 @@ func resolveDefaults(format, mode string) (string, string) {
4949
format = strings.ToLower(format)
5050
mode = strings.ToLower(mode)
5151

52+
if mode == "spdx" {
53+
return format, mode
54+
}
55+
5256
// Set default mode if not specified
5357
if mode == "" {
5458
if format == "json" {
@@ -67,6 +71,24 @@ func executeCompare(cmd *cobra.Command, args []string) error {
6771
imageFile2 := args[1]
6872
log.Infof("Comparing image files: (%s) & (%s)", imageFile1, imageFile2)
6973

74+
format, mode := resolveDefaults(outFormat, outMode)
75+
76+
if mode == "spdx" {
77+
spdxResult, err := imageinspect.CompareSPDXFiles(imageFile1, imageFile2)
78+
if err != nil {
79+
return fmt.Errorf("SPDX compare failed: %w", err)
80+
}
81+
82+
switch format {
83+
case "json":
84+
return writeCompareResult(cmd, spdxResult, prettyDiffJSON)
85+
case "text":
86+
return imageinspect.RenderSPDXCompareText(cmd.OutOrStdout(), spdxResult)
87+
default:
88+
return fmt.Errorf("invalid --format %q (expected text|json)", format)
89+
}
90+
}
91+
7092
inspector := newInspector(hashImages)
7193

7294
image1, err1 := inspector.Inspect(imageFile1)
@@ -80,8 +102,6 @@ func executeCompare(cmd *cobra.Command, args []string) error {
80102

81103
compareResult := imageinspect.CompareImages(image1, image2)
82104

83-
format, mode := resolveDefaults(outFormat, outMode)
84-
85105
switch format {
86106
case "json":
87107
var payload any
@@ -100,7 +120,7 @@ func executeCompare(cmd *cobra.Command, args []string) error {
100120
Summary imageinspect.CompareSummary `json:"summary"`
101121
}{EqualityClass: string(compareResult.Equality.Class), Summary: compareResult.Summary}
102122
default:
103-
return fmt.Errorf("invalid --mode or --format %q (expected --mode=diff|summary|full) and --format=text|json", mode)
123+
return fmt.Errorf("invalid --mode or --format %q (expected --mode=diff|summary|full|spdx) and --format=text|json", mode)
104124
}
105125
return writeCompareResult(cmd, payload, prettyDiffJSON)
106126

@@ -109,7 +129,7 @@ func executeCompare(cmd *cobra.Command, args []string) error {
109129
imageinspect.CompareTextOptions{Mode: mode})
110130

111131
default:
112-
return fmt.Errorf("invalid --mode %q (expected text|json)", outMode)
132+
return fmt.Errorf("invalid --format %q (expected text|json)", format)
113133
}
114134
}
115135

cmd/os-image-composer/compare_cmd_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"encoding/json"
66
"errors"
7+
"os"
8+
"path/filepath"
79
"strings"
810
"testing"
911

@@ -262,6 +264,71 @@ func TestCompareCommand_InvalidModeErrors(t *testing.T) {
262264
}
263265
}
264266

267+
func TestCompareCommand_SPDXMode(t *testing.T) {
268+
origNewInspector := newInspector
269+
origOutFormat, origOutMode := outFormat, outMode
270+
t.Cleanup(func() {
271+
newInspector = origNewInspector
272+
outFormat, outMode = origOutFormat, origOutMode
273+
})
274+
275+
newInspector = func(hash bool) inspector {
276+
return &fakeCompareInspector{errByPath: map[string]error{
277+
"a.spdx.json": errors.New("should not inspect images in spdx mode"),
278+
"b.spdx.json": errors.New("should not inspect images in spdx mode"),
279+
}}
280+
}
281+
282+
tmpDir := t.TempDir()
283+
fromPath := filepath.Join(tmpDir, "a.spdx.json")
284+
toPath := filepath.Join(tmpDir, "b.spdx.json")
285+
286+
fromContent := `{"packages":[{"name":"acl","versionInfo":"2.3.1","downloadLocation":"https://example.com/acl.rpm"}]}`
287+
toContent := `{"packages":[{"name":"acl","versionInfo":"2.3.2","downloadLocation":"https://example.com/acl.rpm"}]}`
288+
289+
if err := os.WriteFile(fromPath, []byte(fromContent), 0644); err != nil {
290+
t.Fatalf("write from SPDX file: %v", err)
291+
}
292+
if err := os.WriteFile(toPath, []byte(toContent), 0644); err != nil {
293+
t.Fatalf("write to SPDX file: %v", err)
294+
}
295+
296+
t.Run("JSON", func(t *testing.T) {
297+
cmd := &cobra.Command{}
298+
outFormat = "json"
299+
outMode = "spdx"
300+
prettyDiffJSON = false
301+
302+
s, err := runCompareExecute(t, cmd, []string{fromPath, toPath})
303+
if err != nil {
304+
t.Fatalf("unexpected error: %v", err)
305+
}
306+
307+
var got imageinspect.SPDXCompareResult
308+
decodeJSON(t, s, &got)
309+
if got.Equal {
310+
t.Fatalf("expected SPDX compare to be different")
311+
}
312+
if len(got.AddedPackages) == 0 || len(got.RemovedPackages) == 0 {
313+
t.Fatalf("expected added/removed package entries, got %+v", got)
314+
}
315+
})
316+
317+
t.Run("Text", func(t *testing.T) {
318+
cmd := &cobra.Command{}
319+
outFormat = "text"
320+
outMode = "spdx"
321+
322+
s, err := runCompareExecute(t, cmd, []string{fromPath, toPath})
323+
if err != nil {
324+
t.Fatalf("unexpected error: %v", err)
325+
}
326+
if !strings.Contains(s, "SPDX Compare") {
327+
t.Fatalf("expected SPDX text header, got:\n%s", s)
328+
}
329+
})
330+
}
331+
265332
func TestCompareCommand_InvalidFormatErrors(t *testing.T) {
266333
origNewInspector := newInspector
267334
origOutFormat, origOutMode := outFormat, outMode

cmd/os-image-composer/inspect_cmd.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package main
33
import (
44
"encoding/json"
55
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
69

710
"github.com/open-edge-platform/os-image-composer/internal/image/imageinspect"
811
"github.com/open-edge-platform/os-image-composer/internal/utils/logger"
@@ -20,10 +23,15 @@ var newInspector = func(hash bool) inspector {
2023
return imageinspect.NewDiskfsInspector(hash) // returns *DiskfsInspector which satisfies inspector
2124
}
2225

26+
var newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector {
27+
return imageinspect.NewDiskfsInspectorWithOptions(hash, inspectSBOM)
28+
}
29+
2330
// Output format command flags
2431
var (
2532
outputFormat string = "text" // Output format for the inspection results
2633
prettyJSON bool = false // Pretty-print JSON output
34+
sbomOutPath string = "" // Optional destination path for extracted SBOM manifest
2735
)
2836

2937
// createInspectCommand creates the inspect subcommand
@@ -55,6 +63,12 @@ func createInspectCommand() *cobra.Command {
5563
inspectCmd.Flags().BoolVar(&prettyJSON, "pretty", false,
5664
"Pretty-print JSON output (only for --format json)")
5765

66+
inspectCmd.Flags().StringVar(&sbomOutPath, "extract-sbom", "",
67+
"Extract embedded SPDX manifest (if present) to this file or directory path")
68+
if extractFlag := inspectCmd.Flags().Lookup("extract-sbom"); extractFlag != nil {
69+
extractFlag.NoOptDefVal = "."
70+
}
71+
5872
return inspectCmd
5973
}
6074

@@ -64,20 +78,79 @@ func executeInspect(cmd *cobra.Command, args []string) error {
6478
imageFile := args[0]
6579
log.Infof("Inspecting image file: %s", imageFile)
6680

67-
inspector := newInspector(false)
81+
extractFlagSet := cmd.Flags().Changed("extract-sbom")
82+
resolvedSBOMOutPath := strings.TrimSpace(sbomOutPath)
83+
if extractFlagSet && resolvedSBOMOutPath == "" {
84+
resolvedSBOMOutPath = "."
85+
}
86+
87+
inspectSBOM := extractFlagSet || resolvedSBOMOutPath != ""
88+
var inspector inspector
89+
if inspectSBOM {
90+
inspector = newInspectorWithSBOM(false, true)
91+
} else {
92+
inspector = newInspector(false)
93+
}
6894

6995
inspectionResults, err := inspector.Inspect(imageFile)
7096
if err != nil {
7197
return fmt.Errorf("image inspection failed: %v", err)
7298
}
7399

100+
if inspectSBOM {
101+
if err := writeExtractedSBOM(inspectionResults.SBOM, resolvedSBOMOutPath); err != nil {
102+
return fmt.Errorf("failed to extract SBOM: %w", err)
103+
}
104+
}
105+
74106
if err := writeInspectionResult(cmd, inspectionResults, outputFormat, prettyJSON); err != nil {
75107
return err
76108
}
77109

78110
return nil
79111
}
80112

113+
func writeExtractedSBOM(sbom imageinspect.SBOMSummary, outPath string) error {
114+
if !sbom.Present || len(sbom.Content) == 0 {
115+
if len(sbom.Notes) > 0 {
116+
return fmt.Errorf("embedded SBOM not found: %s", strings.Join(sbom.Notes, "; "))
117+
}
118+
return fmt.Errorf("embedded SBOM not found")
119+
}
120+
121+
outPath = strings.TrimSpace(outPath)
122+
if outPath == "" {
123+
outPath = "."
124+
}
125+
outPath = filepath.Clean(outPath)
126+
fileName := sbom.FileName
127+
if fileName == "" {
128+
fileName = "spdx_manifest.json"
129+
}
130+
131+
var destination string
132+
if info, err := os.Stat(outPath); err == nil && info.IsDir() {
133+
destination = filepath.Join(outPath, fileName)
134+
} else if strings.HasSuffix(strings.ToLower(outPath), ".json") {
135+
destination = outPath
136+
} else {
137+
if err := os.MkdirAll(outPath, 0755); err != nil {
138+
return fmt.Errorf("create output directory: %w", err)
139+
}
140+
destination = filepath.Join(outPath, fileName)
141+
}
142+
143+
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
144+
return fmt.Errorf("create parent directory: %w", err)
145+
}
146+
147+
if err := os.WriteFile(destination, sbom.Content, 0644); err != nil {
148+
return fmt.Errorf("write SBOM file: %w", err)
149+
}
150+
151+
return nil
152+
}
153+
81154
func writeInspectionResult(cmd *cobra.Command, summary *imageinspect.ImageSummary, format string, pretty bool) error {
82155
out := cmd.OutOrStdout()
83156

0 commit comments

Comments
 (0)