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 "[@][=]" 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 "[=]" // 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) }