mirror of
https://github.com/anchore/syft.git
synced 2026-10-31 22:46:36 +01:00
* ci: run the full unit suite on windows via make unit Drops the curated `unit:windows` subset; the windows job now bootstraps like linux and runs `make unit`. Exceptions get added as failures show up. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * ci: skip fixture refresh for windows unit tests binny has no windows release, so the bootstrap action can't install the tools the fixture refresh needs. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * test: skip linux container fixtures when docker can't run them Windows CI runners only run windows containers, so image fixtures go through `testutils.SkipWithoutLinuxContainers`. No behavior change on linux/mac. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: close leaked file handles and posix squashfs paths on windows - output writers are now closed on early returns in `scan`/`convert` (open handles block cleanup on windows) - `newSBOMMultiWriter` used `path.Dir` on host paths, so output subdirectories were never created on windows - squashfs walk paths were built with `filepath.Join` - skip a few posix-only test cases (dir perms, xdg fallback, go-rpmdb ndb handle leak upstream) Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(golang): resolve go source module dir natively on windows - `parseGoModFile` passed the resolver's posix (`/c/...`) path to the go toolchain, so go source resolution silently found nothing on windows - skip container-built fixtures (go archs, java builds) and golden image tarballs on windows; stereoscope's layer cache filenames contain `:`, which windows rejects Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: keep posix paths posix on windows - `ChrootContext.ToChrootPath` nested volumes (`D:\cwd\C:\root`) when the scan root was on another drive, so the prefix was never trimmed - bitnami, python wheel/egg, and alpm parsers used `filepath` on posix location paths - alpm backup-file lookups silently missed on windows Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * ci: pull the test fixture cache for windows unit tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: don't map virtual file tree paths through the host on windows `FiletreeResolver` without a chroot (snaps) ran request paths through `filepath.Abs`, rooting them on the current volume (`/payload.txt` -> `/d/payload.txt`), so snap scans on windows found nothing. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: match tar entry globs as posix on windows - `matchesAnyGlob` used `doublestar.PathMatch` (host separator) on tar entry names, so tar-wrapped java archives silently produced nothing on windows - test helpers now treat binary snippet keys and `FromFile` locations as posix Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * incorporate windows path fix Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * incorporate go-rpmdb close fix Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * close squashfs fixture writers before finalizing - windows dir listings can report a zero size for files still open, so the manifest was packed empty (also adds a temporary windows diagnostic for the binary cataloger) Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * temp: surface windows binary cataloger diagnostics Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: keep directory symlink targets posix on windows - the directory indexer joined symlink targets with native separators, so links in the posix file tree pointed nowhere and globs on link names found nothing - binary fixture logical keys used native separators, so nested snippets were picked as the fixture dir on windows Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * keep addSymlinkToIndex under the funlen limit Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * drop windows skips covered by the stereoscope and go-rpmdb bumps Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package commands
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/anchore/clio"
|
|
"github.com/anchore/go-homedir"
|
|
"github.com/anchore/syft/cmd/syft/internal/options"
|
|
"github.com/anchore/syft/cmd/syft/internal/ui"
|
|
"github.com/anchore/syft/internal"
|
|
"github.com/anchore/syft/internal/bus"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/internal/spillbuf"
|
|
"github.com/anchore/syft/internal/tmpdir"
|
|
"github.com/anchore/syft/syft/format"
|
|
"github.com/anchore/syft/syft/format/syftjson"
|
|
"github.com/anchore/syft/syft/sbom"
|
|
)
|
|
|
|
const (
|
|
convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout
|
|
{{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json"
|
|
{{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json
|
|
SYFT_CONVERT_PASSTHROUGH_EXACT_FORMAT=true {{.appName}} {{.command}} img.syft.json -o syft-json=out.syft.json copy the input to "out.syft.json" unchanged if it is already syft-json at the current schema version
|
|
`
|
|
)
|
|
|
|
type ConvertOptions struct {
|
|
options.Config `yaml:",inline" mapstructure:",squash"`
|
|
options.Output `yaml:",inline" mapstructure:",squash"`
|
|
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
|
Convert options.Convert `yaml:"convert" json:"convert" mapstructure:"convert"`
|
|
}
|
|
|
|
func Convert(app clio.Application) *cobra.Command {
|
|
id := app.ID()
|
|
|
|
opts := &ConvertOptions{
|
|
UpdateCheck: options.DefaultUpdateCheck(),
|
|
Output: options.DefaultOutput(),
|
|
Convert: options.DefaultConvert(),
|
|
}
|
|
|
|
return app.SetupCommand(&cobra.Command{
|
|
Use: "convert [SOURCE-SBOM] -o [FORMAT]",
|
|
Short: "Convert between SBOM formats",
|
|
Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft/wiki/format-conversion",
|
|
Example: internal.Tprintf(convertExample, map[string]any{
|
|
"appName": id.Name,
|
|
"command": "convert",
|
|
}),
|
|
Args: validateConvertArgs,
|
|
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
restoreStdout := ui.CaptureStdoutToTraceLog()
|
|
defer restoreStdout()
|
|
|
|
return RunConvert(cmd.Context(), opts, args[0])
|
|
},
|
|
}, opts)
|
|
}
|
|
|
|
func validateConvertArgs(cmd *cobra.Command, args []string) error {
|
|
return validateArgs(cmd, args, "an SBOM argument is required")
|
|
}
|
|
|
|
func RunConvert(ctx context.Context, opts *ConvertOptions, userInput string) error {
|
|
log.Warn("convert is an experimental feature, run `syft convert -h` for help")
|
|
|
|
// a temp dir is only needed when STDIN is a pipe and has to be spilled to disk; it is created lazily on
|
|
// first use (under the user's TMPDIR), so a file input never touches it. Mirrors syft.CreateSBOM, which
|
|
// also honors a TempDir already attached to the context.
|
|
td := tmpdir.FromContext(ctx)
|
|
if td == nil {
|
|
_, td = tmpdir.Root(ctx, "syft-convert")
|
|
defer func() {
|
|
_ = td.Cleanup()
|
|
}()
|
|
}
|
|
|
|
reader, inputInfo, err := openConvertInput(userInput, td)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = reader.Close()
|
|
}()
|
|
|
|
outputs := opts.Outputs
|
|
var unchanged []string
|
|
if opts.Convert.PassthroughExactFormat {
|
|
log.Warn("convert.passthrough-exact-format is enabled: syft-json input that already exactly matches a requested output format and schema version will be copied to that output unchanged (this does not apply to any other input format)")
|
|
|
|
unchanged, outputs, err = partitionOutputsBySourceFormat(opts.Output, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// resolve (and validate) every output that needs converting before writing anything, so that
|
|
// a bad -o value fails the command without leaving partial results behind
|
|
var writer sbom.Writer
|
|
if len(outputs) > 0 {
|
|
outputOpts := opts.Output
|
|
outputOpts.Outputs = outputs
|
|
writer, err = outputOpts.SBOMWriter()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// output files are opened up front; release them if we bail out before writing
|
|
if c, ok := writer.(io.Closer); ok {
|
|
defer func() { _ = c.Close() }()
|
|
}
|
|
}
|
|
|
|
for _, output := range unchanged {
|
|
if err := writeUnchanged(output, opts.LegacyFile, inputInfo, reader); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if writer == nil {
|
|
return nil
|
|
}
|
|
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return fmt.Errorf("unable to seek to start of SBOM: %w", err)
|
|
}
|
|
|
|
s, _, _, err := format.Decode(reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode SBOM: %w", err)
|
|
}
|
|
|
|
if s == nil {
|
|
return fmt.Errorf("no SBOM produced")
|
|
}
|
|
|
|
if err := writer.Write(*s); err != nil {
|
|
return fmt.Errorf("failed to write SBOM: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// openConvertInput opens the SBOM document at the given path, or STDIN when "-". The returned reader is seekable so
|
|
// the document can be identified, copied, and decoded in turn without being loaded into memory. The returned
|
|
// os.FileInfo describes the file the document is being read from, so that writing back over it can be recognized;
|
|
// it is nil when the input is not a file (a pipe or a terminal on STDIN).
|
|
func openConvertInput(userInput string, td *tmpdir.TempDir) (io.ReadSeekCloser, os.FileInfo, error) {
|
|
if userInput == "-" {
|
|
return openStdin(td)
|
|
}
|
|
|
|
f, err := os.Open(userInput)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open SBOM file: %w", err)
|
|
}
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
// the document is readable, we just cannot recognize it as an output destination
|
|
log.WithFields("error", err, "path", userInput).Debug("unable to stat SBOM file")
|
|
}
|
|
return f, info, nil
|
|
}
|
|
|
|
// openStdin returns a seekable view of STDIN. When STDIN is a regular file (such as a shell redirect) it is read in
|
|
// place. A pipe or terminal cannot seek (you will get errors such as "seek /dev/stdin: illegal seek"), so its
|
|
// contents are drained into a spill buffer: a small head stays in memory and everything beyond it goes to a temp
|
|
// file under td, so an arbitrarily large piped SBOM never has to be held in memory to be made seekable.
|
|
func openStdin(td *tmpdir.TempDir) (io.ReadSeekCloser, os.FileInfo, error) {
|
|
if info, err := os.Stdin.Stat(); err == nil && info.Mode().IsRegular() {
|
|
if start, err := os.Stdin.Seek(0, io.SeekCurrent); err == nil {
|
|
// a redirect reads the file in place, so it is just as much an input file as a path argument is
|
|
return readSeekNopCloser{io.NewSectionReader(os.Stdin, start, info.Size()-start)}, info, nil
|
|
}
|
|
}
|
|
|
|
buf := spillbuf.New(td)
|
|
if _, err := io.Copy(io.NewOffsetWriter(buf, 0), os.Stdin); err != nil {
|
|
_ = buf.Close()
|
|
return nil, nil, fmt.Errorf("failed to read SBOM from STDIN: %w", err)
|
|
}
|
|
|
|
return &spilledReader{
|
|
SectionReader: io.NewSectionReader(buf, 0, buf.Size()),
|
|
buf: buf,
|
|
}, nil, nil
|
|
}
|
|
|
|
type readSeekNopCloser struct {
|
|
io.ReadSeeker
|
|
}
|
|
|
|
func (readSeekNopCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// spilledReader is a seekable view over a spill buffer; closing it releases the buffer's memory and spill file.
|
|
type spilledReader struct {
|
|
*io.SectionReader
|
|
buf *spillbuf.Buffer
|
|
}
|
|
|
|
func (r *spilledReader) Close() error {
|
|
return r.buf.Close()
|
|
}
|
|
|
|
// partitionOutputsBySourceFormat splits the requested outputs (in "<format>[@<version>][=<path>]" form) into those
|
|
// that exactly match the format and schema version of a syft-json input document (which can be written out
|
|
// unchanged) and those that still require conversion. Input in any other format is always converted. Outputs that
|
|
// cannot be resolved to an encoder are left for the writer to report on.
|
|
func partitionOutputsBySourceFormat(output options.Output, reader io.ReadSeeker) (unchanged []string, toConvert []string, err error) {
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return nil, nil, fmt.Errorf("unable to seek to start of SBOM: %w", err)
|
|
}
|
|
|
|
id, version := format.Identify(reader)
|
|
if id == "" || version == "" {
|
|
// let decoding surface the appropriate error
|
|
return nil, output.Outputs, nil
|
|
}
|
|
|
|
if id != syftjson.ID {
|
|
log.WithFields("format", id, "version", version).Debug("input is not syft-json, passthrough does not apply")
|
|
return nil, output.Outputs, nil
|
|
}
|
|
|
|
encoders, err := output.Encoders()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
collection := format.NewEncoderCollection(encoders...)
|
|
|
|
for _, out := range output.Outputs {
|
|
name, _, _ := strings.Cut(strings.TrimSpace(out), "=")
|
|
enc := collection.GetByString(name)
|
|
if enc != nil && enc.ID() == id && enc.Version() == version {
|
|
log.WithFields("format", id, "version", version, "output", out).Warn("input already exactly matches the requested output format and schema version, copying it unchanged instead of converting it")
|
|
unchanged = append(unchanged, out)
|
|
continue
|
|
}
|
|
toConvert = append(toConvert, out)
|
|
}
|
|
|
|
return unchanged, toConvert, nil
|
|
}
|
|
|
|
// writeUnchanged copies the SBOM document as-is to the destination described by the given "<format>[=<path>]"
|
|
// output value, falling back to the (deprecated) --file path, and finally to the report bus (STDOUT). When the
|
|
// destination is the input file itself there is nothing to do.
|
|
func writeUnchanged(output string, defaultFile string, inputInfo os.FileInfo, reader io.ReadSeeker) error {
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return fmt.Errorf("unable to seek to start of SBOM: %w", err)
|
|
}
|
|
|
|
_, path, _ := strings.Cut(strings.TrimSpace(output), "=")
|
|
if path == "" {
|
|
path = defaultFile
|
|
}
|
|
|
|
if path == "" {
|
|
// the report bus only carries strings, so STDOUT output must be materialized in memory
|
|
content, err := io.ReadAll(reader) //nolint:gocritic // the whole SBOM is the report; there is no meaningful bound
|
|
if err != nil {
|
|
return fmt.Errorf("unable to read SBOM: %w", err)
|
|
}
|
|
bus.Report(string(content))
|
|
return nil
|
|
}
|
|
|
|
expandedPath, err := homedir.Expand(path)
|
|
if err != nil {
|
|
log.Warnf("could not expand given writer output path=%q: %w", path, err)
|
|
expandedPath = path
|
|
}
|
|
|
|
if isSameFile(inputInfo, expandedPath) {
|
|
log.WithFields("path", expandedPath).Info("output is the input file, leaving it as-is")
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(expandedPath), 0755); err != nil {
|
|
return fmt.Errorf("unable to create report directory: %w", err)
|
|
}
|
|
|
|
f, err := os.OpenFile(expandedPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create report file: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(f, reader); err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("unable to write report file: %w", err)
|
|
}
|
|
|
|
return f.Close()
|
|
}
|
|
|
|
// isSameFile reports whether the given path names the file the input is being read from. Comparing the input's
|
|
// os.FileInfo rather than its path covers a shell redirect onto STDIN, which has no path to compare.
|
|
func isSameFile(inputInfo os.FileInfo, path string) bool {
|
|
if inputInfo == nil {
|
|
return false
|
|
}
|
|
pathInfo, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return os.SameFile(inputInfo, pathInfo)
|
|
}
|