Skip to content

Commit f64852f

Browse files
authored
Merge pull request #27 from jfjrh2014/feat/compliance-policy-and-output
feat: add SBOM quality compliance scoring (NTIA/CISA/BSI) with policy enforcement
2 parents b3eafe3 + 89d9e18 commit f64852f

35 files changed

Lines changed: 2187 additions & 80 deletions

cmd/sbomlyze/main.go

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/rezmoss/sbomlyze/internal/analysis"
1010
"github.com/rezmoss/sbomlyze/internal/cli"
11+
"github.com/rezmoss/sbomlyze/internal/compliance"
1112
"github.com/rezmoss/sbomlyze/internal/convert"
1213
"github.com/rezmoss/sbomlyze/internal/output"
1314
"github.com/rezmoss/sbomlyze/internal/pager"
@@ -126,18 +127,50 @@ func main() {
126127
p := pager.Start(opts.NoPager)
127128
defer p.Stop()
128129

130+
// Load policy when given so path/parse errors surface
131+
var pol policy.Policy
132+
havePolicy := opts.PolicyFile != ""
133+
if havePolicy {
134+
policyData, err := os.ReadFile(opts.PolicyFile)
135+
if err != nil {
136+
fmt.Fprintf(os.Stderr, "err: read policy: %v\n", err)
137+
os.Exit(1)
138+
}
139+
pol, err = policy.Load(policyData)
140+
if err != nil {
141+
fmt.Fprintf(os.Stderr, "err: parse policy: %v\n", err)
142+
os.Exit(1)
143+
}
144+
}
145+
146+
// Evaluate on --compliance, or when policy has score thresholds (so
147+
// they can't be skipped by omitting the flag). Shown only on --compliance.
148+
var complianceReport *compliance.Report
149+
var complianceViolations []policy.Violation
150+
if opts.Compliance || (havePolicy && policy.HasComplianceRules(pol)) {
151+
r := compliance.Evaluate(comps, sbomInfo)
152+
if opts.Compliance {
153+
complianceReport = &r
154+
}
155+
if havePolicy {
156+
complianceViolations = policy.EvaluateCompliance(pol, r)
157+
}
158+
}
159+
129160
switch opts.Format {
130161
case "json":
131162
out := struct {
132-
Info sbom.SBOMInfo `json:"info"`
133-
Findings analysis.KeyFindings `json:"findings"`
134-
Stats analysis.Stats `json:"stats"`
135-
Warnings []cli.ParseWarning `json:"warnings,omitempty"`
163+
Info sbom.SBOMInfo `json:"info"`
164+
Findings analysis.KeyFindings `json:"findings"`
165+
Stats analysis.Stats `json:"stats"`
166+
Compliance *compliance.Report `json:"compliance,omitempty"`
167+
Warnings []cli.ParseWarning `json:"warnings,omitempty"`
136168
}{
137-
Info: sbomInfo,
138-
Findings: findings,
139-
Stats: stats,
140-
Warnings: parseOpts.Warnings,
169+
Info: sbomInfo,
170+
Findings: findings,
171+
Stats: stats,
172+
Compliance: complianceReport,
173+
Warnings: parseOpts.Warnings,
141174
}
142175
enc := json.NewEncoder(os.Stdout)
143176
enc.SetIndent("", " ")
@@ -147,13 +180,30 @@ func main() {
147180
os.Exit(1)
148181
}
149182
case "html":
150-
fmt.Println(output.GenerateHTMLStats(stats, sbomInfo, findings))
183+
fmt.Println(output.GenerateHTMLStatsWithCompliance(stats, sbomInfo, findings, complianceReport))
151184
default:
152185
output.PrintSingleScanContext(sbomInfo)
153186
output.PrintKeyFindings(findings)
154187
analysis.PrintStats(stats)
155188
cli.PrintWarnings(parseOpts.Warnings)
189+
if complianceReport != nil {
190+
compliance.PrintReport(*complianceReport)
191+
}
192+
}
193+
194+
if len(complianceViolations) > 0 {
195+
// Policy violations go to stderr (single-file mode). For non-text
196+
// formats, printing to stdout would corrupt the JSON/HTML output.
197+
w := os.Stdout
198+
if opts.Format == "json" || opts.Format == "html" {
199+
w = os.Stderr
200+
}
201+
output.PrintViolationsTo(w, complianceViolations)
202+
if policy.HasErrors(complianceViolations) {
203+
os.Exit(1)
204+
}
156205
}
206+
157207
return
158208
}
159209

