Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ shopware-cli extension --help
shopware-cli project --help
```

Generate a CycloneDX SBOM for a project without running the full CI build:

```bash
shopware-cli project sbom
shopware-cli project sbom ./my-shop --format cyclonedx-json --output sbom.json
```

If you need CI-friendly behavior, disable prompts:

```bash
Expand Down
65 changes: 8 additions & 57 deletions cmd/project/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (
"path/filepath"
"strings"

"github.com/shyim/go-composer"
"github.com/shyim/go-composer/sbom"
"github.com/spf13/cobra"

"github.com/shopware/shopware-cli/internal/ci"
Expand Down Expand Up @@ -363,66 +361,19 @@ func createEmptySnippetFolder(root string) error {
return nil
}

// generateProjectSBOM reads composer.lock from the project root and writes a
// CycloneDX SBOM JSON document to the configured path. When composer.lock is
// absent (for example when composer install was skipped on a project without
// PHP dependencies) the step is a no-op.
// generateProjectSBOM writes a CycloneDX SBOM for the CI pipeline. When
// composer.lock is absent (for example when composer install was skipped on a
// project without PHP dependencies) the step is a no-op so the rest of project
// ci can continue. Output path and format match the defaults of `project sbom`.
func generateProjectSBOM(ctx context.Context, root string) error {
Comment thread
shyim marked this conversation as resolved.
section := ci.Default.Section(ctx, "Generating SBOM")
defer section.End(ctx)

lockPath := path.Join(root, "composer.lock")
if _, err := os.Stat(lockPath); os.IsNotExist(err) {
logging.FromContext(ctx).Infof("Skipping SBOM generation: %s not found", lockPath)
return nil
}

lock, err := composer.ReadLock(lockPath)
if err != nil {
return fmt.Errorf("read composer.lock: %w", err)
}

projectComposer, err := composer.ReadJson(path.Join(root, "composer.json"))
appName := "shopware-project"
appVersion := ""
if err == nil && projectComposer != nil {
if projectComposer.Name != "" {
appName = projectComposer.Name
}
if projectComposer.Version != "" {
appVersion = projectComposer.Version
}
}

bom, err := sbom.Generate(lock, sbom.Options{
ApplicationName: appName,
ApplicationVersion: appVersion,
ToolGroup: "shopware",
ToolName: "shopware-cli",
ToolVersion: tui.AppVersion,
IncludeDevDependencies: false,
return shop.WriteProjectSBOM(ctx, root, shop.ProjectSBOMOptions{
// CI historically skips when lock is missing rather than failing the build.
SkipMissingLock: true,
ToolVersion: tui.AppVersion,
})
if err != nil {
return err
}

data, err := sbom.Marshal(bom)
if err != nil {
return fmt.Errorf("marshal SBOM: %w", err)
}

outputPath := filepath.Join(root, "sbom.cdx.json")

if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return fmt.Errorf("create SBOM output directory: %w", err)
}

if err := os.WriteFile(outputPath, data, 0o644); err != nil {
return fmt.Errorf("write SBOM: %w", err)
}

logging.FromContext(ctx).Infof("Wrote SBOM with %d components to %s", len(bom.Components), outputPath)
return nil
}

func prepareComposerAuth(ctx context.Context, root string) (string, error) {
Expand Down
53 changes: 0 additions & 53 deletions cmd/project/ci_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package project

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -54,58 +53,6 @@ func TestRunTransparentCommandFallsBackToProcessEnv(t *testing.T) {
assert.Contains(t, proc.Cmd.Env, "LOCK_DSN=flock")
}

func TestGenerateProjectSBOM(t *testing.T) {
root := t.TempDir()

assert.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{
"name": "acme/shop",
"version": "1.2.3"
}`), 0o644))

assert.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{
"packages": [
{
"name": "symfony/console",
"version": "v6.3.0",
"type": "library",
"license": ["MIT"],
"require": {"php": ">=8.1"}
}
],
"packages-dev": [
{"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]}
]
}`), 0o644))

assert.NoError(t, generateProjectSBOM(t.Context(), root))

data, err := os.ReadFile(filepath.Join(root, "sbom.cdx.json"))
assert.NoError(t, err)

doc := map[string]interface{}{}
assert.NoError(t, json.Unmarshal(data, &doc))

assert.Equal(t, "CycloneDX", doc["bomFormat"])
assert.Equal(t, "1.7", doc["specVersion"])

metadata := doc["metadata"].(map[string]interface{})
component := metadata["component"].(map[string]interface{})
assert.Equal(t, "acme/shop", component["name"])
assert.Equal(t, "1.2.3", component["version"])

components := doc["components"].([]interface{})
assert.Len(t, components, 1, "dev dependencies excluded by default")
assert.Equal(t, "console", components[0].(map[string]interface{})["name"])
}

func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) {
root := t.TempDir()
assert.NoError(t, generateProjectSBOM(t.Context(), root))

_, err := os.Stat(filepath.Join(root, "sbom.cdx.json"))
assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent")
}

