Migrate format definitions to sbom package (#864)

This commit is contained in:
Alex Goodman 2022-03-04 17:22:40 -05:00 committed by GitHub
parent 640099ce2e
commit 4af32c5bee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 580 additions and 401 deletions

View file

@ -9,6 +9,10 @@ import (
"os"
"strings"
"github.com/anchore/syft/internal/formats/cyclonedx13json"
"github.com/anchore/syft/internal/formats/spdx22json"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/internal"
@ -17,7 +21,7 @@ import (
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
"github.com/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors"
@ -49,7 +53,11 @@ const (
intotoJSONDsseType = `application/vnd.in-toto+json`
)
var attestFormats = []format.Option{format.SPDXJSONOption, format.CycloneDxJSONOption, format.JSONOption}
var attestFormats = []sbom.FormatID{
syftjson.ID,
spdx22json.ID,
cyclonedx13json.ID,
}
var (
attestCmd = &cobra.Command{
@ -149,10 +157,10 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
return fmt.Errorf("unable to generate attestation for more than one output")
}
output := format.ParseOption(appConfig.Output[0])
predicateType := assertPredicateType(output)
format := syft.FormatByName(appConfig.Output[0])
predicateType := formatPredicateType(format)
if predicateType == "" {
return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", output, attestFormats)
return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", formatAliases(format.ID()), formatAliases(attestFormats...))
}
passFunc, err := selectPassFunc(appConfig.Attest.Key)
@ -172,7 +180,7 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
defer sv.Close()
return eventLoop(
attestationExecWorker(*si, output, predicateType, sv),
attestationExecWorker(*si, format, predicateType, sv),
setupSignals(),
eventSubscription,
stereoscope.Cleanup,
@ -180,7 +188,7 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
)
}
func attestationExecWorker(sourceInput source.Input, output format.Option, predicateType string, sv *sign.SignerVerifier) <-chan error {
func attestationExecWorker(sourceInput source.Input, format sbom.Format, predicateType string, sv *sign.SignerVerifier) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
@ -200,7 +208,7 @@ func attestationExecWorker(sourceInput source.Input, output format.Option, predi
return
}
sbomBytes, err := syft.Encode(*s, output)
sbomBytes, err := syft.Encode(*s, format)
if err != nil {
errs <- err
return
@ -215,14 +223,14 @@ func attestationExecWorker(sourceInput source.Input, output format.Option, predi
return errs
}
func assertPredicateType(output format.Option) string {
switch output {
case format.SPDXJSONOption:
func formatPredicateType(format sbom.Format) string {
switch format.ID() {
case spdx22json.ID:
return in_toto.PredicateSPDX
// Tentative see https://github.com/in-toto/attestation/issues/82
case format.CycloneDxJSONOption:
case cyclonedx13json.ID:
// Tentative see https://github.com/in-toto/attestation/issues/82
return "https://cyclonedx.org/bom"
case format.JSONOption:
case syftjson.ID:
return "https://syft.dev/bom"
default:
return ""
@ -293,8 +301,8 @@ func setAttestFlags(flags *pflag.FlagSet) {
// in-toto attestations only support JSON predicates, so not all SBOM formats that syft can output are supported
flags.StringP(
"output", "o", string(format.JSONOption),
fmt.Sprintf("the SBOM format encapsulated within the attestation, available options=%v", attestFormats),
"output", "o", formatAliases(syftjson.ID)[0],
fmt.Sprintf("the SBOM format encapsulated within the attestation, available options=%v", formatAliases(attestFormats...)),
)
}

30
cmd/format_aliases.go Normal file
View file

@ -0,0 +1,30 @@
package cmd
import (
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/sbom"
)
func formatAliases(ids ...sbom.FormatID) (aliases []string) {
for _, id := range ids {
switch id {
case syft.JSONFormatID:
aliases = append(aliases, "syft-json")
case syft.TextFormatID:
aliases = append(aliases, "text")
case syft.TableFormatID:
aliases = append(aliases, "table")
case syft.SPDXJSONFormatID:
aliases = append(aliases, "spdx-json")
case syft.SPDXTagValueFormatID:
aliases = append(aliases, "spdx-tag-value")
case syft.CycloneDxXMLFormatID:
aliases = append(aliases, "cyclonedx-xml")
case syft.CycloneDxJSONFormatID:
aliases = append(aliases, "cyclonedx-json")
default:
aliases = append(aliases, string(id))
}
}
return aliases
}

View file

@ -4,9 +4,10 @@ import (
"fmt"
"strings"
"github.com/anchore/syft/internal/formats"
"github.com/anchore/syft/internal/output"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/sbom"
"github.com/hashicorp/go-multierror"
)
@ -19,7 +20,7 @@ func makeWriter(outputs []string, defaultFile string) (sbom.Writer, error) {
return nil, err
}
writer, err := output.MakeWriter(outputOptions...)
writer, err := sbom.NewWriter(outputOptions...)
if err != nil {
return nil, err
}
@ -28,10 +29,10 @@ func makeWriter(outputs []string, defaultFile string) (sbom.Writer, error) {
}
// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file
func parseOptions(outputs []string, defaultFile string) (out []output.WriterOption, errs error) {
func parseOptions(outputs []string, defaultFile string) (out []sbom.WriterOption, errs error) {
// always should have one option -- we generally get the default of "table", but just make sure
if len(outputs) == 0 {
outputs = append(outputs, string(format.TableOption))
outputs = append(outputs, string(table.ID))
}
for _, name := range outputs {
@ -40,31 +41,25 @@ func parseOptions(outputs []string, defaultFile string) (out []output.WriterOpti
// split to at most two parts for <format>=<file>
parts := strings.SplitN(name, "=", 2)
// the format option is the first part
// the format name is the first part
name = parts[0]
// default to the --file or empty string if not specified
file := defaultFile
// If a file is specified as part of the output option, use that
// If a file is specified as part of the output formatName, use that
if len(parts) > 1 {
file = parts[1]
}
option := format.ParseOption(name)
if option == format.UnknownFormatOption {
format := syft.FormatByName(name)
if format == nil {
errs = multierror.Append(errs, fmt.Errorf("bad output format: '%s'", name))
continue
}
encoder := formats.ByOption(option)
if encoder == nil {
errs = multierror.Append(errs, fmt.Errorf("unknown format: %s", outputFormat))
continue
}
out = append(out, output.WriterOption{
Format: *encoder,
out = append(out, sbom.WriterOption{
Format: format,
Path: file,
})
}

View file

@ -6,6 +6,10 @@ import (
"io/ioutil"
"os"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/syft"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/anchore"
@ -15,7 +19,6 @@ import (
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/pkg/cataloger"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
@ -100,8 +103,8 @@ func setPackageFlags(flags *pflag.FlagSet) {
fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes))
flags.StringArrayP(
"output", "o", []string{string(format.TableOption)},
fmt.Sprintf("report output format, options=%v", format.AllOptions),
"output", "o", formatAliases(table.ID),
fmt.Sprintf("report output format, options=%v", formatAliases(syft.FormatIDs()...)),
)
flags.StringP(

View file

@ -9,7 +9,6 @@ import (
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/output"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/artifact"
@ -74,7 +73,7 @@ func powerUserExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme
userInput := args[0]
writer, err := output.MakeWriter(output.WriterOption{
writer, err := sbom.NewWriter(sbom.WriterOption{
Format: syftjson.Format(),
Path: appConfig.File,
})

View file

@ -11,7 +11,7 @@ import (
"github.com/spf13/cobra"
)
var outputFormat string
var versionCmdOutputFormat string
var versionCmd = &cobra.Command{
Use: "version",
@ -20,14 +20,14 @@ var versionCmd = &cobra.Command{
}
func init() {
versionCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "format to show version information (available=[text, json])")
versionCmd.Flags().StringVarP(&versionCmdOutputFormat, "output", "o", "text", "format to show version information (available=[text, json])")
rootCmd.AddCommand(versionCmd)
}
func printVersion(_ *cobra.Command, _ []string) {
versionInfo := version.FromBuild()
switch outputFormat {
switch versionCmdOutputFormat {
case "text":
fmt.Println("Application: ", internal.ApplicationName)
fmt.Println("Version: ", versionInfo.Version)
@ -54,7 +54,7 @@ func printVersion(_ *cobra.Command, _ []string) {
os.Exit(1)
}
default:
fmt.Printf("unsupported output format: %s\n", outputFormat)
fmt.Printf("unsupported output format: %s\n", versionCmdOutputFormat)
os.Exit(1)
}
}

View file

@ -6,14 +6,13 @@ import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
func GetValidator(format cyclonedx.BOMFileFormat) format.Validator {
func GetValidator(format cyclonedx.BOMFileFormat) sbom.Validator {
return func(reader io.Reader) error {
bom := &cyclonedx.BOM{}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)
@ -28,7 +27,7 @@ func GetValidator(format cyclonedx.BOMFileFormat) format.Validator {
}
}
func GetDecoder(format cyclonedx.BOMFileFormat) format.Decoder {
func GetDecoder(format cyclonedx.BOMFileFormat) sbom.Decoder {
return func(reader io.Reader) (*sbom.SBOM, error) {
bom := &cyclonedx.BOM{}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)

View file

@ -9,7 +9,6 @@ import (
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
@ -32,7 +31,7 @@ func FromSnapshot() ImageOption {
}
}
func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, format format.Format, sbom sbom.SBOM, testImage string, updateSnapshot bool, redactors ...redactor) {
func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, format sbom.Format, sbom sbom.SBOM, testImage string, updateSnapshot bool, redactors ...redactor) {
var buffer bytes.Buffer
// grab the latest image contents and persist
@ -66,7 +65,7 @@ func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, format format.Format,
}
}
func AssertEncoderAgainstGoldenSnapshot(t *testing.T, format format.Format, sbom sbom.SBOM, updateSnapshot bool, redactors ...redactor) {
func AssertEncoderAgainstGoldenSnapshot(t *testing.T, format sbom.Format, sbom sbom.SBOM, updateSnapshot bool, redactors ...redactor) {
var buffer bytes.Buffer
err := format.Encode(&buffer, sbom)

View file

@ -3,12 +3,14 @@ package cyclonedx13json
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/formats/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/sbom"
)
func Format() format.Format {
return format.NewFormat(
format.CycloneDxJSONOption,
const ID sbom.FormatID = "cyclonedx-1-json"
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
cyclonedxhelpers.GetDecoder(cyclonedx.BOMFileFormatJSON),
cyclonedxhelpers.GetValidator(cyclonedx.BOMFileFormatJSON),

View file

@ -3,12 +3,14 @@ package cyclonedx13xml
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/formats/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/sbom"
)
func Format() format.Format {
return format.NewFormat(
format.CycloneDxXMLOption,
const ID sbom.FormatID = "cyclonedx-1-xml"
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
cyclonedxhelpers.GetDecoder(cyclonedx.BOMFileFormatXML),
cyclonedxhelpers.GetValidator(cyclonedx.BOMFileFormatXML),

View file

@ -1,46 +0,0 @@
package formats
import (
"bytes"
"github.com/anchore/syft/internal/formats/cyclonedx13json"
"github.com/anchore/syft/internal/formats/cyclonedx13xml"
"github.com/anchore/syft/internal/formats/spdx22json"
"github.com/anchore/syft/internal/formats/spdx22tagvalue"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/internal/formats/text"
"github.com/anchore/syft/syft/format"
)
// TODO: eventually this is the source of truth for all formatters
func All() []format.Format {
return []format.Format{
syftjson.Format(),
table.Format(),
cyclonedx13xml.Format(),
cyclonedx13json.Format(),
spdx22json.Format(),
spdx22tagvalue.Format(),
text.Format(),
}
}
func Identify(by []byte) (*format.Format, error) {
for _, f := range All() {
if err := f.Validate(bytes.NewReader(by)); err != nil {
continue
}
return &f, nil
}
return nil, nil
}
func ByOption(option format.Option) *format.Format {
for _, f := range All() {
if f.Option == option {
return &f
}
}
return nil
}

View file

@ -1,34 +0,0 @@
package formats
import (
"io"
"os"
"testing"
"github.com/anchore/syft/syft/format"
"github.com/stretchr/testify/assert"
)
func TestIdentify(t *testing.T) {
tests := []struct {
fixture string
expected format.Option
}{
{
fixture: "test-fixtures/alpine-syft.json",
expected: format.JSONOption,
},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
f, err := os.Open(test.fixture)
assert.NoError(t, err)
by, err := io.ReadAll(f)
assert.NoError(t, err)
frmt, err := Identify(by)
assert.NoError(t, err)
assert.NotNil(t, frmt)
assert.Equal(t, test.expected, frmt.Option)
})
}
}

View file

@ -1,11 +1,15 @@
package spdx22json
import "github.com/anchore/syft/syft/format"
import (
"github.com/anchore/syft/syft/sbom"
)
const ID sbom.FormatID = "spdx-2-json"
// note: this format is LOSSY relative to the syftjson format
func Format() format.Format {
return format.NewFormat(
format.SPDXJSONOption,
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
decoder,
validator,

View file

@ -1,11 +1,15 @@
package spdx22tagvalue
import "github.com/anchore/syft/syft/format"
import (
"github.com/anchore/syft/syft/sbom"
)
const ID sbom.FormatID = "spdx-2-tag-value"
// note: this format is LOSSY relative to the syftjson formation, which means that decoding and validation is not supported at this time
func Format() format.Format {
return format.NewFormat(
format.SPDXTagValueOption,
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
decoder,
validator,

View file

@ -221,7 +221,7 @@ func toFormatPackages(catalog *pkg.Catalog) map[spdx.ElementID]*spdx.Package2_2
// 3.17: Copyright Text: copyright notice(s) text, "NONE" or "NOASSERTION"
// Cardinality: mandatory, one
// Purpose: Identify the copyright holders of the package, as well as any dates present. This will be a free form text field extracted from package information files. The options to populate this field are limited to:
// Purpose: IdentifyFormat the copyright holders of the package, as well as any dates present. This will be a free form text field extracted from package information files. The options to populate this field are limited to:
//
// Any text related to a copyright notice, even if not complete;
// NONE if the package contains no copyright information whatsoever; or

View file

@ -1,10 +1,14 @@
package syftjson
import "github.com/anchore/syft/syft/format"
import (
"github.com/anchore/syft/syft/sbom"
)
func Format() format.Format {
return format.NewFormat(
format.JSONOption,
const ID sbom.FormatID = "syft-3-json"
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
decoder,
validator,

View file

@ -1,10 +1,14 @@
package table
import "github.com/anchore/syft/syft/format"
import (
"github.com/anchore/syft/syft/sbom"
)
func Format() format.Format {
return format.NewFormat(
format.TableOption,
const ID sbom.FormatID = "syft-table"
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
nil,
nil,

View file

@ -1,10 +1,14 @@
package text
import "github.com/anchore/syft/syft/format"
import (
"github.com/anchore/syft/syft/sbom"
)
func Format() format.Format {
return format.NewFormat(
format.TextOption,
const ID sbom.FormatID = "syft-text"
func Format() sbom.Format {
return sbom.NewFormat(
ID,
encoder,
nil,
nil,

View file

@ -6,12 +6,7 @@ import "sort"
type StringSet map[string]struct{}
// NewStringSet creates a new empty StringSet.
func NewStringSet() StringSet {
return make(StringSet)
}
// NewStringSetFromSlice creates a StringSet populated with values from the given slice.
func NewStringSetFromSlice(start []string) StringSet {
func NewStringSet(start ...string) StringSet {
ret := make(StringSet)
for _, s := range start {
ret.Add(s)

View file

@ -6,18 +6,10 @@ import (
"io"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/internal/formats"
"github.com/anchore/syft/syft/format"
)
// Encode takes all SBOM elements and a format option and encodes an SBOM document.
func Encode(s sbom.SBOM, option format.Option) ([]byte, error) {
f := formats.ByOption(option)
if f == nil {
return nil, fmt.Errorf("unsupported format: %+v", option)
}
func Encode(s sbom.SBOM, f sbom.Format) ([]byte, error) {
buff := bytes.Buffer{}
if err := f.Encode(&buff, s); err != nil {
@ -28,19 +20,17 @@ func Encode(s sbom.SBOM, option format.Option) ([]byte, error) {
}
// Decode takes a reader for an SBOM and generates all internal SBOM elements.
func Decode(reader io.Reader) (*sbom.SBOM, format.Option, error) {
func Decode(reader io.Reader) (*sbom.SBOM, sbom.Format, error) {
by, err := io.ReadAll(reader)
if err != nil {
return nil, format.UnknownFormatOption, fmt.Errorf("unable to read sbom: %w", err)
return nil, nil, fmt.Errorf("unable to read sbom: %w", err)
}
f, err := formats.Identify(by)
if err != nil {
return nil, format.UnknownFormatOption, fmt.Errorf("unable to detect format: %w", err)
}
f := IdentifyFormat(by)
if f == nil {
return nil, format.UnknownFormatOption, fmt.Errorf("unable to identify format")
return nil, nil, fmt.Errorf("unable to identify format")
}
s, err := f.Decode(bytes.NewReader(by))
return s, f.Option, err
return s, f, err
}

View file

@ -1,10 +0,0 @@
package format
import (
"io"
"github.com/anchore/syft/syft/sbom"
)
// Decoder is a function that can convert an SBOM document of a specific format from a reader into Syft native objects.
type Decoder func(reader io.Reader) (*sbom.SBOM, error)

View file

@ -1,10 +0,0 @@
package format
import (
"io"
"github.com/anchore/syft/syft/sbom"
)
// Encoder is a function that can transform Syft native objects into an SBOM document of a specific format written to the given writer.
type Encoder func(io.Writer, sbom.SBOM) error

View file

@ -1,52 +0,0 @@
package format
import (
"errors"
"io"
"github.com/anchore/syft/syft/sbom"
)
var (
ErrEncodingNotSupported = errors.New("encoding not supported")
ErrDecodingNotSupported = errors.New("decoding not supported")
ErrValidationNotSupported = errors.New("validation not supported")
)
type Format struct {
Option Option
encoder Encoder
decoder Decoder
validator Validator
}
func NewFormat(option Option, encoder Encoder, decoder Decoder, validator Validator) Format {
return Format{
Option: option,
encoder: encoder,
decoder: decoder,
validator: validator,
}
}
func (f Format) Encode(output io.Writer, s sbom.SBOM) error {
if f.encoder == nil {
return ErrEncodingNotSupported
}
return f.encoder(output, s)
}
func (f Format) Decode(reader io.Reader) (*sbom.SBOM, error) {
if f.decoder == nil {
return nil, ErrDecodingNotSupported
}
return f.decoder(reader)
}
func (f Format) Validate(reader io.Reader) error {
if f.validator == nil {
return ErrValidationNotSupported
}
return f.validator(reader)
}

View file

@ -1,49 +0,0 @@
package format
import "strings"
const (
UnknownFormatOption Option = "UnknownFormatOption"
JSONOption Option = "json"
TextOption Option = "text"
TableOption Option = "table"
CycloneDxXMLOption Option = "cyclonedx"
CycloneDxJSONOption Option = "cyclonedx-json"
SPDXTagValueOption Option = "spdx-tag-value"
SPDXJSONOption Option = "spdx-json"
)
var AllOptions = []Option{
JSONOption,
TextOption,
TableOption,
CycloneDxXMLOption,
CycloneDxJSONOption,
SPDXTagValueOption,
SPDXJSONOption,
}
type Option string
func ParseOption(userStr string) Option {
switch strings.ToLower(userStr) {
case string(JSONOption):
return JSONOption
case string(TextOption):
return TextOption
case string(TableOption):
return TableOption
case string(CycloneDxXMLOption), "cyclone", "cyclone-dx", "cyclone-dx-xml", "cyclone-xml":
// NOTE(jonasagx): setting "cyclone" to XML by default for retro-compatibility.
// If we want to show no preference between XML and JSON please remove it.
return CycloneDxXMLOption
case string(CycloneDxJSONOption), "cyclone-json", "cyclone-dx-json":
return CycloneDxJSONOption
case string(SPDXTagValueOption), "spdx", "spdx-tagvalue", "spdxtagvalue", "spdx-tv", "spdxtv":
return SPDXTagValueOption
case string(SPDXJSONOption), "spdxjson":
return SPDXJSONOption
default:
return UnknownFormatOption
}
}

View file

@ -1,12 +0,0 @@
package format
import "io"
// Validator reads the SBOM from the given reader and assesses whether the document conforms to the specific SBOM format.
// The validator should positively confirm if the SBOM is not only the format but also has the minimal set of values
// that the format requires. For example, all syftjson formatted documents have a schema section which should have
// "anchore/syft" within the version --if this isn't found then the validator should raise an error. These active
// assertions protect against "simple" format decoding validations that may lead to false positives (e.g. I decoded
// json successfully therefore this must be the target format, however, all values are their default zero-value and
// really represent a different format that also uses json)
type Validator func(reader io.Reader) error

100
syft/formats.go Normal file
View file

@ -0,0 +1,100 @@
package syft
import (
"bytes"
"strings"
"github.com/anchore/syft/internal/formats/cyclonedx13json"
"github.com/anchore/syft/internal/formats/cyclonedx13xml"
"github.com/anchore/syft/internal/formats/spdx22json"
"github.com/anchore/syft/internal/formats/spdx22tagvalue"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/internal/formats/text"
"github.com/anchore/syft/syft/sbom"
)
// these have been exported for the benefit of API users
const (
JSONFormatID = syftjson.ID
TextFormatID = text.ID
TableFormatID = table.ID
CycloneDxXMLFormatID = cyclonedx13xml.ID
CycloneDxJSONFormatID = cyclonedx13json.ID
SPDXTagValueFormatID = spdx22tagvalue.ID
SPDXJSONFormatID = spdx22json.ID
)
var formats []sbom.Format
func init() {
formats = []sbom.Format{
syftjson.Format(),
cyclonedx13xml.Format(),
cyclonedx13json.Format(),
spdx22tagvalue.Format(),
spdx22json.Format(),
table.Format(),
text.Format(),
}
}
func FormatIDs() (ids []sbom.FormatID) {
for _, f := range formats {
ids = append(ids, f.ID())
}
return ids
}
func FormatByID(id sbom.FormatID) sbom.Format {
for _, f := range formats {
if f.ID() == id {
return f
}
}
return nil
}
func FormatByName(name string) sbom.Format {
cleanName := cleanFormatName(name)
for _, f := range formats {
if cleanFormatName(string(f.ID())) == cleanName {
return f
}
}
// handle any aliases for any supported format
switch cleanName {
case "json", "syftjson":
return FormatByID(syftjson.ID)
case "cyclonedx", "cyclone", "cyclonedxxml":
return FormatByID(cyclonedx13xml.ID)
case "cyclonedxjson":
return FormatByID(cyclonedx13json.ID)
case "spdx", "spdxtv", "spdxtagvalue":
return FormatByID(spdx22tagvalue.ID)
case "spdxjson":
return FormatByID(spdx22json.ID)
case "table":
return FormatByID(table.ID)
case "text":
return FormatByID(text.ID)
}
return nil
}
func cleanFormatName(name string) string {
r := strings.NewReplacer("-", "", "_", "")
return strings.ToLower(r.Replace(name))
}
func IdentifyFormat(by []byte) sbom.Format {
for _, f := range formats {
if err := f.Validate(bytes.NewReader(by)); err != nil {
continue
}
return f
}
return nil
}

157
syft/formats_test.go Normal file
View file

@ -0,0 +1,157 @@
package syft
import (
"github.com/anchore/syft/internal/formats/cyclonedx13json"
"github.com/anchore/syft/internal/formats/cyclonedx13xml"
"github.com/anchore/syft/internal/formats/spdx22json"
"github.com/anchore/syft/internal/formats/spdx22tagvalue"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/internal/formats/text"
"github.com/anchore/syft/syft/sbom"
"github.com/stretchr/testify/require"
"io"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIdentify(t *testing.T) {
tests := []struct {
fixture string
expected sbom.FormatID
}{
{
fixture: "test-fixtures/alpine-syft.json",
expected: syftjson.ID,
},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
f, err := os.Open(test.fixture)
assert.NoError(t, err)
by, err := io.ReadAll(f)
assert.NoError(t, err)
frmt := IdentifyFormat(by)
assert.NotNil(t, frmt)
assert.Equal(t, test.expected, frmt.ID())
})
}
}
func TestFormatByName(t *testing.T) {
tests := []struct {
name string
want sbom.FormatID
}{
// SPDX Tag-Value
{
name: "spdx",
want: spdx22tagvalue.ID,
},
{
name: "spdx-tag-value",
want: spdx22tagvalue.ID,
},
{
name: "spdx-tv",
want: spdx22tagvalue.ID,
},
{
name: "spdxtv", // clean variant
want: spdx22tagvalue.ID,
},
{
name: "spdx-2-tag-value", // clean variant
want: spdx22tagvalue.ID,
},
{
name: "spdx-2-tagvalue", // clean variant
want: spdx22tagvalue.ID,
},
{
name: "spdx2-tagvalue", // clean variant
want: spdx22tagvalue.ID,
},
// SPDX JSON
{
name: "spdx-json",
want: spdx22json.ID,
},
{
name: "spdx-2-json",
want: spdx22json.ID,
},
// Cyclonedx JSON
{
name: "cyclonedx-json",
want: cyclonedx13json.ID,
},
{
name: "cyclonedx-1-json",
want: cyclonedx13json.ID,
},
// Cyclonedx XML
{
name: "cyclonedx",
want: cyclonedx13xml.ID,
},
{
name: "cyclonedx-xml",
want: cyclonedx13xml.ID,
},
{
name: "cyclonedx-1-xml",
want: cyclonedx13xml.ID,
},
// Syft Table
{
name: "table",
want: table.ID,
},
{
name: "syft-table",
want: table.ID,
},
// Syft Text
{
name: "text",
want: text.ID,
},
{
name: "syft-text",
want: text.ID,
},
// Syft JSON
{
name: "json",
want: syftjson.ID,
},
{
name: "syft-json",
want: syftjson.ID,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := FormatByName(tt.name)
if tt.want == "" {
require.Nil(t, f)
return
}
require.NotNil(t, f)
assert.Equal(t, tt.want, f.ID())
})
}
}

View file

@ -14,7 +14,7 @@ import (
// integrity check
var _ common.ParserFn = parseGemFileLockEntries
var sectionsOfInterest = internal.NewStringSetFromSlice([]string{"GEM"})
var sectionsOfInterest = internal.NewStringSet("GEM")
// parseGemFileLockEntries is a parser function for Gemfile.lock contents, returning all Gems discovered.
func parseGemFileLockEntries(_ string, reader io.Reader) ([]*pkg.Package, []artifact.Relationship, error) {

78
syft/sbom/format.go Normal file
View file

@ -0,0 +1,78 @@
package sbom
import (
"errors"
"io"
)
var (
ErrEncodingNotSupported = errors.New("encoding not supported")
ErrDecodingNotSupported = errors.New("decoding not supported")
ErrValidationNotSupported = errors.New("validation not supported")
)
type FormatID string
type Format interface {
ID() FormatID
Encode(io.Writer, SBOM) error
Decode(io.Reader) (*SBOM, error)
Validate(io.Reader) error
}
type format struct {
id FormatID
encoder Encoder
decoder Decoder
validator Validator
}
// Decoder is a function that can convert an SBOM document of a specific format from a reader into Syft native objects.
type Decoder func(reader io.Reader) (*SBOM, error)
// Encoder is a function that can transform Syft native objects into an SBOM document of a specific format written to the given writer.
type Encoder func(io.Writer, SBOM) error
// Validator reads the SBOM from the given reader and assesses whether the document conforms to the specific SBOM format.
// The validator should positively confirm if the SBOM is not only the format but also has the minimal set of values
// that the format requires. For example, all syftjson formatted documents have a schema section which should have
// "anchore/syft" within the version --if this isn't found then the validator should raise an error. These active
// assertions protect against "simple" format decoding validations that may lead to false positives (e.g. I decoded
// json successfully therefore this must be the target format, however, all values are their default zero-value and
// really represent a different format that also uses json)
type Validator func(reader io.Reader) error
func NewFormat(id FormatID, encoder Encoder, decoder Decoder, validator Validator) Format {
return &format{
id: id,
encoder: encoder,
decoder: decoder,
validator: validator,
}
}
func (f format) ID() FormatID {
return f.id
}
func (f format) Encode(output io.Writer, s SBOM) error {
if f.encoder == nil {
return ErrEncodingNotSupported
}
return f.encoder(output, s)
}
func (f format) Decode(reader io.Reader) (*SBOM, error) {
if f.decoder == nil {
return nil, ErrDecodingNotSupported
}
return f.decoder(reader)
}
func (f format) Validate(reader io.Reader) error {
if f.validator == nil {
return ErrValidationNotSupported
}
return f.validator(reader)
}

View file

@ -1,71 +1,28 @@
package output
package sbom
import (
"fmt"
"io"
"os"
"path"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/internal/log"
"github.com/hashicorp/go-multierror"
)
// streamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
type streamWriter struct {
format format.Format
out io.Writer
close func() error
}
// Write the provided SBOM to the data stream
func (w *streamWriter) Write(s sbom.SBOM) error {
return w.format.Encode(w.out, s)
}
// Close any resources, such as open files
func (w *streamWriter) Close() error {
if w.close != nil {
return w.close()
}
return nil
}
// multiWriter holds a list of child sbom.Writers to apply all Write and Close operations to
type multiWriter struct {
writers []sbom.Writer
}
// Write writes the SBOM to all writers
func (m *multiWriter) Write(s sbom.SBOM) (errs error) {
for _, w := range m.writers {
err := w.Write(s)
if err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}
// Close closes all writers
func (m *multiWriter) Close() (errs error) {
for _, w := range m.writers {
err := w.Close()
if err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
writers []Writer
}
// WriterOption Format and path strings used to create sbom.Writer
type WriterOption struct {
Format format.Format
Format Format
Path string
}
// MakeWriter create all report writers from input options; if a file is not specified, os.Stdout is used
func MakeWriter(options ...WriterOption) (_ sbom.Writer, errs error) {
// NewWriter create all report writers from input options; if a file is not specified, os.Stdout is used
func NewWriter(options ...WriterOption) (Writer, error) {
if len(options) == 0 {
return nil, fmt.Errorf("no output options provided")
}
@ -73,9 +30,9 @@ func MakeWriter(options ...WriterOption) (_ sbom.Writer, errs error) {
out := &multiWriter{}
defer func() {
if errs != nil {
// close any previously opened files; we can't really recover from any errors
_ = out.Close()
// close any previously opened files; we can't really recover from any errors
if err := out.Close(); err != nil {
log.Warnf("unable to close sbom writers: %+v", err)
}
}()
@ -114,3 +71,25 @@ func MakeWriter(options ...WriterOption) (_ sbom.Writer, errs error) {
return out, nil
}
// Write writes the SBOM to all writers
func (m *multiWriter) Write(s SBOM) (errs error) {
for _, w := range m.writers {
err := w.Write(s)
if err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}
// Close closes all writers
func (m *multiWriter) Close() (errs error) {
for _, w := range m.writers {
err := w.Close()
if err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}

View file

@ -1,17 +1,21 @@
package output
package sbom
import (
"io"
"strings"
"testing"
"github.com/anchore/syft/internal/formats/spdx22json"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/formats/table"
"github.com/anchore/syft/internal/formats/text"
"github.com/stretchr/testify/assert"
)
func dummyEncoder(io.Writer, SBOM) error {
return nil
}
func dummyFormat(name string) Format {
return NewFormat(FormatID(name), dummyEncoder, nil, nil)
}
type writerConfig struct {
format string
file string
@ -23,7 +27,7 @@ func TestOutputWriter(t *testing.T) {
testName := func(options []WriterOption, err bool) string {
var out []string
for _, opt := range options {
out = append(out, string(opt.Format.Option)+"="+opt.Path)
out = append(out, string(opt.Format.ID())+"="+opt.Path)
}
errs := ""
if err {
@ -44,7 +48,7 @@ func TestOutputWriter(t *testing.T) {
{
outputs: []WriterOption{
{
Format: table.Format(),
Format: dummyFormat("table"),
Path: "",
},
},
@ -57,7 +61,7 @@ func TestOutputWriter(t *testing.T) {
{
outputs: []WriterOption{
{
Format: syftjson.Format(),
Format: dummyFormat("json"),
},
},
expected: []writerConfig{
@ -69,7 +73,7 @@ func TestOutputWriter(t *testing.T) {
{
outputs: []WriterOption{
{
Format: syftjson.Format(),
Format: dummyFormat("json"),
Path: "test-2.json",
},
},
@ -83,11 +87,11 @@ func TestOutputWriter(t *testing.T) {
{
outputs: []WriterOption{
{
Format: syftjson.Format(),
Format: dummyFormat("json"),
Path: "test-3/1.json",
},
{
Format: spdx22json.Format(),
Format: dummyFormat("spdx-json"),
Path: "test-3/2.json",
},
},
@ -105,10 +109,10 @@ func TestOutputWriter(t *testing.T) {
{
outputs: []WriterOption{
{
Format: text.Format(),
Format: dummyFormat("text"),
},
{
Format: spdx22json.Format(),
Format: dummyFormat("spdx-json"),
Path: "test-4.json",
},
},
@ -133,7 +137,7 @@ func TestOutputWriter(t *testing.T) {
}
}
writer, err := MakeWriter(outputs...)
writer, err := NewWriter(outputs...)
if test.err {
assert.Error(t, err)
@ -149,7 +153,7 @@ func TestOutputWriter(t *testing.T) {
for i, e := range test.expected {
w := mw.writers[i].(*streamWriter)
assert.Equal(t, string(w.format.Option), e.format)
assert.Equal(t, string(w.format.ID()), e.format)
if e.file != "" {
assert.FileExists(t, tmp+e.file)

View file

@ -0,0 +1,25 @@
package sbom
import (
"io"
)
// streamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
type streamWriter struct {
format Format
out io.Writer
close func() error
}
// Write the provided SBOM to the data stream
func (w *streamWriter) Write(s SBOM) error {
return w.format.Encode(w.out, s)
}
// Close any resources, such as open files
func (w *streamWriter) Close() error {
if w.close != nil {
return w.close()
}
return nil
}

View file

@ -2,10 +2,11 @@ package cli
import (
"fmt"
"github.com/stretchr/testify/require"
"strings"
"testing"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft"
)
func TestAllFormatsExpressible(t *testing.T) {
@ -18,8 +19,9 @@ func TestAllFormatsExpressible(t *testing.T) {
},
assertSuccessfulReturnCode,
}
for _, o := range format.AllOptions {
formats := syft.FormatIDs()
require.NotEmpty(t, formats)
for _, o := range formats {
t.Run(fmt.Sprintf("format:%s", o), func(t *testing.T) {
cmd, stdout, stderr := runSyft(t, nil, "dir:./test-fixtures/image-pkg-coverage", "-o", string(o))
for _, traitFn := range commonAssertions {

View file

@ -122,8 +122,6 @@ func TestLogFile(t *testing.T) {
request := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage")
envLogFile := filepath.Join(os.TempDir(), "a-pretty-log-file.log")
t.Logf("log file path: %s", envLogFile)
tests := []struct {
name string
args []string

View file

@ -2,6 +2,11 @@ package integration
import (
"bytes"
"github.com/anchore/syft/internal/formats/cyclonedx13json"
"github.com/anchore/syft/internal/formats/cyclonedx13xml"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/syft/sbom"
"github.com/stretchr/testify/require"
"regexp"
"testing"
@ -9,7 +14,6 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/anchore/syft/syft/format"
"github.com/stretchr/testify/assert"
)
@ -21,16 +25,16 @@ import (
// encode-decode-encode loop which will detect lossy behavior in both directions.
func TestEncodeDecodeEncodeCycleComparison(t *testing.T) {
tests := []struct {
format format.Option
redactor func(in []byte) []byte
json bool
formatOption sbom.FormatID
redactor func(in []byte) []byte
json bool
}{
{
format: format.JSONOption,
json: true,
formatOption: syftjson.ID,
json: true,
},
{
format: format.CycloneDxJSONOption,
formatOption: cyclonedx13json.ID,
redactor: func(in []byte) []byte {
in = regexp.MustCompile("\"(timestamp|serialNumber|bom-ref)\": \"[^\"]+\",").ReplaceAll(in, []byte{})
return in
@ -38,7 +42,7 @@ func TestEncodeDecodeEncodeCycleComparison(t *testing.T) {
json: true,
},
{
format: format.CycloneDxXMLOption,
formatOption: cyclonedx13xml.ID,
redactor: func(in []byte) []byte {
in = regexp.MustCompile("(serialNumber|bom-ref)=\"[^\"]+\"").ReplaceAll(in, []byte{})
in = regexp.MustCompile("<timestamp>[^<]+</timestamp>").ReplaceAll(in, []byte{})
@ -47,18 +51,21 @@ func TestEncodeDecodeEncodeCycleComparison(t *testing.T) {
},
}
for _, test := range tests {
t.Run(string(test.format), func(t *testing.T) {
t.Run(string(test.formatOption), func(t *testing.T) {
originalSBOM, _ := catalogFixtureImage(t, "image-pkg-coverage")
by1, err := syft.Encode(originalSBOM, test.format)
format := syft.FormatByID(test.formatOption)
require.NotNil(t, format)
by1, err := syft.Encode(originalSBOM, format)
assert.NoError(t, err)
newSBOM, newFormat, err := syft.Decode(bytes.NewReader(by1))
assert.NoError(t, err)
assert.Equal(t, test.format, newFormat)
assert.Equal(t, format.ID(), newFormat.ID())
by2, err := syft.Encode(*newSBOM, test.format)
by2, err := syft.Encode(*newSBOM, format)
assert.NoError(t, err)
if test.redactor != nil {