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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **CycloneDX SBOM export** (`--format cyclonedx`): emits a CycloneDX 1.5 JSON
Software Bill of Materials of the dependencies discovered by the SCA engine,
with Package URLs (purls) per ecosystem (npm, pypi, golang, maven, gem,
composer, pub). Flat component inventory, deduplicated and consumable by
Grype, Trivy, and Dependency-Track. Transitive graph and SPDX output are
planned for a later release. ([#31](https://github.com/filipi86/drogonsec/issues/31))

## [0.1.0] - 2026-06-23

First public release of DrogonSec, a high-performance open-source security
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ Drogonsec Security Scanner
sarif_file: results.sarif
```

### CycloneDX SBOM

Export a [CycloneDX](https://cyclonedx.org) 1.5 Software Bill of Materials of the
dependencies discovered by the SCA engine. The output is consumable by tools
like Grype, Trivy, and Dependency-Track.

```bash
drogonsec scan . --format cyclonedx --output sbom.json
```

> **Note:** the SBOM is a flat component inventory with Package URLs (purls). It
> does not yet express the transitive dependency graph, because the SCA engine
> resolves manifests rather than full lockfiles. Transitive resolution and SPDX
> output are planned for a later release.

---

## Configuration
Expand Down
11 changes: 11 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,17 @@ func (a *Analyzer) runSCA(result *ScanResult) error {
return err
}

// Record the full component inventory for SBOM generation (all
// dependencies, not just the vulnerable ones surfaced as findings).
for _, d := range scaEngine.Dependencies() {
result.Dependencies = append(result.Dependencies, Dependency{
Name: d.Name,
Version: d.Version,
Ecosystem: d.Ecosystem,
Manifest: d.File,
})
}

minWeight := config.Severity(a.cfg.MinSeverity).Weight()
for _, f := range findings {
sf := SCAFinding(f)
Expand Down
14 changes: 14 additions & 0 deletions internal/analyzer/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ type SCAFinding struct {
OWASP config.OWASPCategory `json:"owasp"`
}

// Dependency represents a single component discovered by the SCA engine.
// It is the inventory used to produce an SBOM, independent of whether the
// component is vulnerable.
type Dependency struct {
Name string `json:"name"`
Version string `json:"version"`
Ecosystem string `json:"ecosystem"`
Manifest string `json:"manifest"`
}

// LeakFinding represents a detected secret or credential leak
type LeakFinding struct {
Type string `json:"type"` // "AWS Key", "GitHub Token", etc.
Expand Down Expand Up @@ -71,6 +81,10 @@ type ScanResult struct {
SCAFindings []SCAFinding `json:"sca_findings"`
LeakFindings []LeakFinding `json:"leak_findings"`

// Dependencies is the full SCA component inventory (all dependencies,
// not only vulnerable ones), used to generate an SBOM.
Dependencies []Dependency `json:"dependencies,omitempty"`

// Statistics
Stats ScanStats `json:"stats"`

Expand Down
1 change: 1 addition & 0 deletions internal/cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ var (
"json\tmachine-readable report",
"sarif\tGitHub / Azure DevOps Security upload",
"html\tstandalone styled report",
"cyclonedx\tCycloneDX 1.5 SBOM (JSON)",
}
completionAIProvider = []string{
"ollama\tlocal, free, no API key (default when Ollama is running)",
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Examples:
}

func init() {
scanCmd.Flags().StringVarP(&outputFormat, "format", "f", "text", "output format: text, json, sarif, html")
scanCmd.Flags().StringVarP(&outputFormat, "format", "f", "text", "output format: text, json, sarif, html, cyclonedx")
scanCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file path (default: stdout)")
scanCmd.Flags().StringSliceVar(&ignorePaths, "ignore", []string{}, "paths to ignore (comma-separated)")
scanCmd.Flags().BoolVar(&enableAI, "enable-ai", false, "enable AI-powered remediation suggestions")
Expand Down
175 changes: 175 additions & 0 deletions internal/reporter/cyclonedx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package reporter

import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/url"
"path/filepath"
"sort"
"strings"
"time"

"github.com/filipi86/drogonsec/internal/analyzer"
)

// ============= CYCLONEDX SBOM REPORTER =============
// Emits a CycloneDX 1.5 JSON Software Bill of Materials of the dependencies
// discovered by the SCA engine. This is a flat component inventory (one
// component per discovered dependency); it does not yet express the transitive
// dependency graph, because the SCA engine resolves manifests rather than full
// lockfiles. The output is consumable by Grype, Trivy, and Dependency-Track.

// purlTypes maps the SCA engine's ecosystem names to Package URL (purl) types.
// See https://github.com/package-url/purl-spec for the canonical type list.
var purlTypes = map[string]string{
"npm": "npm",
"pypi": "pypi",
"go": "golang",
"maven": "maven",
"rubygems": "gem",
"packagist": "composer",
"pub": "pub",
}

// purlFor builds a Package URL for a dependency. Name segments separated by "/"
// (a golang module path, an npm scope) are treated as namespace separators and
// preserved; each segment is percent-encoded. "@" is encoded to %40 so it is
// never confused with the version separator.
func purlFor(ecosystem, name, version string) string {
t := purlTypes[strings.ToLower(ecosystem)]
if t == "" {
t = strings.ToLower(ecosystem)
}
p := "pkg:" + t + "/" + encodePurlPath(name)
if version != "" {
p += "@" + encodePurlSegment(version)
}
return p
}

func encodePurlPath(s string) string {
parts := strings.Split(s, "/")
for i, p := range parts {
parts[i] = encodePurlSegment(p)
}
return strings.Join(parts, "/")
}

func encodePurlSegment(s string) string {
// url.PathEscape leaves "@" unescaped (it is a legal path char), but in a
// purl "@" introduces the version, so it must be percent-encoded.
return strings.ReplaceAll(url.PathEscape(s), "@", "%40")
}

type cdxBOM struct {
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
SerialNumber string `json:"serialNumber,omitempty"`
Version int `json:"version"`
Metadata cdxMetadata `json:"metadata"`
Components []cdxComponent `json:"components"`
}

type cdxMetadata struct {
Timestamp string `json:"timestamp"`
Tools cdxTools `json:"tools"`
Component *cdxComponent `json:"component,omitempty"`
}

type cdxTools struct {
Components []cdxComponent `json:"components"`
}

type cdxComponent struct {
Type string `json:"type"`
BOMRef string `json:"bom-ref,omitempty"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
PURL string `json:"purl,omitempty"`
}

// CycloneDXReporter writes a CycloneDX 1.5 SBOM.
type CycloneDXReporter struct{}

func (r *CycloneDXReporter) Write(result *analyzer.ScanResult, w io.Writer) error {
bom := buildCycloneDX(result)
sn, err := newSerialNumber()
if err == nil {
bom.SerialNumber = sn
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(bom)
}

// buildCycloneDX assembles the BOM from a scan result. It is deterministic
// (the serial number is added by the caller) so it can be unit-tested directly.
func buildCycloneDX(result *analyzer.ScanResult) cdxBOM {
ts := result.ScanTime
if ts.IsZero() {
ts = time.Now()
}

// Dedup by purl: the same dependency can appear in several manifests, or in
// both the prod and dev dependency maps of one manifest.
seen := make(map[string]bool)
var components []cdxComponent
for _, d := range result.Dependencies {
purl := purlFor(d.Ecosystem, d.Name, d.Version)
if seen[purl] {
continue
}
seen[purl] = true
components = append(components, cdxComponent{
Type: "library",
BOMRef: purl,
Name: d.Name,
Version: d.Version,
PURL: purl,
})
}
// Stable ordering for reproducible output.
sort.Slice(components, func(i, j int) bool {
return components[i].BOMRef < components[j].BOMRef
})

name := filepath.Base(result.TargetPath)
if name == "." || name == "" || name == string(filepath.Separator) {
name = "application"
}

return cdxBOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.5",
Version: 1,
Metadata: cdxMetadata{
Timestamp: ts.UTC().Format(time.RFC3339),
Tools: cdxTools{
Components: []cdxComponent{{
Type: "application",
Name: "DrogonSec Security Scanner",
Version: result.Version,
}},
},
Component: &cdxComponent{
Type: "application",
BOMRef: "root:" + name,
Name: name,
},
},
Components: components,
}
}

// newSerialNumber returns a CycloneDX urn:uuid serial number (UUID v4).
func newSerialNumber() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
105 changes: 105 additions & 0 deletions internal/reporter/cyclonedx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package reporter

import (
"bytes"
"encoding/json"
"testing"
"time"

"github.com/filipi86/drogonsec/internal/analyzer"
)

func TestPurlFor(t *testing.T) {
cases := []struct {
eco, name, version, want string
}{
{"npm", "lodash", "4.17.15", "pkg:npm/lodash@4.17.15"},
{"npm", "@angular/core", "17.0.0", "pkg:npm/%40angular/core@17.0.0"},
{"go", "github.com/go-git/go-git/v5", "v5.19.1", "pkg:golang/github.com/go-git/go-git/v5@v5.19.1"},
{"pypi", "requests", "2.31.0", "pkg:pypi/requests@2.31.0"},
{"rubygems", "rails", "7.1.0", "pkg:gem/rails@7.1.0"},
{"packagist", "monolog/monolog", "2.9.1", "pkg:composer/monolog/monolog@2.9.1"},
{"maven", "struts2-core", "2.3.34", "pkg:maven/struts2-core@2.3.34"},
{"pub", "http", "1.2.0", "pkg:pub/http@1.2.0"},
{"npm", "noversion", "", "pkg:npm/noversion"},
}
for _, c := range cases {
got := purlFor(c.eco, c.name, c.version)
if got != c.want {
t.Errorf("purlFor(%q,%q,%q) = %q; want %q", c.eco, c.name, c.version, got, c.want)
}
}
}

func TestBuildCycloneDX_DedupAndSort(t *testing.T) {
result := &analyzer.ScanResult{
TargetPath: "/tmp/myproject",
ScanTime: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
Version: "0.1.0",
Dependencies: []analyzer.Dependency{
{Name: "lodash", Version: "4.17.15", Ecosystem: "npm", Manifest: "a/package.json"},
{Name: "lodash", Version: "4.17.15", Ecosystem: "npm", Manifest: "b/package.json"}, // dup
{Name: "express", Version: "4.18.2", Ecosystem: "npm", Manifest: "a/package.json"},
},
}

bom := buildCycloneDX(result)

if bom.BOMFormat != "CycloneDX" || bom.SpecVersion != "1.5" || bom.Version != 1 {
t.Fatalf("unexpected BOM header: %+v", bom)
}
if len(bom.Components) != 2 {
t.Fatalf("expected 2 deduped components, got %d", len(bom.Components))
}
// Sorted by bom-ref: express before lodash.
if bom.Components[0].Name != "express" || bom.Components[1].Name != "lodash" {
t.Errorf("components not sorted by purl: %q, %q", bom.Components[0].Name, bom.Components[1].Name)
}
if bom.Components[0].PURL != "pkg:npm/express@4.18.2" {
t.Errorf("unexpected purl: %q", bom.Components[0].PURL)
}
if bom.Metadata.Component == nil || bom.Metadata.Component.Name != "myproject" {
t.Errorf("expected metadata.component name 'myproject', got %+v", bom.Metadata.Component)
}
if bom.Metadata.Timestamp != "2026-06-23T12:00:00Z" {
t.Errorf("unexpected timestamp: %q", bom.Metadata.Timestamp)
}
}

func TestCycloneDXReporter_WriteValidJSON(t *testing.T) {
result := &analyzer.ScanResult{
TargetPath: "/tmp/proj",
ScanTime: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
Version: "0.1.0",
Dependencies: []analyzer.Dependency{
{Name: "requests", Version: "2.31.0", Ecosystem: "pypi", Manifest: "requirements.txt"},
},
}

var buf bytes.Buffer
if err := (&CycloneDXReporter{}).Write(result, &buf); err != nil {
t.Fatalf("Write returned error: %v", err)
}

var parsed map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed["bomFormat"] != "CycloneDX" {
t.Errorf("missing/invalid bomFormat: %v", parsed["bomFormat"])
}
sn, ok := parsed["serialNumber"].(string)
if !ok || len(sn) < len("urn:uuid:") || sn[:9] != "urn:uuid:" {
t.Errorf("expected urn:uuid serialNumber, got %v", parsed["serialNumber"])
}
}

func TestNewReporter_CycloneDX(t *testing.T) {
rep, err := New("cyclonedx")
if err != nil {
t.Fatalf("New(cyclonedx) error: %v", err)
}
if _, ok := rep.(*CycloneDXReporter); !ok {
t.Errorf("expected *CycloneDXReporter, got %T", rep)
}
}
4 changes: 3 additions & 1 deletion internal/reporter/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ func New(format string) (Reporter, error) {
return &SARIFReporter{}, nil
case "html":
return &HTMLReporter{}, nil
case "cyclonedx":
return &CycloneDXReporter{}, nil
default:
return nil, fmt.Errorf("unknown format: %s (use: text, json, sarif, html)", format)
return nil, fmt.Errorf("unknown format: %s (use: text, json, sarif, html, cyclonedx)", format)
}
}

Expand Down
Loading
Loading