func TestProjectCISafetyCheck(t *testing.T) {
t.Run("allows dirty git working tree in CI", func(t *testing.T) {
root := newDirtyGitRepository(t)
Expand Down
96 changes: 96 additions & 0 deletions cmd/project/project_sbom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package project

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/shopware/shopware-cli/internal/shop"
"github.com/shopware/shopware-cli/internal/tui"
)

var projectSbomCmd = &cobra.Command{
Use: "sbom [path]",
Short: "Generate a CycloneDX SBOM from composer.lock",
Long: `Generate a Software Bill of Materials (SBOM) for a Shopware project.

Reads composer.lock (and optionally composer.json for the root component name
and version) and writes a CycloneDX 1.7 JSON document — the same artifact that
project ci produces, without running the rest of the CI build.

Examples:
# Write sbom.cdx.json into the current Shopware project
shopware-cli project sbom

# Explicit project path and output file
shopware-cli project sbom ./my-shop \
--format cyclonedx-json \
--output sbom.json

The command is non-interactive and exits non-zero when generation fails
(missing or unreadable composer.lock, unsupported format, write errors).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
root, err := resolveProjectSbomRoot(args)
if err != nil {
return err
}

format, err := cmd.Flags().GetString("format")
if err != nil {
return err
}
if err := shop.ValidateProjectSBOMFormat(format); err != nil {
return err
}

output, err := cmd.Flags().GetString("output")
if err != nil {
return err
}

includeDev, err := cmd.Flags().GetBool("include-dev-dependencies")
if err != nil {
return err
}

return shop.WriteProjectSBOM(cmd.Context(), root, shop.ProjectSBOMOptions{
OutputPath: output,
SkipMissingLock: false,
IncludeDevDependencies: includeDev,
ToolVersion: tui.AppVersion,
})
},
}

func init() {
projectRootCmd.AddCommand(projectSbomCmd)
projectSbomCmd.Flags().String("format", shop.ProjectSBOMFormatCycloneDXJSON, "SBOM format (only cyclonedx-json is supported)")
projectSbomCmd.Flags().StringP("output", "o", "", fmt.Sprintf("Output file path (default: %s in the project root)", shop.DefaultProjectSBOMOutput))
projectSbomCmd.Flags().Bool("include-dev-dependencies", false, "Include packages-dev from composer.lock (excluded by default, matching project ci)")
}

// resolveProjectSbomRoot picks the project directory: an explicit path argument,
// otherwise the closest Shopware project (composer.json/lock walk), falling back
// to the working directory when no Shopware markers are found further up.
func resolveProjectSbomRoot(args []string) (string, error) {
if len(args) == 1 {
return filepath.Abs(args[0])
}

root, err := findClosestShopwareProject()
if err == nil {
return root, nil
}

// findClosestShopwareProject fails when no Shopware markers exist. Still
// allow generating an SBOM from a plain composer project in cwd so the
// command is useful outside full Shopware trees.
cwd, cwdErr := os.Getwd()
if cwdErr != nil {
return "", err
}
return cwd, nil
}
106 changes: 106 additions & 0 deletions cmd/project/project_sbom_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package project

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/shopware/shopware-cli/internal/shop"
"github.com/shopware/shopware-cli/internal/tui"
)

func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) {
// CI wrapper must keep the historical skip-on-missing-lock behaviour.
root := t.TempDir()
assert.NoError(t, generateProjectSBOM(t.Context(), root))

_, err := os.Stat(filepath.Join(root, shop.DefaultProjectSBOMOutput))
assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent")
}

func TestGenerateProjectSBOM(t *testing.T) {
root := t.TempDir()

require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{
"name": "acme/shop",
"version": "1.2.3"
}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{
"packages": [
{
"name": "symfony/console",
"version": "v6.3.0",
"type": "library",
"license": ["MIT"],
"require": {"php": ">=8.1"}
}
],
"packages-dev": [
{"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]}
]
}`), 0o644))

assert.NoError(t, generateProjectSBOM(t.Context(), root))

data, err := os.ReadFile(filepath.Join(root, shop.DefaultProjectSBOMOutput))
assert.NoError(t, err)

doc := map[string]interface{}{}
assert.NoError(t, json.Unmarshal(data, &doc))
assert.Equal(t, "CycloneDX", doc["bomFormat"])
assert.Equal(t, "1.7", doc["specVersion"])
}

func TestProjectSbomCommandUnsupportedFormat(t *testing.T) {
root := t.TempDir()

require.NoError(t, projectSbomCmd.Flags().Set("format", "spdx-json"))
require.NoError(t, projectSbomCmd.Flags().Set("output", ""))
t.Cleanup(func() {
_ = projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON)
_ = projectSbomCmd.Flags().Set("output", "")
_ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false")
})

projectSbomCmd.SetContext(t.Context())
err := projectSbomCmd.RunE(projectSbomCmd, []string{root})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported SBOM format")
}

func TestProjectSbomCommandSuccess(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{"name":"acme/shop","version":"1.2.3"}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{
"packages":[{"name":"symfony/console","version":"v6.3.0","type":"library","license":["MIT"]}],
"packages-dev":[]
}`), 0o644))

out := filepath.Join(root, "from-cmd.json")
require.NoError(t, projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON))
require.NoError(t, projectSbomCmd.Flags().Set("output", out))
require.NoError(t, projectSbomCmd.Flags().Set("include-dev-dependencies", "false"))
t.Cleanup(func() {
_ = projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON)
_ = projectSbomCmd.Flags().Set("output", "")
_ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false")
})

// Ensure the tool version used by the command path is stable in tests.
prev := tui.AppVersion
tui.AppVersion = "test"
t.Cleanup(func() { tui.AppVersion = prev })

projectSbomCmd.SetContext(t.Context())
require.NoError(t, projectSbomCmd.RunE(projectSbomCmd, []string{root}))

data, err := os.ReadFile(out)
require.NoError(t, err)
doc := map[string]interface{}{}
require.NoError(t, json.Unmarshal(data, &doc))
assert.Equal(t, "1.7", doc["specVersion"])
}
Loading
Loading