Skip to content

Commit 9f8bb69

Browse files
alowayedcopybara-github
alowayed
authored andcommitted
Add CLI support to scan container image tarball with layer data.
PiperOrigin-RevId: 748018415
1 parent e2c0c63 commit 9f8bb69

File tree

6 files changed

+174
-6
lines changed

6 files changed

+174
-6
lines changed

README.md

+7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ Add the `--remote-image` flag to scan a remote container image. Example:
6464
scalibr --result=result.textproto --remote-image=alpine@sha256:0a4eaa0eecf5f8c050e5bba433f58c052be7587ee8af3e8b3910ef9ab5fbe9f5
6565
```
6666

67+
Or the `--image-tarball` flag to scan a locally saved image tarball like ones
68+
produced with `docker save my-image > my-image.tar`. Example:
69+
70+
```
71+
scalibr --result=result.textproto --image-tarball=my-image.tar
72+
```
73+
6774
### SPDX generation
6875

6976
OSV-SCALIBR supports generating the result of inventory extraction as an SPDX

binary/cli/cli.go

+13
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ type Flags struct {
130130
MaxFileSize int
131131
UseGitignore bool
132132
RemoteImage string
133+
ImageTarball string
133134
ImagePlatform string
134135
GoBinaryVersionFromContent bool
135136
GovulncheckDBPath string
@@ -162,6 +163,12 @@ func ValidateFlags(flags *Flags) error {
162163
if flags.ImagePlatform != "" && len(flags.RemoteImage) == 0 {
163164
return errors.New("--image-platform cannot be used without --remote-image")
164165
}
166+
if flags.ImageTarball != "" && flags.RemoteImage != "" {
167+
return errors.New("--image-tarball cannot be used with --remote-image")
168+
}
169+
if flags.ImageTarball != "" && flags.ImagePlatform != "" {
170+
return errors.New("--image-tarball cannot be used with --image-platform")
171+
}
165172
if err := validateResultPath(flags.ResultFile); err != nil {
166173
return fmt.Errorf("--result %w", err)
167174
}
@@ -489,6 +496,12 @@ func (f *Flags) scanRoots() ([]*scalibrfs.ScanRoot, error) {
489496
return scalibrfs.RealFSScanRoots(f.Root), nil
490497
}
491498

499+
// If ImageTarball is set, do not set the root.
500+
// It is computed later on by ScanContainer(...) when the tarball is read.
501+
if f.ImageTarball != "" {
502+
return nil, nil
503+
}
504+
492505
// Compute the default scan roots.
493506
var scanRoots []*scalibrfs.ScanRoot
494507
var scanRootPaths []string

binary/cli/cli_test.go

+23-1
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ func TestValidateFlags(t *testing.T) {
194194
},
195195
wantErr: nil,
196196
},
197+
{
198+
desc: "Remoe Image with Image Tarball",
199+
flags: &cli.Flags{
200+
RemoteImage: "docker",
201+
ImageTarball: "image.tar",
202+
ResultFile: "result.textproto",
203+
},
204+
wantErr: cmpopts.AnyError,
205+
},
197206
} {
198207
t.Run(tc.desc, func(t *testing.T) {
199208
err := cli.ValidateFlags(tc.flags)
@@ -236,6 +245,19 @@ func TestGetScanConfig_ScanRoots(t *testing.T) {
236245
"windows": {"C:\\myroot"},
237246
},
238247
},
248+
{
249+
desc: "Scan root is null if image tarball is provided",
250+
flags: map[string]*cli.Flags{
251+
"darwin": {ImageTarball: "image.tar"},
252+
"linux": {ImageTarball: "image.tar"},
253+
"windows": {ImageTarball: "image.tar"},
254+
},
255+
wantScanRoots: map[string][]string{
256+
"darwin": nil,
257+
"linux": nil,
258+
"windows": nil,
259+
},
260+
},
239261
} {
240262
t.Run(tc.desc, func(t *testing.T) {
241263
wantScanRoots, ok := tc.wantScanRoots[runtime.GOOS]
@@ -252,7 +274,7 @@ func TestGetScanConfig_ScanRoots(t *testing.T) {
252274
if err != nil {
253275
t.Errorf("%v.GetScanConfig(): %v", flags, err)
254276
}
255-
gotScanRoots := []string{}
277+
var gotScanRoots []string
256278
for _, r := range cfg.ScanRoots {
257279
gotScanRoots = append(gotScanRoots, r.Path)
258280
}

binary/scalibr/scalibr.go

+2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func parseFlags(args []string) (*cli.Flags, error) {
7171
maxFileSize := fs.Int("max-file-size", 0, "Files larger than this size in bytes are skipped. If 0, no limit is applied.")
7272
useGitignore := fs.Bool("use-gitignore", false, "Skip files declared in .gitignore files in source repos.")
7373
remoteImage := fs.String("remote-image", "", "The remote image to scan. If specified, SCALIBR pulls and scans this image instead of the local filesystem.")
74+
imageTarball := fs.String("image-tarball", "", "The path to a tarball containing a container image. These are commonly procuded using `docker save`. If specified, SCALIBR scans this image instead of the local filesystem.")
7475
imagePlatform := fs.String("image-platform", "", "The platform of the remote image to scan. If not specified, the platform of the client is used. Format is os/arch (e.g. linux/arm64)")
7576
goBinaryVersionFromContent := fs.Bool("gobinary-version-from-content", false, "Parse the main module version from the binary content. Off by default because this drastically increases latency (~10x).")
7677
govulncheckDBPath := fs.String("govulncheck-db", "", "Path to the offline DB for the govulncheck detectors to use. Leave empty to run the detectors in online mode.")
@@ -105,6 +106,7 @@ func parseFlags(args []string) (*cli.Flags, error) {
105106
MaxFileSize: *maxFileSize,
106107
UseGitignore: *useGitignore,
107108
RemoteImage: *remoteImage,
109+
ImageTarball: *imageTarball,
108110
ImagePlatform: *imagePlatform,
109111
GoBinaryVersionFromContent: *goBinaryVersionFromContent,
110112
GovulncheckDBPath: *govulncheckDBPath,

binary/scanrunner/scanrunner.go

+20-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"context"
2020

2121
scalibr "github.com/google/osv-scalibr"
22+
scalibrlayerimage "github.com/google/osv-scalibr/artifact/image/layerscanning/image"
2223
"github.com/google/osv-scalibr/binary/cli"
2324
"github.com/google/osv-scalibr/log"
2425
"github.com/google/osv-scalibr/plugin"
@@ -41,11 +42,28 @@ func RunScan(flags *cli.Flags) int {
4142
"Running scan with %d extractors and %d detectors",
4243
len(cfg.FilesystemExtractors)+len(cfg.StandaloneExtractors), len(cfg.Detectors),
4344
)
44-
log.Infof("Scan roots: %s", cfg.ScanRoots)
4545
if len(cfg.PathsToExtract) > 0 {
4646
log.Infof("Paths to extract: %s", cfg.PathsToExtract)
4747
}
48-
result := scalibr.New().Scan(context.Background(), cfg)
48+
49+
var result *scalibr.ScanResult
50+
if flags.ImageTarball != "" {
51+
layerCfg := scalibrlayerimage.DefaultConfig()
52+
log.Infof("Scanning image tarball: %s", flags.ImageTarball)
53+
img, err := scalibrlayerimage.FromTarball(flags.ImageTarball, layerCfg)
54+
if err != nil {
55+
log.Errorf("Failed to create image from tarball: %v", err)
56+
return 1
57+
}
58+
result, err = scalibr.New().ScanContainer(context.Background(), img, cfg)
59+
if err != nil {
60+
log.Errorf("Failed to scan container: %v", err)
61+
return 1
62+
}
63+
} else {
64+
log.Infof("Scan roots: %s", cfg.ScanRoots)
65+
result = scalibr.New().Scan(context.Background(), cfg)
66+
}
4967

5068
log.Infof("Scan status: %v", result.Status)
5169
log.Infof("Found %d software packages, %d security findings", len(result.Inventory.Packages), len(result.Inventory.Findings))

binary/scanrunner/scanrunner_test.go

+109-3
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,21 @@
1515
package scanrunner_test
1616

1717
import (
18+
"bytes"
19+
"errors"
20+
"io"
1821
"os"
1922
"path/filepath"
2023
"runtime"
2124
"slices"
2225
"testing"
2326

27+
"archive/tar"
28+
2429
"github.com/google/go-cmp/cmp"
30+
"github.com/google/go-containerregistry/pkg/v1/empty"
31+
"github.com/google/go-containerregistry/pkg/v1/mutate"
32+
"github.com/google/go-containerregistry/pkg/v1/tarball"
2533
"github.com/google/osv-scalibr/binary/cli"
2634
"github.com/google/osv-scalibr/binary/scanrunner"
2735
"google.golang.org/protobuf/encoding/prototext"
@@ -75,11 +83,72 @@ func createFailingDetectorTestFiles(t *testing.T) string {
7583
return dir
7684
}
7785

86+
func createImageTarball(t *testing.T) string {
87+
t.Helper()
88+
89+
var buf bytes.Buffer
90+
w := tar.NewWriter(&buf)
91+
requirements := `
92+
nltk==3.2.2
93+
tabulate==0.7.7
94+
`
95+
files := []struct {
96+
name, contents string
97+
}{
98+
{"requirements.txt", requirements},
99+
}
100+
for _, file := range files {
101+
hdr := &tar.Header{
102+
Name: file.name,
103+
Mode: 0600,
104+
Size: int64(len(file.contents)),
105+
Typeflag: tar.TypeReg,
106+
}
107+
if err := w.WriteHeader(hdr); err != nil {
108+
t.Fatalf("couldn't write header for %s: %v", file.name, err)
109+
}
110+
if _, err := w.Write([]byte(file.contents)); err != nil {
111+
t.Fatalf("couldn't write %s: %v", file.name, err)
112+
}
113+
}
114+
w.Close()
115+
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
116+
return io.NopCloser(bytes.NewBuffer(buf.Bytes())), nil
117+
})
118+
if err != nil {
119+
t.Fatalf("unable to create layer: %v", err)
120+
}
121+
image, err := mutate.AppendLayers(empty.Image, layer)
122+
if err != nil {
123+
t.Fatalf("unable to create image: %v", err)
124+
}
125+
126+
dir := t.TempDir()
127+
tarPath := filepath.Join(dir, "image.tar")
128+
if err := tarball.WriteToFile(tarPath, nil, image); err != nil {
129+
t.Fatalf("unable to write tarball: %v", err)
130+
}
131+
132+
return dir
133+
}
134+
135+
func createBadImageTarball(t *testing.T) string {
136+
t.Helper()
137+
138+
dir := t.TempDir()
139+
tarPath := filepath.Join(dir, "image.tar")
140+
if err := os.WriteFile(tarPath, []byte("bad tarball"), 0600); err != nil {
141+
t.Fatalf("unable to write tarball: %v", err)
142+
}
143+
return dir
144+
}
145+
78146
func TestRunScan(t *testing.T) {
79147
testCases := []struct {
80148
desc string
81149
setupFunc func(t *testing.T) string
82150
flags *cli.Flags
151+
wantExit int
83152
wantPluginStatus []spb.ScanStatus_ScanStatusEnum
84153
wantPackagesCount int
85154
wantFindingCount int
@@ -103,6 +172,29 @@ func TestRunScan(t *testing.T) {
103172
wantPackagesCount: 1,
104173
wantFindingCount: 0,
105174
},
175+
{
176+
desc: "Successful image extractor run",
177+
setupFunc: createImageTarball,
178+
flags: &cli.Flags{
179+
ImageTarball: "image.tar",
180+
ExtractorsToRun: []string{"python/requirements"},
181+
},
182+
wantPluginStatus: []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_SUCCEEDED},
183+
wantPackagesCount: 2,
184+
wantFindingCount: 0,
185+
},
186+
{
187+
desc: "Failure to read image tarball",
188+
setupFunc: createBadImageTarball,
189+
flags: &cli.Flags{
190+
ImageTarball: "image.tar",
191+
ExtractorsToRun: []string{"python/requirements"},
192+
},
193+
wantExit: 1,
194+
wantPluginStatus: []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_FAILED},
195+
wantPackagesCount: 0,
196+
wantFindingCount: 0,
197+
},
106198
{
107199
desc: "Unsuccessful plugin run",
108200
setupFunc: createFailingDetectorTestFiles,
@@ -123,11 +215,25 @@ func TestRunScan(t *testing.T) {
123215

124216
dir := tc.setupFunc(t)
125217
resultFile := filepath.Join(dir, "result.textproto")
126-
tc.flags.Root = dir
127218
tc.flags.ResultFile = resultFile
128219

129-
if gotExit := scanrunner.RunScan(tc.flags); gotExit != 0 {
130-
t.Errorf("result.RunScan(%v) returned unexpected exit code, want 0 got %d", tc.flags, gotExit)
220+
if tc.flags.ImageTarball != "" {
221+
tc.flags.ImageTarball = filepath.Join(dir, tc.flags.ImageTarball)
222+
} else {
223+
tc.flags.Root = dir
224+
}
225+
226+
gotExit := scanrunner.RunScan(tc.flags)
227+
if gotExit != tc.wantExit {
228+
t.Fatalf("result.RunScan(%v) = %d, want %d", tc.flags, gotExit, tc.wantExit)
229+
}
230+
_, err := os.Stat(resultFile)
231+
if gotExit == 0 && errors.Is(err, os.ErrNotExist) {
232+
t.Fatalf("Scan returned successful exit code 0 but no result file was created")
233+
}
234+
if gotExit != 0 && errors.Is(err, os.ErrNotExist) {
235+
// It is expected that no results are created if the scan fails. Nothing else to check.
236+
return
131237
}
132238

133239
output, err := os.ReadFile(resultFile)

0 commit comments

Comments
 (0)