diff --git a/.golangci.yml b/.golangci.yml index a9ac9d33..48dbf989 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,12 @@ linters: enable: - asciicheck + - exhaustive + - gochecknoinits + - goconst - gofmt - gosec + - predeclared + - unconvert + - unparam - wastedassign diff --git a/Dockerfile b/Dockerfile index 1328a4c1..18328905 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM golang:1.16-alpine as build ARG VERSION=latest WORKDIR /tmp/cyclonedx-gomod +RUN apk --no-cache add git make COPY . . -RUN go install +RUN make install FROM golang:1.16-alpine COPY --from=build /go/bin/cyclonedx-gomod /usr/local/bin/ diff --git a/Makefile b/Makefile index 2eec4b46..3593f859 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,13 @@ +LDFLAGS="-s -w -X github.com/CycloneDX/cyclonedx-gomod/internal/version.Version=v0.0.0-$(shell git show -s --date=format:'%Y%m%d%H%M%S' --format=%cd HEAD)-$(shell git rev-parse HEAD | head -c 12)" + build: - go build -v + go build -v -ldflags=${LDFLAGS} .PHONY: build +install: + go install -v -ldflags=${LDFLAGS} +.PHONY: install + generate: go generate -v ./... .PHONY: generate diff --git a/examples/cyclonedx-go-v0.4.0.bom.json b/examples/cyclonedx-go-v0.4.0.bom.json index 40683b3d..b3f37e98 100644 --- a/examples/cyclonedx-go-v0.4.0.bom.json +++ b/examples/cyclonedx-go-v0.4.0.bom.json @@ -1,31 +1,31 @@ { "bomFormat": "CycloneDX", "specVersion": "1.2", - "serialNumber": "urn:uuid:6851773a-8365-4efc-9210-2db3cbc7dcf8", + "serialNumber": "urn:uuid:4b21c403-047b-45d4-91ba-9b45448c0b69", "version": 1, "metadata": { - "timestamp": "2021-07-17T08:48:10+02:00", + "timestamp": "2021-07-31T22:31:40+02:00", "tools": [ { "vendor": "CycloneDX", "name": "cyclonedx-gomod", - "version": "v0.9.0", + "version": "v0.0.0-20210729183245-27eb9c8d1f90", "hashes": [ { "alg": "MD5", - "content": "fbd6c9be6da0f447c40095c2713fe922" + "content": "876cb6fddc1cf5faa72bb4f6f4356edf" }, { "alg": "SHA-1", - "content": "086cc0f0ffd38240d4362b87f2c2d66a7e83aa07" + "content": "9711bd6a951a5f30481a3f163ee1398ebb3d515c" }, { "alg": "SHA-256", - "content": "a05c272164e0f59937063a6304015286faa90f93b2985e67b60788dd7c69ad00" + "content": "9bc8fb8d2245a3b1f115e5baf51a88afa785e679469377587048ae652730b6b5" }, { "alg": "SHA-512", - "content": "1938e8d1add284d2f308910a28f3ca8dd84ff4c84b67957a361e12527fef14cfae39ad2a02a5815dee6fba40b360a2e4cc74aeea2a5b5a39dc97dfb1545c0e34" + "content": "9c506ceb3915657824425ad180737dc2cf74f749db13edeb4a5fadc7ad0f54e43390945abe3c1ea405568f74a5980dcfa0b72f887ee1baf0bc4822a78829eb78" } ] } diff --git a/examples/proton-bridge-v1.6.3.bom.xml b/examples/proton-bridge-v1.6.3.bom.xml index 7c56f0e5..60e55b6d 100644 --- a/examples/proton-bridge-v1.6.3.bom.xml +++ b/examples/proton-bridge-v1.6.3.bom.xml @@ -1,17 +1,17 @@ - + - 2021-07-17T08:49:52+02:00 + 2021-07-31T22:33:34+02:00 CycloneDX cyclonedx-gomod - v0.9.0 + v0.0.0-20210729183245-27eb9c8d1f90 - fbd6c9be6da0f447c40095c2713fe922 - 086cc0f0ffd38240d4362b87f2c2d66a7e83aa07 - a05c272164e0f59937063a6304015286faa90f93b2985e67b60788dd7c69ad00 - 1938e8d1add284d2f308910a28f3ca8dd84ff4c84b67957a361e12527fef14cfae39ad2a02a5815dee6fba40b360a2e4cc74aeea2a5b5a39dc97dfb1545c0e34 + 876cb6fddc1cf5faa72bb4f6f4356edf + 9711bd6a951a5f30481a3f163ee1398ebb3d515c + 9bc8fb8d2245a3b1f115e5baf51a88afa785e679469377587048ae652730b6b5 + 9c506ceb3915657824425ad180737dc2cf74f749db13edeb4a5fadc7ad0f54e43390945abe3c1ea405568f74a5980dcfa0b72f887ee1baf0bc4822a78829eb78 @@ -1471,10 +1471,10 @@ + - @@ -1511,10 +1511,10 @@ + - @@ -1544,7 +1544,11 @@ + + + + @@ -1554,8 +1558,6 @@ - - @@ -1575,7 +1577,6 @@ - @@ -1592,7 +1593,6 @@ - diff --git a/go.mod b/go.mod index 2694c09f..d6ec51cc 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-enry/go-license-detector/v4 v4.3.0 github.com/go-git/go-git/v5 v5.4.2 github.com/google/uuid v1.3.0 + github.com/peterbourgon/ff/v3 v3.1.0 github.com/stretchr/testify v1.7.0 golang.org/x/mod v0.4.2 ) diff --git a/go.sum b/go.sum index a8cd8410..5e32c701 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/CycloneDX/cyclonedx-go v0.4.0 h1:Wz4QZ9B4RXGWIWTypVLEOVJgOdFfy5mcS5PGNzUkZxU= github.com/CycloneDX/cyclonedx-go v0.4.0/go.mod h1:rmRcf//gT7PIzovatusbWi377xqCg1FS4jyST0GH20E= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= @@ -85,6 +86,9 @@ github.com/montanaflynn/stats v0.0.0-20151014174947-eeaced052adb/go.mod h1:wL8QJ github.com/neurosnap/sentences v1.0.6 h1:iBVUivNtlwGkYsJblWV8GGVFmXzZzak907Ci8aA0VTE= github.com/neurosnap/sentences v1.0.6/go.mod h1:pg1IapvYpWCJJm/Etxeh0+gtMf1rI1STY9S7eUCPbDc= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff/v3 v3.1.0 h1:5JAeDK5j/zhKFjyHEZQXwXBoDijERaos10RE+xamOsY= +github.com/peterbourgon/ff/v3 v3.1.0/go.mod h1:XNJLY8EIl6MjMVjBS4F0+G0LYoAqs0DTa4rmHHukKDE= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/internal/cli/mod.go b/internal/cli/mod.go new file mode 100644 index 00000000..cf2a421a --- /dev/null +++ b/internal/cli/mod.go @@ -0,0 +1,155 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/CycloneDX/cyclonedx-gomod/internal/sbom" + "github.com/google/uuid" + "github.com/peterbourgon/ff/v3/ffcli" +) + +// ModOptions provides options for the `mod` command. +type ModOptions struct { + OutputOptions + SBOMOptions + + ModuleDir string + ResolveLicenses bool +} + +func (m *ModOptions) RegisterFlags(fs *flag.FlagSet) { + m.OutputOptions.RegisterFlags(fs) + m.SBOMOptions.RegisterFlags(fs) + + fs.BoolVar(&m.ResolveLicenses, "licenses", false, "Resolve module licenses") +} + +func (m ModOptions) Validate() error { + errs := make([]error, 0) + + if err := m.OutputOptions.Validate(); err != nil { + var verr *OptionsValidationError + if errors.As(err, &verr) { + errs = append(errs, verr.Errors...) + } else { + return err + } + } + if err := m.SBOMOptions.Validate(); err != nil { + var verr *OptionsValidationError + if errors.As(err, &verr) { + errs = append(errs, verr.Errors...) + } else { + return err + } + } + + if len(errs) > 0 { + return &OptionsValidationError{Errors: errs} + } + + return nil +} + +func newModCmd() *ffcli.Command { + fs := flag.NewFlagSet("cyclonedx-gomod mod", flag.ExitOnError) + + var options ModOptions + options.RegisterFlags(fs) + + return &ffcli.Command{ + Name: "mod", + ShortHelp: "Generate SBOM for a module", + ShortUsage: "cyclonedx-gomod mod [FLAGS...] [PATH]", + FlagSet: fs, + Exec: func(ctx context.Context, args []string) error { + if len(args) > 1 { + return flag.ErrHelp + } + if len(args) == 0 { + options.ModuleDir = "." + } else { + options.ModuleDir = args[0] + } + + return execModCmd(options) + }, + } +} + +func execModCmd(options ModOptions) error { + if err := options.Validate(); err != nil { + return err + } + + var serial *uuid.UUID + if !options.NoSerialNumber && options.SerialNumber != "" { + serialUUID := uuid.MustParse(options.SerialNumber) + serial = &serialUUID + } + + bom, err := sbom.Generate(options.ModuleDir, sbom.GenerateOptions{ + ComponentType: cdx.ComponentType(options.ComponentType), + IncludeStdLib: options.IncludeStd, + IncludeTest: options.IncludeTest, + NoSerialNumber: options.NoSerialNumber, + NoVersionPrefix: options.NoVersionPrefix, + Reproducible: options.Reproducible, + ResolveLicenses: options.ResolveLicenses, + SerialNumber: serial, + }) + if err != nil { + return fmt.Errorf("failed to generate sbom: %w", err) + } + + var outputFormat cdx.BOMFileFormat + if options.UseJSON { + outputFormat = cdx.BOMFileFormatJSON + } else { + outputFormat = cdx.BOMFileFormatXML + } + + var outputWriter io.Writer + if options.FilePath == "" || options.FilePath == "-" { + outputWriter = os.Stdout + } else { + outputFile, err := os.Create(options.FilePath) + if err != nil { + return fmt.Errorf("failed to create output file %s: %w", options.FilePath, err) + } + defer outputFile.Close() + outputWriter = outputFile + } + + encoder := cdx.NewBOMEncoder(outputWriter, outputFormat) + encoder.SetPretty(true) + + if err = encoder.Encode(bom); err != nil { + return fmt.Errorf("failed to encode sbom: %w", err) + } + + return nil +} diff --git a/main_integration_test.go b/internal/cli/mod_integration_test.go similarity index 80% rename from main_integration_test.go rename to internal/cli/mod_integration_test.go index 4a9a3192..b0a4c70f 100644 --- a/main_integration_test.go +++ b/internal/cli/mod_integration_test.go @@ -15,7 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. -package main +package cli import ( "fmt" @@ -52,12 +52,14 @@ func TestIntegrationSimple(t *testing.T) { fixturePath := extractFixture(t, "./testdata/integration/simple.tar.gz") defer os.RemoveAll(fixturePath) - runSnapshotIT(t, Options{ - ComponentType: cdx.ComponentTypeLibrary, - ModulePath: fixturePath, + runSnapshotIT(t, ModOptions{ + SBOMOptions: SBOMOptions{ + ComponentType: string(cdx.ComponentTypeLibrary), + Reproducible: true, + SerialNumber: zeroUUID.String(), + }, + ModuleDir: fixturePath, ResolveLicenses: true, - Reproducible: true, - SerialNumber: &zeroUUID, }) } @@ -67,12 +69,14 @@ func TestIntegrationLocal(t *testing.T) { fixturePath := extractFixture(t, "./testdata/integration/local.tar.gz") defer os.RemoveAll(fixturePath) - runSnapshotIT(t, Options{ - ComponentType: cdx.ComponentTypeLibrary, - ModulePath: filepath.Join(fixturePath, "local"), + runSnapshotIT(t, ModOptions{ + SBOMOptions: SBOMOptions{ + ComponentType: string(cdx.ComponentTypeLibrary), + Reproducible: true, + SerialNumber: zeroUUID.String(), + }, + ModuleDir: filepath.Join(fixturePath, "local"), ResolveLicenses: true, - Reproducible: true, - SerialNumber: &zeroUUID, }) } @@ -81,12 +85,14 @@ func TestIntegrationNoDependencies(t *testing.T) { fixturePath := extractFixture(t, "./testdata/integration/no-dependencies.tar.gz") defer os.RemoveAll(fixturePath) - runSnapshotIT(t, Options{ - ComponentType: cdx.ComponentTypeLibrary, - ModulePath: fixturePath, + runSnapshotIT(t, ModOptions{ + SBOMOptions: SBOMOptions{ + ComponentType: string(cdx.ComponentTypeLibrary), + Reproducible: true, + SerialNumber: zeroUUID.String(), + }, + ModuleDir: fixturePath, ResolveLicenses: true, - Reproducible: true, - SerialNumber: &zeroUUID, }) } @@ -96,12 +102,14 @@ func TestIntegrationVendored(t *testing.T) { fixturePath := extractFixture(t, "./testdata/integration/vendored.tar.gz") defer os.RemoveAll(fixturePath) - runSnapshotIT(t, Options{ - ComponentType: cdx.ComponentTypeLibrary, - ModulePath: fixturePath, + runSnapshotIT(t, ModOptions{ + SBOMOptions: SBOMOptions{ + ComponentType: string(cdx.ComponentTypeLibrary), + Reproducible: true, + SerialNumber: zeroUUID.String(), + }, + ModuleDir: fixturePath, ResolveLicenses: true, - Reproducible: true, - SerialNumber: &zeroUUID, }) } @@ -119,20 +127,22 @@ func TestIntegrationNested(t *testing.T) { fixturePath := extractFixture(t, "./testdata/integration/nested.tar.gz") defer os.RemoveAll(fixturePath) - runSnapshotIT(t, Options{ - ComponentType: cdx.ComponentTypeLibrary, - ModulePath: filepath.Join(fixturePath, "simple"), + runSnapshotIT(t, ModOptions{ + SBOMOptions: SBOMOptions{ + ComponentType: string(cdx.ComponentTypeLibrary), + Reproducible: true, + SerialNumber: zeroUUID.String(), + }, + ModuleDir: filepath.Join(fixturePath, "simple"), ResolveLicenses: true, - Reproducible: true, - SerialNumber: &zeroUUID, }) } -func runSnapshotIT(t *testing.T, options Options) { +func runSnapshotIT(t *testing.T, modOptions ModOptions) { skipIfShort(t) bomFileExtension := ".xml" - if options.UseJSON { + if modOptions.OutputOptions.UseJSON { bomFileExtension = ".json" } @@ -143,8 +153,8 @@ func runSnapshotIT(t *testing.T, options Options) { require.NoError(t, bomFile.Close()) // Generate the SBOM - options.OutputPath = bomFile.Name() - err = executeCommand(options) + modOptions.OutputOptions.FilePath = bomFile.Name() + err = execModCmd(modOptions) require.NoError(t, err) // Sanity check: Make sure the SBOM is valid diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 00000000..f5230eb9 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,116 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "flag" + "fmt" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/google/uuid" +) + +// OptionsValidationError represents a validation error for options. +// It can contain multiple errors with details about which validation +// operations failed. The Errors slice should never be empty. +type OptionsValidationError struct { + Errors []error +} + +func (e OptionsValidationError) Error() string { + err := "invalid options:\n" + for _, e := range e.Errors { + err += fmt.Sprintf(" - %s\n", e) + } + return err +} + +// OutputOptions provides options for customizing the output. +type OutputOptions struct { + FilePath string + UseJSON bool +} + +func (o *OutputOptions) RegisterFlags(fs *flag.FlagSet) { + fs.BoolVar(&o.UseJSON, "json", false, "Output in JSON") + fs.StringVar(&o.FilePath, "output", "-", "Output file path (or - for STDOUT)") +} + +func (o OutputOptions) Validate() error { + return nil +} + +// SBOMOptions provides options for customizing the SBOM. +type SBOMOptions struct { + ComponentType string + IncludeStd bool + IncludeTest bool + NoSerialNumber bool + NoVersionPrefix bool + Reproducible bool + SerialNumber string +} + +func (s *SBOMOptions) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar(&s.ComponentType, "type", "application", "Type of the main component") + fs.BoolVar(&s.IncludeStd, "std", false, "Include Go standard library as component and dependency of the module") + fs.BoolVar(&s.IncludeTest, "test", false, "Include test dependencies") + fs.BoolVar(&s.NoSerialNumber, "noserial", false, "Omit serial number") + fs.BoolVar(&s.NoVersionPrefix, "novprefix", false, "Omit \"v\" prefix from versions") + fs.BoolVar(&s.Reproducible, "reproducible", false, "Make the SBOM reproducible by omitting dynamic content") + fs.StringVar(&s.SerialNumber, "serial", "", "Serial number") +} + +var allowedComponentTypes = []cdx.ComponentType{ + cdx.ComponentTypeApplication, + cdx.ComponentTypeContainer, + cdx.ComponentTypeDevice, + cdx.ComponentTypeFile, + cdx.ComponentTypeFirmware, + cdx.ComponentTypeFramework, + cdx.ComponentTypeLibrary, + cdx.ComponentTypeOS, +} + +func (s SBOMOptions) Validate() error { + errs := make([]error, 0) + + isAllowedComponentType := false + for i := range allowedComponentTypes { + if allowedComponentTypes[i] == cdx.ComponentType(s.ComponentType) { + isAllowedComponentType = true + break + } + } + if !isAllowedComponentType { + errs = append(errs, fmt.Errorf("invalid component type: \"%s\"", s.ComponentType)) + } + + // Serial numbers must be valid UUIDs + if !s.NoSerialNumber && s.SerialNumber != "" { + if _, err := uuid.Parse(s.SerialNumber); err != nil { + errs = append(errs, fmt.Errorf("invalid serial number: %w", err)) + } + } + + if len(errs) > 0 { + return &OptionsValidationError{Errors: errs} + } + + return nil +} diff --git a/internal/cli/options_test.go b/internal/cli/options_test.go new file mode 100644 index 00000000..c657e201 --- /dev/null +++ b/internal/cli/options_test.go @@ -0,0 +1,76 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "testing" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/stretchr/testify/require" +) + +func TestOutputOptions_Validate(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + var options OutputOptions + require.NoError(t, options.Validate()) + }) +} + +func TestSBOMOptions_Validate(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + var options SBOMOptions + + err := options.Validate() + require.Error(t, err) + + var validationError *OptionsValidationError + require.ErrorAs(t, err, &validationError) + + require.Len(t, validationError.Errors, 1) + require.Contains(t, validationError.Errors[0].Error(), "invalid component type") + }) + + t.Run("InvalidComponentType", func(t *testing.T) { + var options SBOMOptions + options.ComponentType = "foobar" + + err := options.Validate() + require.Error(t, err) + + var validationError *OptionsValidationError + require.ErrorAs(t, err, &validationError) + + require.Len(t, validationError.Errors, 1) + require.Contains(t, validationError.Errors[0].Error(), "invalid component type") + }) + + t.Run("InvalidSerialNumber", func(t *testing.T) { + var options SBOMOptions + options.ComponentType = string(cdx.ComponentTypeApplication) + options.SerialNumber = "foobar" + + err := options.Validate() + require.Error(t, err) + + var validationError *OptionsValidationError + require.ErrorAs(t, err, &validationError) + + require.Len(t, validationError.Errors, 1) + require.Contains(t, validationError.Errors[0].Error(), "invalid serial number") + }) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 00000000..952701ba --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,43 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "context" + "flag" + + "github.com/peterbourgon/ff/v3/ffcli" +) + +func NewRootCmd() *ffcli.Command { + return &ffcli.Command{ + Name: "cyclonedx-gomod", + ShortUsage: "cyclonedx-gomod [FLAGS...] [...]", + Subcommands: []*ffcli.Command{ + newModCmd(), + newVersionCmd(), + }, + Exec: func(_ context.Context, _ []string) error { + return execRootCmd() + }, + } +} + +func execRootCmd() error { + return flag.ErrHelp +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..b296d345 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,14 @@ +package cli + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExecRootCmd(t *testing.T) { + err := execRootCmd() + require.Error(t, err) + require.ErrorIs(t, err, flag.ErrHelp) +} diff --git a/testdata/integration/local.tar.gz b/internal/cli/testdata/integration/local.tar.gz similarity index 100% rename from testdata/integration/local.tar.gz rename to internal/cli/testdata/integration/local.tar.gz diff --git a/testdata/integration/nested.tar.gz b/internal/cli/testdata/integration/nested.tar.gz similarity index 100% rename from testdata/integration/nested.tar.gz rename to internal/cli/testdata/integration/nested.tar.gz diff --git a/testdata/integration/no-dependencies.tar.gz b/internal/cli/testdata/integration/no-dependencies.tar.gz similarity index 100% rename from testdata/integration/no-dependencies.tar.gz rename to internal/cli/testdata/integration/no-dependencies.tar.gz diff --git a/testdata/integration/simple.tar.gz b/internal/cli/testdata/integration/simple.tar.gz similarity index 100% rename from testdata/integration/simple.tar.gz rename to internal/cli/testdata/integration/simple.tar.gz diff --git a/testdata/integration/snapshots/TestIntegrationLocal b/internal/cli/testdata/integration/snapshots/TestIntegrationLocal similarity index 100% rename from testdata/integration/snapshots/TestIntegrationLocal rename to internal/cli/testdata/integration/snapshots/TestIntegrationLocal diff --git a/testdata/integration/snapshots/TestIntegrationNested b/internal/cli/testdata/integration/snapshots/TestIntegrationNested similarity index 100% rename from testdata/integration/snapshots/TestIntegrationNested rename to internal/cli/testdata/integration/snapshots/TestIntegrationNested diff --git a/testdata/integration/snapshots/TestIntegrationNoDependencies b/internal/cli/testdata/integration/snapshots/TestIntegrationNoDependencies similarity index 100% rename from testdata/integration/snapshots/TestIntegrationNoDependencies rename to internal/cli/testdata/integration/snapshots/TestIntegrationNoDependencies diff --git a/testdata/integration/snapshots/TestIntegrationSimple b/internal/cli/testdata/integration/snapshots/TestIntegrationSimple similarity index 100% rename from testdata/integration/snapshots/TestIntegrationSimple rename to internal/cli/testdata/integration/snapshots/TestIntegrationSimple diff --git a/testdata/integration/snapshots/TestIntegrationVendored b/internal/cli/testdata/integration/snapshots/TestIntegrationVendored similarity index 100% rename from testdata/integration/snapshots/TestIntegrationVendored rename to internal/cli/testdata/integration/snapshots/TestIntegrationVendored diff --git a/testdata/integration/vendored.tar.gz b/internal/cli/testdata/integration/vendored.tar.gz similarity index 100% rename from testdata/integration/vendored.tar.gz rename to internal/cli/testdata/integration/vendored.tar.gz diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 00000000..68a47e9f --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,44 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/CycloneDX/cyclonedx-gomod/internal/version" + "github.com/peterbourgon/ff/v3/ffcli" +) + +func newVersionCmd() *ffcli.Command { + return &ffcli.Command{ + Name: "version", + ShortHelp: "Show version information", + ShortUsage: "cyclonedx-gomod version", + Exec: func(_ context.Context, _ []string) error { + return execVersionCmd(os.Stdout) + }, + } +} + +func execVersionCmd(writer io.Writer) error { + fmt.Fprintln(writer, version.Version) + return nil +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 00000000..f3f95def --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,35 @@ +// This file is part of CycloneDX GoMod +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +package cli + +import ( + "bytes" + "fmt" + "testing" + + "github.com/CycloneDX/cyclonedx-gomod/internal/version" + "github.com/stretchr/testify/require" +) + +func TestExecVersionCmd(t *testing.T) { + buf := new(bytes.Buffer) + + err := execVersionCmd(buf) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("%s\n", version.Version), buf.String()) +} diff --git a/internal/gocmd/gocmd_test.go b/internal/gocmd/gocmd_test.go index d0559b33..b4719a87 100644 --- a/internal/gocmd/gocmd_test.go +++ b/internal/gocmd/gocmd_test.go @@ -73,7 +73,7 @@ func TestModWhy(t *testing.T) { require.NoError(t, err) require.Equal(t, `# github.com/CycloneDX/cyclonedx-go -github.com/CycloneDX/cyclonedx-gomod +github.com/CycloneDX/cyclonedx-gomod/internal/cli github.com/CycloneDX/cyclonedx-go `, buf.String()) } diff --git a/main.go b/main.go index 1925b679..febe641a 100644 --- a/main.go +++ b/main.go @@ -18,170 +18,16 @@ package main import ( - "flag" + "context" "fmt" - "io" - "log" "os" - "strings" - "github.com/CycloneDX/cyclonedx-gomod/internal/gocmd" - "golang.org/x/mod/semver" - - cdx "github.com/CycloneDX/cyclonedx-go" - "github.com/CycloneDX/cyclonedx-gomod/internal/sbom" - "github.com/google/uuid" - - "github.com/CycloneDX/cyclonedx-gomod/internal/version" + "github.com/CycloneDX/cyclonedx-gomod/internal/cli" ) -// The minimum Go version required for cyclonedx-gomod to work. -// This is currently 1.11, as modules were introduced in this version. -const minimumGoVersion = "1.11" - -type Options struct { - ComponentType cdx.ComponentType - ComponentTypeStr string - IncludeStd bool - IncludeTest bool - ModulePath string - NoSerialNumber bool - NoVersionPrefix bool - OutputPath string - ResolveLicenses bool - Reproducible bool - SerialNumber *uuid.UUID - SerialNumberStr string - ShowVersion bool - UseJSON bool -} - func main() { - var options Options - - flag.StringVar(&options.ComponentTypeStr, "type", string(cdx.ComponentTypeApplication), "Type of the main component") - flag.BoolVar(&options.IncludeStd, "std", false, "Include Go standard library as component and dependency of the module") - flag.BoolVar(&options.IncludeTest, "test", false, "Include test dependencies") - flag.StringVar(&options.ModulePath, "module", ".", "Path to Go module") - flag.BoolVar(&options.NoSerialNumber, "noserial", false, "Omit serial number") - flag.BoolVar(&options.NoVersionPrefix, "novprefix", false, "Omit \"v\" version prefix") - flag.StringVar(&options.OutputPath, "output", "-", "Output path") - flag.BoolVar(&options.ResolveLicenses, "licenses", false, "Resolve module licenses") - flag.BoolVar(&options.Reproducible, "reproducible", false, "Make the SBOM reproducible by omitting dynamic content") - flag.StringVar(&options.SerialNumberStr, "serial", "", "Serial number (default [random UUID])") - flag.BoolVar(&options.ShowVersion, "version", false, "Show version") - flag.BoolVar(&options.UseJSON, "json", false, "Output in JSON format") - flag.Parse() - - if options.ShowVersion { - fmt.Println(version.Version) - return - } - - if err := checkGoVersion(); err != nil { - log.Fatal(err) - } - - if err := validateOptions(&options); err != nil { - log.Fatal(err) - } - - if err := executeCommand(options); err != nil { - log.Fatal(err) - } -} - -func checkGoVersion() error { - if goVersion, err := gocmd.GetVersion(); err == nil { - goVersion = strings.TrimPrefix(goVersion, "go") - if semver.Compare("v"+goVersion, "v"+minimumGoVersion) == -1 { // semver requires the v prefix - return fmt.Errorf("go >= %s is required, but is %s", minimumGoVersion, goVersion) - } - } else { - return fmt.Errorf("failed to determine go version: %w", err) - } - return nil -} - -var allowedComponentTypes = []cdx.ComponentType{ - cdx.ComponentTypeApplication, - cdx.ComponentTypeContainer, - cdx.ComponentTypeDevice, - cdx.ComponentTypeFile, - cdx.ComponentTypeFirmware, - cdx.ComponentTypeFramework, - cdx.ComponentTypeLibrary, - cdx.ComponentTypeOS, -} - -func validateOptions(options *Options) error { - isAllowedComponentType := false - for i := range allowedComponentTypes { - if allowedComponentTypes[i] == cdx.ComponentType(options.ComponentTypeStr) { - isAllowedComponentType = true - break - } - } - if isAllowedComponentType { - options.ComponentType = cdx.ComponentType(options.ComponentTypeStr) - } else { - return fmt.Errorf("invalid component type %s. See https://cyclonedx.org/docs/1.2/#type_classification", options.ComponentTypeStr) - } - - // Serial numbers must be valid UUIDs - if !options.NoSerialNumber && options.SerialNumberStr != "" { - if serialNumber, err := uuid.Parse(options.SerialNumberStr); err != nil { - return fmt.Errorf("invalid serial number: %w", err) - } else { - options.SerialNumber = &serialNumber - } - } - - return nil -} - -func executeCommand(options Options) error { - log.Println("generating sbom") - bom, err := sbom.Generate(options.ModulePath, sbom.GenerateOptions{ - ComponentType: options.ComponentType, - IncludeStdLib: options.IncludeStd, - IncludeTest: options.IncludeTest, - NoSerialNumber: options.NoSerialNumber, - NoVersionPrefix: options.NoVersionPrefix, - Reproducible: options.Reproducible, - ResolveLicenses: options.ResolveLicenses, - SerialNumber: options.SerialNumber, - }) - if err != nil { - return fmt.Errorf("generating sbom failed: %w", err) - } - - var outputFormat cdx.BOMFileFormat - if options.UseJSON { - outputFormat = cdx.BOMFileFormatJSON - } else { - outputFormat = cdx.BOMFileFormatXML - } - - log.Println("writing sbom") - var outputWriter io.Writer - if options.OutputPath == "" || options.OutputPath == "-" { - outputWriter = os.Stdout - } else { - outputFile, err := os.Create(options.OutputPath) - if err != nil { - return fmt.Errorf("failed to create output file %s: %w", options.OutputPath, err) - } - defer outputFile.Close() - outputWriter = outputFile + if err := cli.NewRootCmd().ParseAndRun(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } - - encoder := cdx.NewBOMEncoder(outputWriter, outputFormat) - encoder.SetPretty(true) - - if err = encoder.Encode(bom); err != nil { - return fmt.Errorf("encoding BOM failed: %w", err) - } - - return nil } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 513b95af..00000000 --- a/main_test.go +++ /dev/null @@ -1,71 +0,0 @@ -// This file is part of CycloneDX GoMod -// -// Licensed under the Apache License, Version 2.0 (the “License”); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an “AS IS” BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) OWASP Foundation. All Rights Reserved. - -package main - -import ( - "testing" - - cdx "github.com/CycloneDX/cyclonedx-go" - "github.com/stretchr/testify/assert" -) - -func TestValidateOptions(t *testing.T) { - // Should fail on invalid ComponentType - options := Options{ - ComponentTypeStr: "foobar", - } - err := validateOptions(&options) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid component type") - - // Should set ComponentType when valid - options = Options{ - ComponentTypeStr: "container", - } - err = validateOptions(&options) - assert.NoError(t, err) - assert.Equal(t, cdx.ComponentTypeContainer, options.ComponentType) - - // Should fail when invalid SerialNumber is provided - options = Options{ - ComponentTypeStr: "container", - SerialNumberStr: "foobar", - } - err = validateOptions(&options) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid serial number") - - // Should not fail when invalid SerialNumber and NoSerialNumber are provided - options = Options{ - ComponentTypeStr: "container", - NoSerialNumber: true, - SerialNumberStr: "foobar", - } - err = validateOptions(&options) - assert.NoError(t, err) - assert.Nil(t, options.SerialNumber) - - // Should set SerialNumber when provided an valid - options = Options{ - ComponentTypeStr: "container", - SerialNumberStr: "b2330afe-e16b-4c4c-b10f-f571e96d6ecc", - } - err = validateOptions(&options) - assert.NoError(t, err) - assert.Equal(t, "b2330afe-e16b-4c4c-b10f-f571e96d6ecc", options.SerialNumber.String()) -}