diff --git a/detectors/sshole/internal/repo/more_test.go b/detectors/sshole/internal/repo/more_test.go index b9f203a..35c05f1 100644 --- a/detectors/sshole/internal/repo/more_test.go +++ b/detectors/sshole/internal/repo/more_test.go @@ -2,6 +2,7 @@ package repo import ( "context" + "io" "os" "path/filepath" "strings" @@ -283,3 +284,110 @@ func TestAlpineRetag(t *testing.T) { t.Fatalf("trusted a tag: %+v", fs) } } + +type tempErr struct{} + +func (tempErr) Error() string { return "timeout" } +func (tempErr) Temporary() bool { return true } + +func TestBaseLookupsOnlyWhenNeeded(t *testing.T) { + root := t.TempDir() + write(t, root, "a/Dockerfile", "FROM node:22\nRUN npm ci\nFROM golang:1.27 AS build\nRUN go build\n") + write(t, root, "b/Dockerfile", "FROM acme/base:1\nRUN apk add openssh-server=9.9_p1-r2\n") + var asked []string + opts := Options{Base: func(_ context.Context, img string) (bool, string, error) { + asked = append(asked, img) + return true, "3.21", nil + }} + fs := scan(t, root, opts) + if strings.Join(asked, ",") != "acme/base:1" { + t.Fatalf("looked up %v", asked) + } + if len(fs) != 1 || fs[0].Rule != RulePin { + t.Fatalf("%+v", fs) + } + // A temporary failure is reported to the caller, and the finding's + // message does not carry the error. + opts.Base = func(context.Context, string) (bool, string, error) { return false, "", tempErr{} } + res, err := Scan(context.Background(), root, opts) + if err != nil || len(res.Transient) != 1 || strings.Contains(res.Findings[0].Message, "timeout") { + t.Fatalf("%v %+v", err, res) + } +} + +func TestWriteNewFilesSafety(t *testing.T) { + root := t.TempDir() + files := map[string][]byte{"ext/patches/openssh/9.9p2/x.patch": []byte("p"), "ext/patches/openssh/10.0p1/x.patch": []byte("p")} + // Parallel writers of the same files all succeed. + errs := make(chan error, 8) + for i := 0; i < 8; i++ { + go func() { errs <- WriteNewFiles(root, files) }() + } + for i := 0; i < 8; i++ { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + // Other content, .git, and a symlinked directory are refused before + // anything is written. + if err := WriteNewFiles(root, map[string][]byte{"ext/patches/openssh/9.9p2/x.patch": []byte("q")}); err == nil { + t.Error("replaced a file") + } + if err := WriteNewFiles(root, map[string][]byte{"a/new.txt": []byte("x"), ".git/hooks/x": []byte("x")}); err == nil { + t.Error("wrote into .git") + } + if _, err := os.Stat(filepath.Join(root, "a")); err == nil { + t.Error("partial write before a refusal") + } + os.Symlink(t.TempDir(), filepath.Join(root, "link")) + if err := WriteNewFiles(root, map[string][]byte{"link/x": []byte("x")}); err == nil { + t.Error("wrote through a symlink") + } +} + +func TestBuildrootGitPatchDir(t *testing.T) { + root := t.TempDir() + write(t, root, "Config.in", "") + write(t, root, "configs/a_defconfig", "BR2_TOOLCHAIN_BUILDROOT_MUSL=y\nBR2_GLOBAL_PATCH_DIR=\".git/hooks\"\nBR2_PACKAGE_OPENSSH=y\n") + fs := scan(t, root, Options{}) + if len(fs) != 1 { + t.Fatalf("%+v", fs) + } + if _, _, err := Plan(context.Background(), root, fs[0], Options{}); err == nil || !strings.Contains(err.Error(), ".git") { + t.Fatalf("%v", err) + } +} + +func TestFindMovedLine(t *testing.T) { + root := t.TempDir() + write(t, root, "Dockerfile", "FROM alpine:3.22\nRUN usermod -p '*' bob\n") + fs := scan(t, root, Options{}) + write(t, root, "Dockerfile", "FROM alpine:3.22\n# a comment added above\nRUN usermod -p '*' bob\n") + f, gone, err := Find(context.Background(), root, "Dockerfile", fs[0].Line, RuleStar, fs[0].Message, Options{}) + if err != nil || gone || f == nil || f.Line != 3 { + t.Fatalf("%v %v %+v", err, gone, f) + } +} + +func TestYoctoPRMode(t *testing.T) { + root := t.TempDir() + write(t, root, "meta-a/conf/layer.conf", "LAYERSERIES_COMPAT_a = \"scarthgap\"\n") + write(t, root, "meta-a/conf/distro/a.conf", "TCLIBC = \"musl\"\n") + write(t, root, "meta-a/recipes-core/images/a.bb", "IMAGE_INSTALL:append = \" openssh\"\n") + res, err := Scan(context.Background(), root, Options{Files: []string{"meta-a/recipes-core/images/a.bb"}}) + if err != nil || len(res.Findings) != 1 || res.Findings[0].Rule != RuleBuild { + t.Fatalf("%v %+v", err, res) + } +} + +func TestCappedReader(t *testing.T) { + r := &CappedReader{R: strings.NewReader("abcdef"), N: 3} + b, err := io.ReadAll(r) + if string(b) != "abc" || err != ErrTooLarge { + t.Fatalf("%q %v", b, err) + } + r = &CappedReader{R: strings.NewReader("abc"), N: 3} + if b, err := io.ReadAll(r); string(b) != "abc" || err != nil { + t.Fatalf("exact: %q %v", b, err) + } +} diff --git a/detectors/sshole/internal/repo/plan.go b/detectors/sshole/internal/repo/plan.go index a1671da..b0a7ed5 100644 --- a/detectors/sshole/internal/repo/plan.go +++ b/detectors/sshole/internal/repo/plan.go @@ -47,8 +47,8 @@ func Plan(ctx context.Context, root string, f Finding, opts Options) (*Change, b return nil, false, &Declined{Reason: f.Fix} } for p, data := range f.newFiles { - if cur, err := readFile(root, p); err == nil && string(cur) != string(data) { - return nil, false, &Declined{Reason: fmt.Sprintf("%s already exists with other content; add the fix by hand", p)} + if err := checkNewPath(root, p, data); err != nil { + return nil, false, &Declined{Reason: err.Error() + "; add the fix by hand"} } } if f.edit == nil { @@ -309,6 +309,22 @@ func Find(ctx context.Context, root, file string, line int, rule, message string at = picked } } + if len(at) == 0 && message != "" { + // The line moved (an edit above it): the same finding elsewhere in + // the file, when there is exactly one, is still this finding. + var moved []Finding + for _, g := range fs { + if g.Rule == rule && strings.Contains(message, g.Message) { + moved = append(moved, g) + } + } + if len(moved) == 1 { + return &moved[0], false, nil + } + if len(moved) > 1 { + return nil, false, &Declined{Reason: fmt.Sprintf("line %d of %s no longer has this finding and the file has %d like it; rescan and fix the new findings", line, file, len(moved))} + } + } switch len(at) { case 0: return nil, true, nil @@ -328,10 +344,19 @@ func WriteNewFiles(root string, files map[string][]byte) error { paths = append(paths, p) } sort.Strings(paths) + // Check every path before writing anything, so a refusal leaves no + // partial change behind. + for _, rel := range paths { + if err := checkNewPath(root, rel, files[rel]); err != nil { + return err + } + } for _, rel := range paths { data := files[rel] // Create the directories one by one, refusing any symbolic link on - // the way, then check the whole path again. + // the way, then check the whole path again. Another writer (a + // parallel fix of a sibling file) may create the same directories + // and files: identical content already there is success. if err := mkdirsNoLinks(root, path.Dir(path.Clean(rel))); err != nil { return err } @@ -339,24 +364,102 @@ func WriteNewFiles(root string, files map[string][]byte) error { if err != nil { return err } - if fi, err := os.Lstat(p); err == nil { - cur, rerr := os.ReadFile(p) - if !fi.Mode().IsRegular() || rerr != nil || string(cur) != string(data) { - return fmt.Errorf("%s exists with other content", rel) - } + if same, err := sameFile(p, data); err != nil { + return fmt.Errorf("%s: %w", rel, err) + } else if same { continue } - f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + // Write a temporary file, then link it into place: the link fails + // rather than replace a file, and a reader never sees half a file. + tmp, err := os.CreateTemp(filepath.Dir(p), ".earlylight-new-*") if err != nil { return err } - if _, err := f.Write(data); err != nil { - f.Close() - return err + name := tmp.Name() + _, werr := tmp.Write(data) + cerr := tmp.Close() + if werr == nil { + werr = cerr } - if err := f.Close(); err != nil { + if werr == nil { + werr = os.Chmod(name, 0o644) + } + if werr != nil { + os.Remove(name) + return werr + } + lerr := os.Link(name, p) + os.Remove(name) + if errors.Is(lerr, os.ErrExist) { + if same, serr := sameFile(p, data); serr == nil && same { + continue + } + return fmt.Errorf("%s exists with other content", rel) + } + if lerr != nil { + return lerr + } + } + return nil +} + +// sameFile reports whether a regular file with exactly data is at p; an +// error when something else is there. +func sameFile(p string, data []byte) (bool, error) { + fi, err := os.Lstat(p) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !fi.Mode().IsRegular() { + return false, errors.New("exists and is not a regular file") + } + cur, err := os.ReadFile(p) + if err != nil { + return false, err + } + if string(cur) != string(data) { + return false, errors.New("exists with other content") + } + return true, nil +} + +// checkNewPath refuses a path outside the repository, inside .git, or +// through a symbolic link that already exists, and an existing file with +// other content. +func checkNewPath(root, rel string, data []byte) error { + clean := path.Clean(rel) + if rel == "" || path.IsAbs(rel) || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(rel, "\\") { + return fmt.Errorf("%q leaves the repository", rel) + } + for _, part := range strings.Split(clean, "/") { + if strings.EqualFold(part, ".git") { + return fmt.Errorf("%q is inside .git", rel) + } + } + cur := root + parts := strings.Split(clean, "/") + for i, e := range parts { + cur = filepath.Join(cur, e) + fi, err := os.Lstat(cur) + if errors.Is(err, os.ErrNotExist) { + return nil // the rest is created + } + if err != nil { return err } + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%q passes through a symbolic link", rel) + } + if i == len(parts)-1 { + if _, err := sameFile(cur, data); err != nil { + return fmt.Errorf("%s %w", rel, err) + } + } else if !fi.IsDir() { + return fmt.Errorf("%q: %s is not a directory", rel, path.Join(parts[:i+1]...)) + } } return nil } @@ -374,9 +477,12 @@ func mkdirsNoLinks(root, dir string) error { fi, err := os.Lstat(cur) switch { case errors.Is(err, os.ErrNotExist): - if err := os.Mkdir(cur, 0o755); err != nil { + if err := os.Mkdir(cur, 0o755); err != nil && !errors.Is(err, os.ErrExist) { return err } + if fi, err := os.Lstat(cur); err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s could not be created as a directory", cur) + } case err != nil: return err case fi.Mode()&os.ModeSymlink != 0: diff --git a/detectors/sshole/internal/repo/repo.go b/detectors/sshole/internal/repo/repo.go index c1b0420..a535b21 100644 --- a/detectors/sshole/internal/repo/repo.go +++ b/detectors/sshole/internal/repo/repo.go @@ -31,6 +31,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/DefectDojo/earlylight/detectors/sshole/internal/assess" "github.com/DefectDojo/earlylight/internal/refs" @@ -73,6 +74,10 @@ type Skip struct { // Result is a scan of a tree. type Result struct { + // Transient lists base-image lookups that failed in a way that may not + // happen again; a reimporting caller should fail rather than report + // the affected installs as unchecked. + Transient []string Findings []Finding Skips []Skip Files int @@ -114,7 +119,41 @@ func Scan(ctx context.Context, root string, opts Options) (Result, error) { list = append(list, rel) } } - return scanList(ctx, root, list, opts, res) + // A Yocto finding joins files (TCLIBC in a distro conf, the image + // recipe, bbappends): read the tree's other Yocto files too, and + // report only on the changed ones. + yocto := false + for _, rel := range list { + if kindOf(rel) == "build" && (&scanner{}).yoctoNeedsTree(rel) { + yocto = true + } + } + if !yocto { + return scanList(ctx, root, list, opts, res) + } + changed := map[string]bool{} + for _, rel := range list { + changed[rel] = true + } + full := append([]string(nil), list...) + _ = walkRepo(root, func(rel string) { + if !changed[rel] && kindOf(rel) == "build" && (&scanner{}).yoctoNeedsTree(rel) { + full = append(full, rel) + } + }) + out, err := scanList(ctx, root, full, opts, res) + if err != nil { + return out, err + } + var kept []Finding + for _, f := range out.Findings { + if changed[f.File] { + kept = append(kept, f) + } + } + out.Findings = kept + out.Files = len(list) + return out, nil } err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { @@ -188,6 +227,7 @@ func scanList(ctx context.Context, root string, list []string, opts Options, res } } res.Findings = append(res.Findings, s.finishBuild()...) + res.Transient = s.transient return res, nil } @@ -256,16 +296,20 @@ func readFile(root, rel string) ([]byte, error) { } type baseInfo struct { + img string // the image, for resolving it only when a verdict needs it alpine, known bool branch string why string } type scanner struct { - ctx context.Context - root string - opts Options - bases map[string]baseInfo + ctx context.Context + root string + opts Options + bases map[string]baseInfo + lookups int + lookupStart time.Time + transient []string // vars are the current Dockerfile's ARG and ENV values. vars map[string]varDef // from is the current stage's FROM instruction. @@ -485,22 +529,64 @@ func expand(s string, args map[string]string) string { var alpineTagRE = regexp.MustCompile(`(?:^|[-_.])alpine(\d+\.\d+)`) -// base decides whether an image is Alpine and which branch, from its name -// and, when a resolver is set, from the image itself. +// base decides from an image's name whether it is Alpine and which branch. +// Nothing is pulled here: need resolves the image itself, and only when a +// stage installs an OpenSSH server. func (s *scanner) base(img string) baseInfo { - if b, ok := s.bases[img]; ok { + b := baseFromName(img) + b.img = img + return b +} + +// Limits on the base images one scan reads: how many, and how long all +// lookups may take together, so images that are slow on purpose cannot hold +// a scan past its own timeout. +const ( + maxBaseLookups = 32 + maxBaseLookupTime = 3 * time.Minute +) + +// TemporaryError is a lookup failure that may not happen again (a timeout, +// a 5xx): a caller that reimports should fail the scan rather than report +// the install as unchecked. +type TemporaryError interface{ Temporary() bool } + +// need resolves what the name left open by reading the image, once per +// image, through the resolver. +func (s *scanner) need(b baseInfo) baseInfo { + if b.known && !(b.alpine && b.branch == "") || s.opts.Base == nil || b.img == "" || b.img == "scratch" || strings.Contains(b.img, "$") { return b } - b := baseFromName(img) - if (!b.known || b.alpine && b.branch == "") && s.opts.Base != nil && img != "" && img != "scratch" && !strings.Contains(img, "$") { - if alpine, branch, err := s.opts.Base(s.ctx, img); err == nil { - b = baseInfo{alpine: alpine, known: true, branch: branch} - } else if !b.known { - b.why = err.Error() + if r, ok := s.bases[b.img]; ok { + return r + } + r := b + if s.lookups == 0 { + s.lookupStart = time.Now() + } + if s.lookups >= maxBaseLookups || time.Since(s.lookupStart) > maxBaseLookupTime { + if !r.known { + r.why = fmt.Sprintf("this scan's base-image lookups reached their limit (%d images, %s)", maxBaseLookups, maxBaseLookupTime) } + s.bases[b.img] = r + return r } - s.bases[img] = b - return b + s.lookups++ + lctx, cancel := context.WithTimeout(s.ctx, maxBaseLookupTime-time.Since(s.lookupStart)) + defer cancel() + if alpine, branch, err := s.opts.Base(lctx, b.img); err == nil { + r = baseInfo{img: b.img, alpine: alpine, known: true, branch: branch} + } else { + var te TemporaryError + if errors.As(err, &te) && te.Temporary() { + s.transient = append(s.transient, b.img+": "+err.Error()) + } + if !r.known { + r.why = err.Error() + } + } + s.bases[b.img] = r + return r } func baseFromName(img string) baseInfo { @@ -786,6 +872,7 @@ func (s *scanner) apkVariable(rel string, sp span, w word, b baseInfo, image str } def, ok := s.vars[name] if !ok { + b = s.need(b) if b.known && !b.alpine { return nil } @@ -869,17 +956,17 @@ func (s *scanner) judgeSpec(rel string, at specAt, pkg, op, ver string, b baseIn if image != "" { props["image"] = image } + b = s.need(b) if b.branch != "" { props["branch"] = b.branch } f := Finding{File: rel, Line: l, Props: props} if !b.known { f.Rule, f.Level = RuleNotChecked, "note" - why := "the base image could not be identified" if b.why != "" { - why += " (" + b.why + ")" + props["reason"] = b.why // not in the message: the title must not change with each error } - f.Message = fmt.Sprintf("installs %s with apk, but %s, so whether it is Alpine (musl) is not known", at.val, why) + f.Message = fmt.Sprintf("installs %s with apk, but the base image could not be identified, so whether it is Alpine (musl) is not known", at.val) f.Fix = "If this image is Alpine, require " + pkg + " at the fixed release for its branch (" + fixedTable() + ")" return f, true } @@ -1080,8 +1167,40 @@ func (s *scanner) sshdConfig(rel, text string) []Finding { // ---- base image resolution -// AlpineOf reads an image's filesystem for /etc/alpine-release. open -// returns the flattened filesystem as a tar stream. +// maxBaseEntries bounds the tar entries a base-image lookup reads. +const maxBaseEntries = 200000 + +// ErrTooLarge is a base layer past a read limit: not checked, never "not +// Alpine". +var ErrTooLarge = errors.New("the base layer is larger than the lookup reads") + +// CappedReader reads at most N bytes and then fails with ErrTooLarge, where +// io.LimitReader would end quietly and make a truncated layer look whole. +type CappedReader struct { + R io.Reader + N int64 +} + +func (c *CappedReader) Read(p []byte) (int, error) { + if c.N <= 0 { + // One byte more means the layer goes on past the limit. + var one [1]byte + if n, _ := c.R.Read(one[:]); n > 0 { + return 0, ErrTooLarge + } + return 0, io.EOF + } + if int64(len(p)) > c.N { + p = p[:c.N] + } + n, err := c.R.Read(p) + c.N -= int64(n) + return n, err +} + +// AlpineOf reads a tar stream (a base image's first layer) for +// /etc/alpine-release; a musl loader without it is Alpine-like, and a glibc +// os-release ends the read early. func AlpineOf(open func() (io.ReadCloser, error)) (bool, string, error) { rc, err := open() if err != nil { @@ -1090,7 +1209,10 @@ func AlpineOf(open func() (io.ReadCloser, error)) (bool, string, error) { defer rc.Close() tr := tar.NewReader(rc) musl := false - for { + for entries := 0; ; entries++ { + if entries >= maxBaseEntries { + return false, "", ErrTooLarge + } h, err := tr.Next() if err == io.EOF { break @@ -1108,6 +1230,15 @@ func AlpineOf(open func() (io.ReadCloser, error)) (bool, string, error) { return true, assess.BranchFromRelease(string(b)), nil case strings.HasPrefix(name, "/lib/ld-musl-"): musl = true + case (name == "/etc/os-release" || name == "/usr/lib/os-release") && h.Typeflag == tar.TypeReg: + // A glibc distribution says so here: stop reading. + b, err := io.ReadAll(io.LimitReader(tr, 4096)) + if err != nil { + return false, "", err + } + if glibcDistro(string(b)) { + return false, "", nil + } } } if musl { @@ -1293,3 +1424,19 @@ func (s *scanner) alpineRetag(image string) string { } return "" } + +var osIDRE = regexp.MustCompile(`(?m)^ID=["']?([a-z0-9._-]+)`) + +// glibcDistro reports an os-release ID of a distribution built on glibc. +func glibcDistro(osRelease string) bool { + m := osIDRE.FindStringSubmatch(osRelease) + if m == nil { + return false + } + switch m[1] { + case "debian", "ubuntu", "fedora", "rhel", "centos", "rocky", "almalinux", "amzn", "ol", "opensuse-leap", "opensuse-tumbleweed", + "sles", "arch", "wolfi", "chainguard", "photon", "mariner", "azurelinux", "cos", "distroless", "gentoo", "nixos": + return true + } + return false +} diff --git a/detectors/sshole/internal/repo/repo_test.go b/detectors/sshole/internal/repo/repo_test.go index 8361767..69fa67c 100644 --- a/detectors/sshole/internal/repo/repo_test.go +++ b/detectors/sshole/internal/repo/repo_test.go @@ -177,7 +177,7 @@ func TestPlanDeclines(t *testing.T) { // A failed resolution leaves it unchecked. bad := Options{Base: func(context.Context, string) (bool, string, error) { return false, "", errors.New("denied") }} write(t, root, "Dockerfile", "FROM registry.example/x\nRUN apk add openssh-server\n") - if fs := scan(t, root, bad); len(fs) != 1 || fs[0].Rule != RuleNotChecked || !strings.Contains(fs[0].Message, "denied") { + if fs := scan(t, root, bad); len(fs) != 1 || fs[0].Rule != RuleNotChecked || fs[0].Props["reason"] != "denied" || strings.Contains(fs[0].Message, "denied") { t.Fatalf("%+v", fs) } } @@ -199,6 +199,9 @@ func TestAlpineOf(t *testing.T) { if a, _, _ := AlpineOf(mk(map[string]string{"lib/ld-musl-x86_64.so.1": ""})); !a { t.Error("musl without release file") } + if a, _, _ := AlpineOf(mk(map[string]string{"etc/os-release": "ID=debian\nVERSION_ID=12\n", "lib/ld-musl-x86_64.so.1": ""})); a { + t.Error("debian os-release did not stop the read") + } if a, _, _ := AlpineOf(mk(map[string]string{"etc/debian_version": "12"})); a { t.Error("debian taken for alpine") } diff --git a/detectors/sshole/repo.go b/detectors/sshole/repo.go index 39e895b..0c5f64a 100644 --- a/detectors/sshole/repo.go +++ b/detectors/sshole/repo.go @@ -62,6 +62,9 @@ func (d *Detector) ScanRepo(ctx context.Context, dir string, o engine.Options) * d.scanImages(dir, o, t) } t.Files = res.Files + if len(res.Transient) > 0 { + t.Incomplete = "a base image lookup failed and may succeed later, so the installs on it are not judged: " + strings.Join(res.Transient, "; ") + } if res.Truncated { t.Incomplete = "the source tree has too many files to walk; what lies past the limit is not checked" } @@ -228,7 +231,7 @@ func baseResolver(o engine.Options) func(context.Context, string) (bool, string, } var mu sync.Mutex cache := map[string]res{} - return func(_ context.Context, ref string) (bool, string, error) { + return func(ctx context.Context, ref string) (bool, string, error) { mu.Lock() defer mu.Unlock() if r, ok := cache[ref]; ok { @@ -246,17 +249,26 @@ func baseResolver(o engine.Options) func(context.Context, string) (bool, string, if platform == "" && len(o.Platforms) > 0 { platform = o.Platforms[0] } + // The deadline is on the requests themselves, so a stalled read + // is cancelled, not only the next one. + lctx, cancel := context.WithTimeout(ctx, baseLookupTime) + defer cancel() a, b, err := repo.AlpineOf(func() (io.ReadCloser, error) { // The guarded transport refuses link-local addresses (cloud // metadata) always, and with --public-registries-only every // loopback, private and internal one, on each connection and // redirect. - op, err := image.OpenRegistry(ref, platform, netguard.Transport(o.PublicOnly)) + rc, err := image.OpenBaseLayer(lctx, ref, platform, netguard.Transport(o.PublicOnly)) if err != nil { return nil, err } - return limitedReadCloser{io.LimitReader(op.FS, maxBaseBytes), op.FS}, nil + return limitedReadCloser{&repo.CappedReader{R: rc, N: maxBaseBytes}, rc}, nil }) + if err != nil && lctx.Err() != nil && ctx.Err() == nil { + // The lookup's own deadline: an image can be slow on purpose, + // so this is "not checked", not a failure that may pass. + err = fmt.Errorf("reading the base image took longer than %s", baseLookupTime) + } cache[ref] = res{a, b, err} return a, b, err } @@ -265,8 +277,9 @@ func baseResolver(o engine.Options) func(context.Context, string) (bool, string, // Limits on base-image lookups: a source tree may not be yours, so what it // names cannot make a scan read without bound. const ( - maxBases = 32 - maxBaseBytes = 2 << 30 + maxBases = 32 + maxBaseBytes = 512 << 20 // one layer + baseLookupTime = 90 * time.Second ) type limitedReadCloser struct { diff --git a/internal/image/image.go b/internal/image/image.go index 652d60b..c7bfce3 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -2,6 +2,7 @@ package image import ( + "context" "fmt" "io" "net/http" @@ -114,6 +115,28 @@ func OpenRegistry(ref, platform string, rt http.RoundTripper) (*Opened, error) { return opened(img) } +// OpenBaseLayer returns the uncompressed tar of a registry image's first +// (lowest) layer, where a base image keeps its OS release files, through +// rt. Reading one layer bounds what a base-image lookup downloads. +// Every request, the layer download included, is bound to ctx. +func OpenBaseLayer(ctx context.Context, ref, platform string, rt http.RoundTripper) (io.ReadCloser, error) { + if strings.HasPrefix(ref, "docker-archive:") || strings.HasPrefix(ref, "rootfs:") { + return nil, fmt.Errorf("%q: only registry references can be opened here", ref) + } + img, err := loadRemoteCtx(ctx, ref, platform, rt) + if err != nil { + return nil, err + } + ls, err := img.Layers() + if err != nil { + return nil, err + } + if len(ls) == 0 { + return nil, fmt.Errorf("%s has no layers", ref) + } + return ls[0].Uncompressed() +} + func load(ref, platform string) (v1.Image, error) { if p, ok := strings.CutPrefix(ref, "docker-archive:"); ok { return tarball.ImageFromPath(p, nil) @@ -122,11 +145,15 @@ func load(ref, platform string) (v1.Image, error) { } func loadRemote(ref, platform string, rt http.RoundTripper) (v1.Image, error) { + return loadRemoteCtx(context.Background(), ref, platform, rt) +} + +func loadRemoteCtx(ctx context.Context, ref, platform string, rt http.RoundTripper) (v1.Image, error) { r, err := name.ParseReference(ref) if err != nil { return nil, fmt.Errorf("parsing image reference %q: %w", ref, err) } - opts := []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)} + opts := []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)} if rt != nil { opts = append(opts, remote.WithTransport(rt)) }