@@ -189,13 +239,14 @@ func main() {
189239
spin.Done("Done")
190240

191241
var violations []policy.Violation
242+
var pol policy.Policy
192243
if opts.PolicyFile != "" {
193244
policyData, err := os.ReadFile(opts.PolicyFile)
194245
if err != nil {
195246
fmt.Fprintf(os.Stderr, "err: read policy: %v\n", err)
196247
os.Exit(1)
197248
}
198-
pol, err := policy.Load(policyData)
249+
pol, err = policy.Load(policyData)
199250
if err != nil {
200251
fmt.Fprintf(os.Stderr, "err: parse policy: %v\n", err)
201252
os.Exit(1)
@@ -208,6 +259,19 @@ func main() {
208259
sbomFile = opts.Files[1]
209260
}
210261

262+
// Evaluate on "after" SBOM (2nd file) on --compliance, or when policy has
263+
// score thresholds (can't skip via no flag). Shown only on --compliance.
264+
var complianceReport *compliance.Report
265+
if opts.Compliance || (opts.PolicyFile != "" && policy.HasComplianceRules(pol)) {
266+
r := compliance.Evaluate(comps2, info2)
267+
if opts.Compliance {
268+
complianceReport = &r
269+
}
270+
if opts.PolicyFile != "" {
271+
violations = append(violations, policy.EvaluateCompliance(pol, r)...)
272+
}
273+
}
274+
211275
p := pager.Start(opts.NoPager)
212276

213277
switch opts.Format {
@@ -217,12 +281,14 @@ func main() {
217281
Findings analysis.KeyFindings `json:"findings"`
218282
Diff analysis.DiffResult `json:"diff"`
219283
Violations []policy.Violation `json:"violations,omitempty"`
284+
Compliance *compliance.Report `json:"compliance,omitempty"`
220285
Warnings []cli.ParseWarning `json:"warnings,omitempty"`
221286
}{
222287
Overview: overview,
223288
Findings: findings,
224289
Diff: result,
225290
Violations: violations,
291+
Compliance: complianceReport,
226292
Warnings: parseOpts.Warnings,
227293
}
228294
enc := json.NewEncoder(os.Stdout)
@@ -254,10 +320,10 @@ func main() {
254320
fmt.Println(xml.Header + string(out))
255321

256322
case "markdown", "md":
257-
fmt.Println(output.GenerateMarkdownWithOverview(result, violations, overview, findings))
323+
fmt.Println(output.GenerateMarkdownWithOverviewAndCompliance(result, violations, overview, findings, complianceReport))
258324

259325
case "html":
260-
fmt.Println(output.GenerateHTML(result, violations, overview, findings))
326+
fmt.Println(output.GenerateHTMLWithCompliance(result, violations, overview, findings, complianceReport))
261327

262328
case "patch":
263329
patch := output.GenerateJSONPatch(result)
@@ -277,6 +343,9 @@ func main() {
277343
output.PrintTextDiff(result)
278344
output.PrintViolations(violations)
279345
cli.PrintWarnings(parseOpts.Warnings)
346+
if complianceReport != nil {
347+
compliance.PrintReport(*complianceReport)
348+
}
280349
}
281350

282351
p.Stop()

examples/policies/compliance.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"max_added": 20,
3+
"max_removed": 10,
4+
"deny_integrity_drift": true,
5+
"require_licenses": true,
6+
"max_depth": 4,
7+
"min_ntia_score": 85,
8+
"min_cisa_score": 70,
9+
"min_overall_compliance": 75,
10+
"warn_new_transitive": true,
11+
"warn_supplier_change": true
12+
}

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
module github.com/rezmoss/sbomlyze
22

3-
go 1.24.2
3+
go 1.25.0
44

55
require (
66
github.com/CycloneDX/cyclonedx-go v0.10.0
77
github.com/charmbracelet/bubbles v1.0.0
88
github.com/charmbracelet/bubbletea v1.3.10
99
github.com/charmbracelet/lipgloss v1.1.0
10+
github.com/github/go-spdx/v2 v2.7.0
1011
github.com/mattn/go-isatty v0.0.21
1112
github.com/spdx/tools-golang v0.5.7
1213
)
@@ -32,6 +33,6 @@ require (
3233
github.com/rivo/uniseg v0.4.7 // indirect
3334
github.com/sahilm/fuzzy v0.1.1 // indirect
3435
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
35-
golang.org/x/sys v0.38.0 // indirect
36+
golang.org/x/sys v0.46.0 // indirect
3637
golang.org/x/text v0.3.8 // indirect
3738
)

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
3636
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3737
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3838
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
39+
github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ=
40+
github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0=
3941
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
4042
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
4143
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -75,8 +77,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
7577
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
7678
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
7779
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
78-
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
79-
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
80+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
81+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
8082
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
8183
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
8284
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/analysis/findings.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,19 @@ type KeyFindings struct {
2727
func ComputeSingleFindings(stats Stats, info sbom.SBOMInfo, comps []sbom.Component) KeyFindings {
2828
var findings []Finding
2929

30+
// pkg-quality findings exclude file/os comps
31+
pkgStats := stats
32+
if pkg := sbom.PackageComponents(comps); len(pkg) != len(comps) {
33+
pkgStats = ComputeStats(pkg)
34+
}
35+
3036
findings = append(findings, detectSingleOS(info)...)
31-
findings = append(findings, detectDominantType(stats)...)
37+
findings = append(findings, detectDominantType(pkgStats)...)
3238
findings = append(findings, detectFilesystemFootprint(info)...)
3339
findings = append(findings, detectRelationshipDensity(info, stats)...)
3440
findings = append(findings, detectLocationHotspots(comps)...)
35-
findings = append(findings, detectLicenseRiskProfile(stats)...)
36-
findings = append(findings, detectDataQuality(stats)...)
41+
findings = append(findings, detectLicenseRiskProfile(pkgStats)...)
42+
findings = append(findings, detectDataQuality(pkgStats)...)
3743
findings = append(findings, detectDuplicateWarning(stats)...)
3844
findings = append(findings, detectCatalogerBreakdown(stats)...)
3945

internal/analysis/findings_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package analysis
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/rezmoss/sbomlyze/internal/sbom"
8+
)
9+
10+
func TestDetectDominantType_IgnoresFileComponents(t *testing.T) {
11+
comps := []sbom.Component{
12+
{ID: "1", Name: "a", PURL: "pkg:npm/a@1", Type: "library"},
13+
{ID: "2", Name: "b", PURL: "pkg:npm/b@1", Type: "library"},
14+
{ID: "3", Name: "c", PURL: "pkg:npm/c@1", Type: "library"},
15+
{ID: "4", Name: "/f1", Type: "file"},
16+
{ID: "5", Name: "/f2", Type: "file"},
17+
{ID: "6", Name: "/f3", Type: "file"},
18+
{ID: "7", Name: "/f4", Type: "file"},
19+
{ID: "8", Name: "/f5", Type: "file"},
20+
}
21+
stats := ComputeStats(comps)
22+
findings := ComputeSingleFindings(stats, sbom.SBOMInfo{}, comps)
23+
for _, f := range findings.Findings {
24+
if strings.Contains(f.Message, "Dominated by file") {
25+
t.Errorf("dominant-type finding must ignore file components, got: %s", f.Message)
26+
}
27+
}
28+
}

internal/analysis/stats.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ func ComputeStats(comps []sbom.Component) Stats {
5656
if ptype == "unknown" && c.PURL == "" {
5757
ptype = ExtractPURLType(c.ID)
5858
}
59+
// no PURL type: fall back to component type (file, os, ...)
60+
if ptype == "unknown" && c.Type != "" {
61+
ptype = c.Type
62+
}
5963
stats.ByType[ptype]++
6064

6165
if c.Language != "" {

internal/analysis/stats_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,21 @@ func TestComputeStats_CompoundLicenses(t *testing.T) {
319319
}
320320
}
321321

322+
323+
func TestComputeStats_ByTypeFallsBackToComponentType(t *testing.T) {
324+
comps := []sbom.Component{
325+
{ID: "id-1", Name: "lib", Version: "1", PURL: "pkg:npm/lib@1"},
326+
{ID: "id-2", Name: "/etc/motd", Type: "file"},
327+
{ID: "id-3", Name: "alpine", Version: "3.22", Type: "operating-system"},
328+
}
329+
stats := ComputeStats(comps)
330+
if stats.ByType["npm"] != 1 {
331+
t.Errorf("expected ByType[npm]=1, got %d", stats.ByType["npm"])
332+
}
333+
if stats.ByType["file"] != 1 {
334+
t.Errorf("expected ByType[file]=1 (component type fallback), got %d (unknown=%d)", stats.ByType["file"], stats.ByType["unknown"])
335+
}
336+
if stats.ByType["operating-system"] != 1 {
337+
t.Errorf("expected ByType[operating-system]=1, got %d", stats.ByType["operating-system"])
338+
}
339+
}

internal/cli/options.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Options struct {
2626
WebServer bool
2727
WebPort int
2828
NoPager bool
29+
Compliance bool // show NTIA/CISA/BSI compliance score
2930
Convert bool
3031
TargetFormat string // cyclonedx, cdx, spdx, syft
3132
OutputFile string
@@ -54,7 +55,7 @@ func ParseArgs(args []string) Options {
5455

5556
if len(args) > 1 && args[1] == "convert" {
5657
opts.Convert = true
57-
args = append(args[:1], args[2:]...) // remove "convert" from args
58+
args = append(args[:1], args[2:]...)
5859
}
5960

6061
for i := 1; i < len(args); i++ {
@@ -93,6 +94,8 @@ func ParseArgs(args []string) Options {
9394
opts.Interactive = true
9495
case "--no-pager":
9596
opts.NoPager = true
97+
case "--compliance":
98+
opts.Compliance = true
9699
case "-web", "--web":
97100
opts.WebServer = true
98101
case "--port":

internal/cli/options_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,35 @@ func TestParseArgs_NonConvertModeUnchanged(t *testing.T) {
236236
t.Error("expected JSONOutput=true")
237237
}
238238
}
239+
240+
func TestParseArgs_ComplianceFlag(t *testing.T) {
241+
t.Run("parses --compliance flag", func(t *testing.T) {
242+
args := []string{"sbomlyze", "a.json", "--compliance"}
243+
opts := ParseArgs(args)
244+
245+
if !opts.Compliance {
246+
t.Error("expected Compliance=true from --compliance flag")
247+
}
248+
})
249+
250+
t.Run("default is no compliance", func(t *testing.T) {
251+
args := []string{"sbomlyze", "a.json"}
252+
opts := ParseArgs(args)
253+
254+
if opts.Compliance {
255+
t.Error("expected default Compliance=false")
256+
}
257+
})
258+
259+
t.Run("compliance with policy", func(t *testing.T) {
260+
args := []string{"sbomlyze", "a.json", "b.json", "--compliance", "--policy", "pol.json"}
261+
opts := ParseArgs(args)
262+
263+
if !opts.Compliance {
264+
t.Error("expected Compliance=true")
265+
}
266+
if opts.PolicyFile != "pol.json" {
267+
t.Errorf("expected PolicyFile=pol.json, got %s", opts.PolicyFile)
268+
}
269+
})
270+
}

0 commit comments

Comments
 (0)