From 8b1deb355639d672dbebabae4727922befefc40a Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Tue, 2 Jun 2026 23:36:03 -0700 Subject: [PATCH 1/7] feat: add experimental SBOM attestations Add an experimental bomly sbom command group with attest and verify subcommands. The MVP supports file, directory, git, and immutable container digest subjects. Directory scans are modeled as one whole-folder subject so multi-subproject folders produce one attested SBOM claim. Git subjects require a clean worktree, and container subjects require image@sha256 digest references. Add internal/attestation for subject resolution, in-toto statement construction, Sigstore bundle-format DSSE signing, verification, and verified SBOM extraction. The experimental local-key/keyless bundle path avoids external cosign shelling while keeping the bundle structure portable. Document the experimental workflow, trust model, limitations, and TDD guidance for future security-sensitive features. Private Cloud planning notes are intentionally excluded from Git. --- AGENTS.md | 2 + CLAUDE.md | 4 + docs/ARCHITECTURE.md | 1 + docs/CI_INTEGRATION.md | 2 + docs/README.md | 1 + docs/SBOM.md | 4 + docs/SBOM_ATTESTATIONS.md | 82 +++++ go.mod | 4 +- go.sum | 6 +- internal/attestation/bundle.go | 402 ++++++++++++++++++++++ internal/attestation/bundle_test.go | 127 +++++++ internal/attestation/subject.go | 309 +++++++++++++++++ internal/attestation/subject_test.go | 127 +++++++ internal/attestation/test_helpers_test.go | 21 ++ internal/cli/root_cmd.go | 2 + internal/cli/sbom_cmd.go | 177 ++++++++++ internal/cli/sbom_cmd_test.go | 119 +++++++ 17 files changed, 1387 insertions(+), 3 deletions(-) create mode 100644 docs/SBOM_ATTESTATIONS.md create mode 100644 internal/attestation/bundle.go create mode 100644 internal/attestation/bundle_test.go create mode 100644 internal/attestation/subject.go create mode 100644 internal/attestation/subject_test.go create mode 100644 internal/attestation/test_helpers_test.go create mode 100644 internal/cli/sbom_cmd.go create mode 100644 internal/cli/sbom_cmd_test.go diff --git a/AGENTS.md b/AGENTS.md index 8b9da4e2..9cd57112 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for full detail. Component ma | `internal/matchers/*` | External enrichment matchers and shared matcher cache (osv, grype, deps.dev, ClearlyDefined, eol, scorecard) | | `internal/auditors/*` | Policy evaluators and audit-only logic (policy, noop) | | `internal/sbom` | SBOM codec (SPDX 2.3, CycloneDX) | +| `internal/attestation` | Experimental SBOM attestation subject resolution, in-toto statement construction, and bundle verification | | `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets | | `internal/output` | Output rendering plus structured command payloads and schema generation for `scan`, `diff`, `explain`, JSON, and SARIF 2.1.0 | | `internal/plugin` | Plugin discovery, protocol, handshake, and execution | @@ -142,6 +143,7 @@ Core passes these env vars. Plugin discovery: `~/.bomly/plugins/bomly-*` overrid ## Quality Bar - Every exported type/function has a doc comment. +- Use TDD for security-sensitive user-visible features: write failing unit or command tests first, implement the smallest clean change that passes, then refactor for readability and maintainability. - Unit tests for new logic; integration tests for new commands. - Test helpers: `t.TempDir()`, `testutil.BuildGoBinary()`, `httptest.NewServer()`. - Generated docs are part of the contract: update `docs/CONFIG_REFERENCE.md`, `docs/schemas/*`, and `docs/SUPPORT_MATRIX.md` via `make generate` when their source packages change. diff --git a/CLAUDE.md b/CLAUDE.md index 28d50a04..dd186f82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ internal/analyzers/* Reachability analyzers (govulncheck — Go; and never abort the pipeline on failure internal/auditors/* Policy evaluators (policy, noop) internal/sbom/ SPDX 2.3 / CycloneDX codec +internal/attestation/ Experimental SBOM attestation subject resolution, + in-toto statement construction, and bundle verification internal/benchmark/ Hidden local dependency-graph benchmark, baseline scoring, and embedded smoke/benchmark repository presets internal/output/ Text, JSON, SARIF 2.1.0, SBOM rendering + schema generation @@ -136,6 +138,8 @@ Cache failures are non-fatal — log a warning and continue. **Testing helpers**: `t.TempDir()`, `testutil.BuildGoBinary()`, `httptest.NewServer()`. Shared fake-binary setup lives in `internal/cli/root_test_main_test.go`. No tests may be conditionally skipped without a recorded reason. +**TDD for security-sensitive features**: for user-visible security functionality, write failing unit or command tests first, implement the smallest clean change that passes, then refactor for readability and maintainability before broadening coverage. + ## Feature Checklist When adding a new user-visible feature (new CLI flag, new component class, new pipeline stage, new analyzer, etc.), walk this checklist before requesting review. Reviewers will ask for everything that applies, and the surface that gets forgotten most often is **MCP** + **plugin command** + **smoke test**. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8133595f..9b000702 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -196,6 +196,7 @@ Cache failures are non-fatal. The command should warn and continue rather than f | `internal/engine/scan` | Scan command pipeline API | | `internal/output` | Text, JSON, SARIF rendering, plus structured response payloads and schema generation | | `internal/sbom` | SPDX and CycloneDX codecs | +| `internal/attestation` | Experimental SBOM attestation subject resolution, in-toto statement construction, and bundle verification | | `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets | | `sdk` | Shared domain types | | `internal/plugin` | Managed plugin manifests, installation, verification, store state, adapters, and runtime glue | diff --git a/docs/CI_INTEGRATION.md b/docs/CI_INTEGRATION.md index 6ffd79ea..7ea29840 100644 --- a/docs/CI_INTEGRATION.md +++ b/docs/CI_INTEGRATION.md @@ -218,6 +218,8 @@ Tune `--fail-on` to taste. `pre-push` keeps commits fast and only runs on push. ## See also +- [SBOM attestations](SBOM_ATTESTATIONS.md) — experimental SBOM signing and verification + - [Exit codes](EXIT_CODES.md) — what each CI exit means - [Output formats](OUTPUT_FORMATS.md) — SARIF, JSON, SBOM details - [Auditors](AUDITORS.md) — `--fail-on` and `--fail-on-scope` diff --git a/docs/README.md b/docs/README.md index ccb5bd2d..c7f600e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Task-oriented walkthroughs. - [Scan Targets](SCAN_TARGETS.md) — directories, Git repos, containers, SBOMs - [Output Formats](OUTPUT_FORMATS.md) — text, JSON, SARIF, SBOM - [SBOM Formats](SBOM.md) — SPDX vs. CycloneDX, write and ingest +- [SBOM Attestations](SBOM_ATTESTATIONS.md) — experimental signing and verification for SBOM artifacts - [CI Integration](CI_INTEGRATION.md) — GitHub Actions, GitLab, Jenkins, Azure, CircleCI - [Interactive TUI](TUI.md) — keybindings and tabs for `--interactive` - [Troubleshooting](TROUBLESHOOTING.md) — common errors and fixes diff --git a/docs/SBOM.md b/docs/SBOM.md index df8a6656..3ec2a850 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -8,6 +8,8 @@ A Software Bill of Materials is a structured list of every package in a piece of You produce an SBOM once and consume it many times: in PR checks, in release artifacts, in supplier audits, in attestation pipelines. +To sign and verify an SBOM claim about a file, folder, Git snapshot, or immutable container digest, see [SBOM attestations](SBOM_ATTESTATIONS.md). + ## Format comparison | | SPDX 2.3 | CycloneDX 1.6 | @@ -96,6 +98,8 @@ Bomly does not advertise a one-shot `convert` command — the scan pipeline is t ## See also +- [SBOM attestations](SBOM_ATTESTATIONS.md) — experimental signing and verification + - [Scan targets](SCAN_TARGETS.md) — every input Bomly accepts - [Output formats](OUTPUT_FORMATS.md) — text, JSON, SARIF, SBOM details - [SBOM detector](detectors/ecosystems/sbom/sbom.md) — ingest specifics diff --git a/docs/SBOM_ATTESTATIONS.md b/docs/SBOM_ATTESTATIONS.md new file mode 100644 index 00000000..13169582 --- /dev/null +++ b/docs/SBOM_ATTESTATIONS.md @@ -0,0 +1,82 @@ +# SBOM Attestations + +> **Experimental.** SBOM attestations are opt-in and under active development. The MVP creates and verifies local Sigstore-style DSSE bundles around in-toto statements, but certificate identity verification and registry attachment are not part of the first release. + +An SBOM attestation binds an SBOM to the thing it describes. Verification checks that: + +- The attestation signature is valid. +- The requested subject digest matches the attested subject. +- The embedded SBOM is SPDX 2.3 or CycloneDX JSON and parses successfully. + +An attestation does not prove the SBOM is complete or correct. It proves that a signer made a claim about a subject. Trust still depends on who signed it and how the SBOM was produced. + +## Create an attestation + +Generate an SBOM first, then attest it: + +```bash +bomly scan -o spdx=sbom.spdx.json +bomly sbom attest \ + --sbom sbom.spdx.json \ + --subject dir:. \ + --output sbom.att.json \ + --keyless +``` + +The experimental `--keyless` mode creates a self-contained local bundle for convenient testing and CI artifact integrity checks. It is not an identity-backed Fulcio/OIDC signature yet. + +For a durable local signing key, pass an ECDSA P-256 PEM private key: + +```bash +bomly sbom attest \ + --sbom sbom.spdx.json \ + --subject git \ + --output sbom.att.json \ + --key signing-key.pem +``` + +## Verify an attestation + +Verify the bundle against the same subject: + +```bash +bomly sbom verify \ + --attestation sbom.att.json \ + --subject dir:. +``` + +To extract the verified embedded SBOM: + +```bash +bomly sbom verify \ + --attestation sbom.att.json \ + --subject git \ + --extract-sbom verified.spdx.json +``` + +`--extract-sbom` writes only after signature, subject, predicate, and SBOM checks pass. + +## Subjects + +| Subject | Use for | Behavior | +| --- | --- | --- | +| `file:` | Release archives, binaries, package files | Hashes the file bytes with SHA-256 | +| `dir:` | Local source folders and monorepos | Computes one deterministic tree digest over regular files | +| `git` | CI source snapshots | Requires a clean worktree and hashes a deterministic `git archive HEAD` view | +| `container:` | Container scan results | Accepts digest references only; tags are rejected | + +For folders with multiple subprojects, Bomly creates one attestation for the whole folder snapshot. The SBOM is treated as one document describing the full scan result. If you need per-subproject attestations, scan each subpath separately. + +## Limitations + +- The feature is experimental and the bundle shape may evolve. +- Container subjects must use `image@sha256:`; Bomly does not resolve tags for attestation subjects. +- Certificate identity verification flags are reserved for future identity-backed Sigstore bundles. +- Registry attachment is deferred. Store the SBOM and attestation as CI/release artifacts for now. +- The tree digest excludes VCS metadata and any SBOM or attestation output path passed to the command when those outputs live inside the subject folder. + +## See also + +- [SBOM formats](SBOM.md) +- [CI integration](CI_INTEGRATION.md) +- [Output formats](OUTPUT_FORMATS.md) diff --git a/go.mod b/go.mod index 52bb98f8..0d05a4a2 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,10 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.8.0 + github.com/in-toto/attestation v1.2.0 github.com/mark3labs/mcp-go v0.54.1 github.com/pandatix/go-cvss v0.6.2 + github.com/sigstore/protobuf-specs v0.5.0 github.com/spdx/tools-golang v0.5.7 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -188,7 +190,6 @@ require ( github.com/henvic/httpretty v0.1.4 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/in-toto/attestation v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/copier v0.4.0 // indirect @@ -253,6 +254,7 @@ require ( github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect diff --git a/go.sum b/go.sum index a9e4722d..7a2f3738 100644 --- a/go.sum +++ b/go.sum @@ -864,8 +864,8 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -904,6 +904,8 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= +github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= diff --git a/internal/attestation/bundle.go b/internal/attestation/bundle.go new file mode 100644 index 00000000..ed51d7a3 --- /dev/null +++ b/internal/attestation/bundle.go @@ -0,0 +1,402 @@ +package attestation + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bomly-dev/bomly-cli/internal/sbom" + intoto "github.com/in-toto/attestation/go/v1" + protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1" + protodsse "github.com/sigstore/protobuf-specs/gen/pb-go/dsse" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + // PredicateTypeSBOM identifies Bomly's experimental SBOM predicate. + PredicateTypeSBOM = "https://bomly.dev/attestation/sbom/v1" + payloadTypeInToto = "application/vnd.in-toto+json" + bundleMediaType = "application/vnd.bomly.sbom-attestation.v1+json" + sigstoreBundleV03Type = "application/vnd.dev.sigstore.bundle.v0.3+json" +) + +// AttestRequest describes an SBOM attestation request. +type AttestRequest struct { + SBOMPath string + Subject Subject + KeyPath string + Keyless bool +} + +// VerifyRequest describes an SBOM attestation verification request. +type VerifyRequest struct { + Bundle []byte + Subject Subject + KeyPath string +} + +// VerifyResult describes a verified SBOM attestation. +type VerifyResult struct { + Subject Subject + SBOMFormat string + SBOM []byte +} + +type bomlyBundle struct { + MediaType string `json:"mediaType"` + SigstoreBundle json.RawMessage `json:"sigstoreBundle"` + PublicKeyPEM string `json:"publicKeyPem,omitempty"` +} + +// BuildStatement builds an in-toto statement carrying a supported SBOM predicate. +func BuildStatement(subject Subject, sbomBytes []byte) (*intoto.Statement, error) { + target, err := detectSupportedSBOM(sbomBytes) + if err != nil { + return nil, err + } + var sbomObject map[string]any + if err := json.Unmarshal(sbomBytes, &sbomObject); err != nil { + return nil, fmt.Errorf("parse sbom json: %w", err) + } + sum := sha256.Sum256(sbomBytes) + predicate, err := structpb.NewStruct(map[string]any{ + "schemaVersion": "experimental/v1", + "sbomFormat": string(target), + "sbomDigest": map[string]any{ + "sha256": hex.EncodeToString(sum[:]), + }, + "sbom": sbomObject, + }) + if err != nil { + return nil, fmt.Errorf("build sbom predicate: %w", err) + } + statement := &intoto.Statement{ + Type: intoto.StatementTypeUri, + Subject: []*intoto.ResourceDescriptor{resourceDescriptor(subject)}, + PredicateType: PredicateTypeSBOM, + Predicate: predicate, + } + if err := statement.Validate(); err != nil { + return nil, fmt.Errorf("validate in-toto statement: %w", err) + } + return statement, nil +} + +// Attest creates a signed experimental SBOM attestation bundle. +func Attest(ctx context.Context, req AttestRequest) ([]byte, error) { + sbomBytes, err := os.ReadFile(req.SBOMPath) + if err != nil { + return nil, fmt.Errorf("read sbom: %w", err) + } + statement, err := BuildStatement(req.Subject, sbomBytes) + if err != nil { + return nil, err + } + statementBytes, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(statement) + if err != nil { + return nil, fmt.Errorf("marshal in-toto statement: %w", err) + } + keypair, err := loadSigningKey(req) + if err != nil { + return nil, err + } + protoBundle, err := signDSSEBundle(ctx, statementBytes, keypair) + if err != nil { + return nil, fmt.Errorf("sign sbom attestation: %w", err) + } + bundleBytes, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(protoBundle) + if err != nil { + return nil, fmt.Errorf("marshal sigstore bundle: %w", err) + } + publicKeyPEM, err := keypair.GetPublicKeyPem() + if err != nil { + return nil, fmt.Errorf("marshal public key: %w", err) + } + return json.MarshalIndent(bomlyBundle{ + MediaType: bundleMediaType, + SigstoreBundle: bundleBytes, + PublicKeyPEM: publicKeyPEM, + }, "", " ") +} + +// Verify verifies an experimental SBOM attestation bundle and returns the embedded SBOM. +func Verify(_ context.Context, req VerifyRequest) (VerifyResult, error) { + var wrapped bomlyBundle + if err := json.Unmarshal(req.Bundle, &wrapped); err != nil { + return VerifyResult{}, fmt.Errorf("parse attestation bundle: %w", err) + } + if wrapped.MediaType != bundleMediaType { + return VerifyResult{}, fmt.Errorf("unsupported attestation media type %q", wrapped.MediaType) + } + var protoBundle protobundle.Bundle + if err := protojson.Unmarshal(wrapped.SigstoreBundle, &protoBundle); err != nil { + return VerifyResult{}, fmt.Errorf("parse sigstore bundle: %w", err) + } + envelope := protoBundle.GetDsseEnvelope() + if envelope == nil { + return VerifyResult{}, fmt.Errorf("attestation bundle does not contain a DSSE envelope") + } + if envelope.PayloadType != payloadTypeInToto { + return VerifyResult{}, fmt.Errorf("unsupported DSSE payload type %q", envelope.PayloadType) + } + if len(envelope.Signatures) != 1 { + return VerifyResult{}, fmt.Errorf("expected exactly one DSSE signature, got %d", len(envelope.Signatures)) + } + publicKeyPEM := strings.TrimSpace(wrapped.PublicKeyPEM) + if strings.TrimSpace(req.KeyPath) != "" { + data, err := os.ReadFile(req.KeyPath) + if err != nil { + return VerifyResult{}, fmt.Errorf("read verification key: %w", err) + } + publicKeyPEM = string(data) + } + if err := verifyECDSADSSE(publicKeyPEM, envelope.PayloadType, envelope.Payload, envelope.Signatures[0].Sig); err != nil { + return VerifyResult{}, err + } + var statement intoto.Statement + if err := protojson.Unmarshal(envelope.Payload, &statement); err != nil { + return VerifyResult{}, fmt.Errorf("parse in-toto statement: %w", err) + } + if err := statement.Validate(); err != nil { + return VerifyResult{}, fmt.Errorf("validate in-toto statement: %w", err) + } + if statement.PredicateType != PredicateTypeSBOM { + return VerifyResult{}, fmt.Errorf("unsupported predicate type %q", statement.PredicateType) + } + if !subjectMatches(req.Subject, statement.Subject) { + return VerifyResult{}, fmt.Errorf("subject digest does not match attestation") + } + sbomBytes, target, err := sbomFromPredicate(statement.Predicate) + if err != nil { + return VerifyResult{}, err + } + return VerifyResult{Subject: req.Subject, SBOMFormat: string(target), SBOM: sbomBytes}, nil +} + +func resourceDescriptor(subject Subject) *intoto.ResourceDescriptor { + return &intoto.ResourceDescriptor{ + Name: subject.Name, + Uri: subject.URI, + Digest: subject.Digest, + } +} + +func detectSupportedSBOM(data []byte) (sbom.Target, error) { + target, err := sbom.DetectJSONTarget(data) + if err != nil { + return "", fmt.Errorf("detect sbom format: %w", err) + } + switch target { + case sbom.TargetSPDX23JSON, sbom.TargetCycloneDX14JSON, sbom.TargetCycloneDX15JSON, sbom.TargetCycloneDX16JSON: + return target, nil + default: + return "", fmt.Errorf("unsupported sbom format %q for attestation", target) + } +} + +func sbomFromPredicate(predicate *structpb.Struct) ([]byte, sbom.Target, error) { + if predicate == nil { + return nil, "", fmt.Errorf("sbom predicate is missing") + } + formatValue := predicate.Fields["sbomFormat"].GetStringValue() + sbomValue := predicate.Fields["sbom"] + if formatValue == "" || sbomValue == nil { + return nil, "", fmt.Errorf("sbom predicate is missing required fields") + } + raw, err := json.Marshal(sbomValue.AsInterface()) + if err != nil { + return nil, "", fmt.Errorf("marshal embedded sbom: %w", err) + } + target, err := detectSupportedSBOM(raw) + if err != nil { + return nil, "", err + } + if string(target) != formatValue { + return nil, "", fmt.Errorf("sbom predicate format %q does not match embedded sbom %q", formatValue, target) + } + return raw, target, nil +} + +func subjectMatches(expected Subject, actual []*intoto.ResourceDescriptor) bool { + if len(actual) != 1 { + return false + } + for alg, digest := range expected.Digest { + if !strings.EqualFold(actual[0].Digest[alg], digest) { + return false + } + } + return true +} + +func loadSigningKey(req AttestRequest) (*ecdsaKeypair, error) { + if req.Keyless { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate keyless signing key: %w", err) + } + return newECDSAKeypair(privateKey) + } + if strings.TrimSpace(req.KeyPath) == "" { + return nil, fmt.Errorf("--key or --keyless is required") + } + data, err := os.ReadFile(req.KeyPath) + if err != nil { + return nil, fmt.Errorf("read signing key: %w", err) + } + privateKey, err := parseECDSAPrivateKey(data) + if err != nil { + return nil, fmt.Errorf("parse signing key: %w", err) + } + return newECDSAKeypair(privateKey) +} + +func signDSSEBundle(ctx context.Context, payload []byte, keypair *ecdsaKeypair) (*protobundle.Bundle, error) { + pae := dssePAE(payloadTypeInToto, payload) + signature, _, err := keypair.SignData(ctx, pae) + if err != nil { + return nil, err + } + return &protobundle.Bundle{ + MediaType: sigstoreBundleV03Type, + VerificationMaterial: &protobundle.VerificationMaterial{ + Content: &protobundle.VerificationMaterial_PublicKey{ + PublicKey: &protocommon.PublicKeyIdentifier{Hint: string(keypair.GetHint())}, + }, + }, + Content: &protobundle.Bundle_DsseEnvelope{ + DsseEnvelope: &protodsse.Envelope{ + Payload: payload, + PayloadType: payloadTypeInToto, + Signatures: []*protodsse.Signature{{ + Sig: signature, + }}, + }, + }, + }, nil +} + +func parseECDSAPrivateKey(data []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("PEM private key is required") + } + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("only ECDSA P-256 private keys are supported") + } + return key, nil +} + +func verifyECDSADSSE(publicKeyPEM, payloadType string, payload, sig []byte) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("public verification key is missing") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("parse public verification key: %w", err) + } + publicKey, ok := parsed.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("only ECDSA public keys are supported") + } + pae := dssePAE(payloadType, payload) + digest := sha256.Sum256(pae) + if !ecdsa.VerifyASN1(publicKey, digest[:], sig) { + return fmt.Errorf("attestation signature verification failed") + } + return nil +} + +type ecdsaKeypair struct { + privateKey *ecdsa.PrivateKey + hint []byte +} + +func newECDSAKeypair(privateKey *ecdsa.PrivateKey) (*ecdsaKeypair, error) { + pubKeyBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return nil, err + } + sum := sha256.Sum256(pubKeyBytes) + return &ecdsaKeypair{privateKey: privateKey, hint: []byte(base64.StdEncoding.EncodeToString(sum[:]))}, nil +} + +func (k *ecdsaKeypair) GetHashAlgorithm() protocommon.HashAlgorithm { + return protocommon.HashAlgorithm_SHA2_256 +} + +func (k *ecdsaKeypair) GetSigningAlgorithm() protocommon.PublicKeyDetails { + return protocommon.PublicKeyDetails_PKIX_ECDSA_P256_SHA_256 +} + +func (k *ecdsaKeypair) GetHint() []byte { + return k.hint +} + +func (k *ecdsaKeypair) GetKeyAlgorithm() string { + return "ECDSA" +} + +func (k *ecdsaKeypair) GetPublicKey() crypto.PublicKey { + return k.privateKey.Public() +} + +func (k *ecdsaKeypair) GetPublicKeyPem() (string, error) { + data, err := x509.MarshalPKIXPublicKey(k.privateKey.Public()) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: data})), nil +} + +func (k *ecdsaKeypair) SignData(_ context.Context, data []byte) ([]byte, []byte, error) { + digest := sha256.Sum256(data) + signature, err := k.privateKey.Sign(rand.Reader, digest[:], crypto.SHA256) + if err != nil { + return nil, nil, err + } + return signature, digest[:], nil +} + +func dssePAE(payloadType string, payload []byte) []byte { + return []byte(fmt.Sprintf("DSSEv1 %d %s %d %s", len(payloadType), payloadType, len(payload), payload)) +} + +// WriteVerifiedSBOM writes a verified SBOM to path. +func WriteVerifiedSBOM(path string, data []byte) error { + if strings.TrimSpace(path) == "" { + return nil + } + if parent := filepath.Dir(path); parent != "." && parent != "" { + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create verified sbom directory: %w", err) + } + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("write verified sbom: %w", err) + } + return nil +} diff --git a/internal/attestation/bundle_test.go b/internal/attestation/bundle_test.go new file mode 100644 index 00000000..7d18a6d4 --- /dev/null +++ b/internal/attestation/bundle_test.go @@ -0,0 +1,127 @@ +package attestation + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildStatementEmbedsSupportedSBOM(t *testing.T) { + subject := Subject{ + Kind: SubjectKindFile, + Name: "file:artifact.tar", + Digest: map[string]string{"sha256": strings.Repeat("a", 64)}, + } + statement, err := BuildStatement(subject, []byte(minimalSPDXDocument())) + if err != nil { + t.Fatalf("BuildStatement() error = %v", err) + } + if statement.PredicateType != PredicateTypeSBOM { + t.Fatalf("PredicateType = %q, want %q", statement.PredicateType, PredicateTypeSBOM) + } + if len(statement.Subject) != 1 || statement.Subject[0].Name != subject.Name { + t.Fatalf("unexpected subject: %#v", statement.Subject) + } + if got := statement.Predicate.Fields["sbomFormat"].GetStringValue(); got != "spdx-2.3+json" { + t.Fatalf("sbomFormat = %q", got) + } +} + +func TestBuildStatementRejectsUnsupportedSBOM(t *testing.T) { + subject := Subject{Name: "file:artifact", Digest: map[string]string{"sha256": strings.Repeat("a", 64)}} + if _, err := BuildStatement(subject, []byte(`{"not":"an sbom"}`)); err == nil { + t.Fatal("expected unsupported SBOM error") + } +} + +func TestAttestAndVerifyKeylessRoundTrip(t *testing.T) { + dir := t.TempDir() + sbomPath := filepath.Join(dir, "bomly.spdx.json") + if err := os.WriteFile(sbomPath, []byte(minimalSPDXDocument()), 0o644); err != nil { + t.Fatalf("write sbom: %v", err) + } + subjectPath := filepath.Join(dir, "artifact.txt") + if err := os.WriteFile(subjectPath, []byte("artifact"), 0o644); err != nil { + t.Fatalf("write subject: %v", err) + } + subject, err := ResolveSubject("file:"+subjectPath, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject() error = %v", err) + } + + bundle, err := Attest(context.Background(), AttestRequest{ + SBOMPath: sbomPath, + Subject: subject, + Keyless: true, + }) + if err != nil { + t.Fatalf("Attest() error = %v", err) + } + if !json.Valid(bundle) { + t.Fatalf("bundle is not JSON: %s", bundle) + } + + result, err := Verify(context.Background(), VerifyRequest{ + Bundle: bundle, + Subject: subject, + }) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + if result.SBOMFormat != "spdx-2.3+json" { + t.Fatalf("SBOMFormat = %q", result.SBOMFormat) + } + if len(result.SBOM) == 0 { + t.Fatal("expected verified SBOM bytes") + } +} + +func TestVerifyRejectsWrongSubject(t *testing.T) { + dir := t.TempDir() + sbomPath := filepath.Join(dir, "bomly.spdx.json") + if err := os.WriteFile(sbomPath, []byte(minimalSPDXDocument()), 0o644); err != nil { + t.Fatalf("write sbom: %v", err) + } + onePath := filepath.Join(dir, "one.txt") + twoPath := filepath.Join(dir, "two.txt") + if err := os.WriteFile(onePath, []byte("one"), 0o644); err != nil { + t.Fatalf("write subject one: %v", err) + } + if err := os.WriteFile(twoPath, []byte("two"), 0o644); err != nil { + t.Fatalf("write subject two: %v", err) + } + one, err := ResolveSubject("file:"+onePath, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject(one) error = %v", err) + } + two, err := ResolveSubject("file:"+twoPath, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject(two) error = %v", err) + } + bundle, err := Attest(context.Background(), AttestRequest{SBOMPath: sbomPath, Subject: one, Keyless: true}) + if err != nil { + t.Fatalf("Attest() error = %v", err) + } + _, err = Verify(context.Background(), VerifyRequest{Bundle: bundle, Subject: two}) + if err == nil || !strings.Contains(err.Error(), "subject digest does not match") { + t.Fatalf("expected subject mismatch, got %v", err) + } +} + +func minimalSPDXDocument() string { + return `{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "demo", + "documentNamespace": "https://bomly.dev/test/demo", + "creationInfo": { + "created": "2026-01-01T00:00:00Z", + "creators": ["Tool: bomly-test"] + }, + "packages": [] +}` +} diff --git a/internal/attestation/subject.go b/internal/attestation/subject.go new file mode 100644 index 00000000..2bfcae21 --- /dev/null +++ b/internal/attestation/subject.go @@ -0,0 +1,309 @@ +// Package attestation signs and verifies SBOM attestations. +package attestation + +import ( + "archive/tar" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/bomly-dev/bomly-cli/internal/system" +) + +// SubjectKind identifies the type of artifact an SBOM attestation describes. +type SubjectKind string + +const ( + // SubjectKindFile identifies a single file subject. + SubjectKindFile SubjectKind = "file" + // SubjectKindDir identifies a deterministic filesystem tree subject. + SubjectKindDir SubjectKind = "dir" + // SubjectKindGit identifies a clean Git HEAD snapshot subject. + SubjectKindGit SubjectKind = "git" + // SubjectKindContainer identifies an immutable container image digest subject. + SubjectKindContainer SubjectKind = "container" +) + +var containerDigestPattern = regexp.MustCompile(`^(.+)@sha256:([a-fA-F0-9]{64})$`) + +// SubjectOptions controls subject resolution. +type SubjectOptions struct { + BaseDir string + ExcludePaths []string +} + +// Subject describes the artifact bound to an SBOM attestation. +type Subject struct { + Kind SubjectKind `json:"kind"` + Name string `json:"name"` + URI string `json:"uri,omitempty"` + Digest map[string]string `json:"digest"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ResolveSubject resolves a user-facing subject spec into a stable attestation subject. +func ResolveSubject(spec string, opts SubjectOptions) (Subject, error) { + spec = strings.TrimSpace(spec) + switch { + case spec == "git": + return resolveGitSubject(opts) + case strings.HasPrefix(spec, "file:"): + return resolveFileSubject(strings.TrimPrefix(spec, "file:")) + case strings.HasPrefix(spec, "dir:"): + return resolveDirSubject(strings.TrimPrefix(spec, "dir:"), opts) + case strings.HasPrefix(spec, "container:"): + return resolveContainerSubject(strings.TrimPrefix(spec, "container:")) + default: + return Subject{}, fmt.Errorf("unsupported subject %q (accepted: file:, dir:, git, container:)", spec) + } +} + +func resolveFileSubject(path string) (Subject, error) { + absPath, err := system.ResolveExistingFile(path) + if err != nil { + return Subject{}, fmt.Errorf("resolve file subject: %w", err) + } + digest, err := fileSHA256(absPath) + if err != nil { + return Subject{}, fmt.Errorf("hash file subject %q: %w", path, err) + } + return Subject{ + Kind: SubjectKindFile, + Name: "file:" + absPath, + URI: "file://" + filepath.ToSlash(absPath), + Digest: map[string]string{"sha256": digest}, + }, nil +} + +func resolveDirSubject(path string, opts SubjectOptions) (Subject, error) { + selectedPath := strings.TrimSpace(path) + if selectedPath == "" { + selectedPath = opts.BaseDir + } + if selectedPath == "" { + selectedPath = "." + } + absPath, err := filepath.Abs(selectedPath) + if err != nil { + return Subject{}, fmt.Errorf("resolve dir subject %q: %w", selectedPath, err) + } + info, err := os.Stat(absPath) + if err != nil { + return Subject{}, fmt.Errorf("stat dir subject %q: %w", selectedPath, err) + } + if !info.IsDir() { + return Subject{}, fmt.Errorf("dir subject %q is not a directory", selectedPath) + } + digest, err := dirSHA256(absPath, opts.ExcludePaths) + if err != nil { + return Subject{}, fmt.Errorf("hash dir subject %q: %w", selectedPath, err) + } + return Subject{ + Kind: SubjectKindDir, + Name: "dir:" + absPath, + URI: "file://" + filepath.ToSlash(absPath), + Digest: map[string]string{"sha256": digest}, + }, nil +} + +func resolveContainerSubject(ref string) (Subject, error) { + ref = strings.TrimSpace(ref) + match := containerDigestPattern.FindStringSubmatch(ref) + if match == nil { + return Subject{}, fmt.Errorf("container subject requires image@sha256:; tags are not accepted for attestation subjects") + } + digest := strings.ToLower(match[2]) + return Subject{ + Kind: SubjectKindContainer, + Name: "container:" + ref, + URI: "oci://" + ref, + Digest: map[string]string{"sha256": digest}, + Metadata: map[string]string{ + "image": match[1], + "digest": "sha256:" + digest, + }, + }, nil +} + +func resolveGitSubject(opts SubjectOptions) (Subject, error) { + repoPath := strings.TrimSpace(opts.BaseDir) + if repoPath == "" { + repoPath = "." + } + root, err := gitOutput(repoPath, "rev-parse", "--show-toplevel") + if err != nil { + return Subject{}, fmt.Errorf("find git repository root: %w", err) + } + root = strings.TrimSpace(root) + status, err := gitOutput(root, "status", "--porcelain") + if err != nil { + return Subject{}, fmt.Errorf("inspect git worktree: %w", err) + } + if strings.TrimSpace(status) != "" { + return Subject{}, fmt.Errorf("git subject requires a clean worktree") + } + head, err := gitOutput(root, "rev-parse", "HEAD") + if err != nil { + return Subject{}, fmt.Errorf("resolve git HEAD: %w", err) + } + head = strings.TrimSpace(head) + remote, err := gitOutput(root, "remote", "get-url", "origin") + if err != nil { + remote = root + } + remote = strings.TrimSpace(remote) + if remote == "" { + remote = root + } + archiveDigest, err := gitArchiveSHA256(root) + if err != nil { + return Subject{}, fmt.Errorf("hash git archive: %w", err) + } + return Subject{ + Kind: SubjectKindGit, + Name: "git:" + remote + "@" + head, + URI: "git+" + remote + "@" + head, + Digest: map[string]string{ + "sha256": archiveDigest, + }, + Metadata: map[string]string{ + "commit": head, + "remote": remote, + }, + }, nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func dirSHA256(root string, excludePaths []string) (string, error) { + excluded := normalizedExcludeSet(excludePaths) + entries := make([]string, 0) + if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + name := entry.Name() + if entry.IsDir() { + switch name { + case ".git", ".hg", ".svn": + return filepath.SkipDir + } + return nil + } + if !entry.Type().IsRegular() { + return nil + } + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + if _, ok := excluded[filepath.Clean(absPath)]; ok { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + digest, err := fileSHA256(path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + entries = append(entries, filepath.ToSlash(rel)+"\x00"+digest+"\x00"+fmt.Sprint(info.Size())) + return nil + }); err != nil { + return "", err + } + sort.Strings(entries) + hash := sha256.New() + for _, entry := range entries { + _, _ = io.WriteString(hash, entry) + _, _ = hash.Write([]byte{0}) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func normalizedExcludeSet(paths []string) map[string]struct{} { + out := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + absPath, err := filepath.Abs(path) + if err != nil { + continue + } + out[filepath.Clean(absPath)] = struct{}{} + } + return out +} + +func gitArchiveSHA256(repoPath string) (string, error) { + cmd := system.Command("git", "archive", "--format=tar", "HEAD") + cmd.Dir = repoPath + data, err := cmd.Output() + if err != nil { + return "", err + } + hash := sha256.New() + reader := tar.NewReader(bytes.NewReader(data)) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if header.Typeflag != tar.TypeReg { + continue + } + _, _ = io.WriteString(hash, header.Name) + _, _ = hash.Write([]byte{0}) + fileHash := sha256.New() + if _, err := io.Copy(fileHash, reader); err != nil { + return "", err + } + _, _ = io.WriteString(hash, hex.EncodeToString(fileHash.Sum(nil))) + _, _ = hash.Write([]byte{0}) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func gitOutput(workingDir string, args ...string) (string, error) { + cmd := system.Command("git", args...) + cmd.Dir = workingDir + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return "", fmt.Errorf("%w: %s", err, msg) + } + return "", err + } + return stdout.String(), nil +} diff --git a/internal/attestation/subject_test.go b/internal/attestation/subject_test.go new file mode 100644 index 00000000..7097e743 --- /dev/null +++ b/internal/attestation/subject_test.go @@ -0,0 +1,127 @@ +package attestation + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestResolveFileSubject(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "artifact.bin") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + subject, err := ResolveSubject("file:"+path, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject() error = %v", err) + } + if subject.Kind != SubjectKindFile { + t.Fatalf("Kind = %q, want file", subject.Kind) + } + if subject.Digest["sha256"] != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" { + t.Fatalf("unexpected digest: %#v", subject.Digest) + } +} + +func TestResolveDirSubjectIsDeterministicAndExcludesOutputs(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "app"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for path, contents := range map[string]string{ + "package.json": `{"name":"root"}`, + "app/package.json": `{"name":"app"}`, + ".git/ignored": "ignored", + "sbom.spdx.json": "output", + "attestation.json": "output", + } { + fullPath := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + opts := SubjectOptions{ExcludePaths: []string{filepath.Join(dir, "sbom.spdx.json"), filepath.Join(dir, "attestation.json")}} + first, err := ResolveSubject("dir:"+dir, opts) + if err != nil { + t.Fatalf("ResolveSubject(first) error = %v", err) + } + second, err := ResolveSubject("dir:"+dir, opts) + if err != nil { + t.Fatalf("ResolveSubject(second) error = %v", err) + } + if first.Digest["sha256"] != second.Digest["sha256"] { + t.Fatalf("digest not deterministic: %s != %s", first.Digest["sha256"], second.Digest["sha256"]) + } + + if err := os.WriteFile(filepath.Join(dir, "sbom.spdx.json"), []byte("changed"), 0o644); err != nil { + t.Fatalf("rewrite excluded output: %v", err) + } + third, err := ResolveSubject("dir:"+dir, opts) + if err != nil { + t.Fatalf("ResolveSubject(third) error = %v", err) + } + if first.Digest["sha256"] != third.Digest["sha256"] { + t.Fatalf("excluded output changed digest: %s != %s", first.Digest["sha256"], third.Digest["sha256"]) + } +} + +func TestResolveContainerSubjectRequiresDigestRef(t *testing.T) { + _, err := ResolveSubject("container:ghcr.io/acme/app:latest", SubjectOptions{}) + if err == nil || !strings.Contains(err.Error(), "requires image@sha256") { + t.Fatalf("expected tag ref rejection, got %v", err) + } + + digest := strings.Repeat("a", 64) + subject, err := ResolveSubject("container:ghcr.io/acme/app@sha256:"+digest, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject(container digest) error = %v", err) + } + if subject.Kind != SubjectKindContainer || subject.Digest["sha256"] != digest { + t.Fatalf("unexpected container subject: %#v", subject) + } +} + +func TestResolveGitSubjectRejectsDirtyTree(t *testing.T) { + requireGitForAttestation(t) + dir := t.TempDir() + runGitForAttestation(t, dir, "init", "--initial-branch=main") + runGitForAttestation(t, dir, "config", "user.email", "test@example.com") + runGitForAttestation(t, dir, "config", "user.name", "Bomly Test") + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"demo"}`), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + runGitForAttestation(t, dir, "add", "package.json") + runGitForAttestation(t, dir, "commit", "-m", "initial") + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"dirty"}`), 0o644); err != nil { + t.Fatalf("dirty fixture: %v", err) + } + + _, err := ResolveSubject("git", SubjectOptions{BaseDir: dir}) + if err == nil || !strings.Contains(err.Error(), "requires a clean worktree") { + t.Fatalf("expected dirty tree rejection, got %v", err) + } +} + +func requireGitForAttestation(t *testing.T) { + t.Helper() + if runtime.GOOS == "js" { + t.Skip("git unavailable") + } + if _, err := os.Stat(".git"); err != nil { + // This package can be tested outside a checkout; the git binary check + // below still decides whether the actual git command can run. + } +} + +func runGitForAttestation(t *testing.T, dir string, args ...string) string { + t.Helper() + return runCommandForAttestationTest(t, dir, "git", args...) +} diff --git a/internal/attestation/test_helpers_test.go b/internal/attestation/test_helpers_test.go new file mode 100644 index 00000000..fd21139e --- /dev/null +++ b/internal/attestation/test_helpers_test.go @@ -0,0 +1,21 @@ +package attestation + +import ( + "os/exec" + "strings" + "testing" +) + +func runCommandForAttestationTest(t *testing.T, dir, name string, args ...string) string { + t.Helper() + if _, err := exec.LookPath(name); err != nil { + t.Skipf("%s is required for this test: %v", name, err) + } + cmd := exec.Command(name, args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(output)) + } + return strings.TrimSpace(string(output)) +} diff --git a/internal/cli/root_cmd.go b/internal/cli/root_cmd.go index 17697bcc..15529160 100644 --- a/internal/cli/root_cmd.go +++ b/internal/cli/root_cmd.go @@ -110,6 +110,7 @@ func newRootCmd(version string) (*cobra.Command, error) { } pluginCmd := newPluginCmd() + sbomCmd := newSBOMCmd() mcpCmd := newMcpCmd() versionCmd := newVersionCmd(version) benchmarkCmd := newBenchmarkCmd() @@ -118,6 +119,7 @@ func newRootCmd(version string) (*cobra.Command, error) { root.AddCommand(scanCmd) root.AddCommand(diffCmd) root.AddCommand(pluginCmd) + root.AddCommand(sbomCmd) root.AddCommand(mcpCmd) root.AddCommand(versionCmd) root.AddCommand(benchmarkCmd) diff --git a/internal/cli/sbom_cmd.go b/internal/cli/sbom_cmd.go new file mode 100644 index 00000000..e54dd66d --- /dev/null +++ b/internal/cli/sbom_cmd.go @@ -0,0 +1,177 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bomly-dev/bomly-cli/internal/attestation" + "github.com/bomly-dev/bomly-cli/internal/cli/exit" + "github.com/spf13/cobra" +) + +func newSBOMCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sbom", + Short: "Work with SBOM artifacts", + Example: " bomly sbom attest --sbom bomly.spdx.json --subject git --output bomly.att.json --keyless\n" + + " bomly sbom verify --attestation bomly.att.json --subject git --extract-sbom verified.spdx.json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newSBOMAttestCmd(), newSBOMVerifyCmd()) + return cmd +} + +func newSBOMAttestCmd() *cobra.Command { + var sbomPath string + var subjectSpec string + var outputPath string + var keyPath string + var keyless bool + cmd := &cobra.Command{ + Use: "attest", + Short: "[Experimental] Create a signed SBOM attestation", + Example: " bomly sbom attest --sbom bomly.spdx.json --subject git --output bomly.att.json --keyless\n" + + " bomly sbom attest --sbom bomly.cdx.json --subject dir:. --output bomly.att.json --key signing-key.pem\n" + + " bomly sbom attest --sbom image.spdx.json --subject container:ghcr.io/acme/app@sha256: --output image.att.json --keyless", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(sbomPath) == "" { + return exit.InvalidInputError("--sbom is required") + } + if strings.TrimSpace(subjectSpec) == "" { + return exit.InvalidInputError("--subject is required") + } + if strings.TrimSpace(outputPath) == "" { + return exit.InvalidInputError("--output is required") + } + if keyless && strings.TrimSpace(keyPath) != "" { + return exit.InvalidInputError("--keyless cannot be combined with --key") + } + if !keyless && strings.TrimSpace(keyPath) == "" { + return exit.InvalidInputError("--key or --keyless is required") + } + subject, err := resolveAttestationSubject(subjectSpec, sbomPath, outputPath) + if err != nil { + return exit.InvalidInputError("%v", err) + } + bundle, err := attestation.Attest(context.Background(), attestation.AttestRequest{ + SBOMPath: sbomPath, + Subject: subject, + KeyPath: keyPath, + Keyless: keyless, + }) + if err != nil { + return err + } + if err := writeAttestationOutput(outputPath, bundle); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Wrote SBOM attestation to %s\n", outputPath) + return err + }, + } + cmd.Flags().StringVar(&sbomPath, "sbom", "", "SBOM JSON file to attest (SPDX 2.3 or CycloneDX)") + cmd.Flags().StringVar(&subjectSpec, "subject", "", "Attestation subject: file:, dir:, git, or container:") + cmd.Flags().StringVar(&outputPath, "output", "", "Path to write the attestation bundle") + cmd.Flags().StringVar(&keyPath, "key", "", "ECDSA P-256 PEM private key used to sign the attestation") + cmd.Flags().BoolVar(&keyless, "keyless", false, "Generate an experimental self-contained keyless bundle") + return cmd +} + +func newSBOMVerifyCmd() *cobra.Command { + var attestationPath string + var subjectSpec string + var keyPath string + var certIdentity string + var certIssuer string + var extractPath string + cmd := &cobra.Command{ + Use: "verify", + Short: "[Experimental] Verify a signed SBOM attestation", + Example: " bomly sbom verify --attestation bomly.att.json --subject git\n" + + " bomly sbom verify --attestation bomly.att.json --subject dir:. --extract-sbom verified.spdx.json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(attestationPath) == "" { + return exit.InvalidInputError("--attestation is required") + } + if strings.TrimSpace(subjectSpec) == "" { + return exit.InvalidInputError("--subject is required") + } + if strings.TrimSpace(certIdentity) != "" || strings.TrimSpace(certIssuer) != "" { + return exit.InvalidInputError("certificate identity verification is not available in the experimental local-key MVP; use --key or a self-contained bundle") + } + subject, err := resolveAttestationSubject(subjectSpec, "", "") + if err != nil { + return exit.InvalidInputError("%v", err) + } + bundle, err := os.ReadFile(attestationPath) + if err != nil { + return fmt.Errorf("read attestation: %w", err) + } + result, err := attestation.Verify(context.Background(), attestation.VerifyRequest{ + Bundle: bundle, + Subject: subject, + KeyPath: keyPath, + }) + if err != nil { + return err + } + if err := attestation.WriteVerifiedSBOM(extractPath, result.SBOM); err != nil { + return err + } + if extractPath != "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Verified SBOM attestation (%s); extracted SBOM to %s\n", result.SBOMFormat, extractPath) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Verified SBOM attestation (%s)\n", result.SBOMFormat) + } + return err + }, + } + cmd.Flags().StringVar(&attestationPath, "attestation", "", "Attestation bundle to verify") + cmd.Flags().StringVar(&subjectSpec, "subject", "", "Expected attestation subject: file:, dir:, git, or container:") + cmd.Flags().StringVar(&keyPath, "key", "", "ECDSA P-256 PEM public key used to verify the attestation") + cmd.Flags().StringVar(&certIdentity, "certificate-identity", "", "Expected Sigstore certificate identity (reserved for future identity bundles)") + cmd.Flags().StringVar(&certIssuer, "certificate-oidc-issuer", "", "Expected Sigstore OIDC issuer (reserved for future identity bundles)") + cmd.Flags().StringVar(&extractPath, "extract-sbom", "", "Write the verified embedded SBOM to this path") + return cmd +} + +func resolveAttestationSubject(subjectSpec, sbomPath, outputPath string) (attestation.Subject, error) { + cwd, err := os.Getwd() + if err != nil { + return attestation.Subject{}, fmt.Errorf("resolve current directory: %w", err) + } + excludes := []string{} + for _, path := range []string{sbomPath, outputPath} { + if strings.TrimSpace(path) == "" { + continue + } + absPath, err := filepath.Abs(path) + if err == nil { + excludes = append(excludes, absPath) + } + } + return attestation.ResolveSubject(subjectSpec, attestation.SubjectOptions{ + BaseDir: cwd, + ExcludePaths: excludes, + }) +} + +func writeAttestationOutput(path string, data []byte) error { + if parent := filepath.Dir(path); parent != "." && parent != "" { + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create attestation output directory: %w", err) + } + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("write attestation output: %w", err) + } + return nil +} diff --git a/internal/cli/sbom_cmd_test.go b/internal/cli/sbom_cmd_test.go new file mode 100644 index 00000000..73e3a89b --- /dev/null +++ b/internal/cli/sbom_cmd_test.go @@ -0,0 +1,119 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRoot_RegistersSBOMCommands(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", tempHome) + } + + root, err := newRootCmd("0.9.0-test") + if err != nil { + t.Fatalf("newRootCmd() error = %v", err) + } + for _, args := range [][]string{{"sbom"}, {"sbom", "attest"}, {"sbom", "verify"}} { + cmd, _, err := root.Find(args) + if err != nil { + t.Fatalf("root.Find(%v) error = %v", args, err) + } + if cmd == nil { + t.Fatalf("expected command for %v", args) + } + } +} + +func TestSBOMCommandHelpMarksAttestationExperimental(t *testing.T) { + root, err := newRootCmd("0.9.0-test") + if err != nil { + t.Fatalf("newRootCmd() error = %v", err) + } + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stdout) + root.SetArgs([]string{"sbom", "attest", "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("root.Execute() error = %v", err) + } + if !strings.Contains(stdout.String(), "Experimental") { + t.Fatalf("expected experimental help text, got:\n%s", stdout.String()) + } +} + +func TestSBOMAttestAndVerifyCommandsRoundTrip(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", tempHome) + } + + dir := t.TempDir() + sbomPath := filepath.Join(dir, "bomly.spdx.json") + if err := os.WriteFile(sbomPath, []byte(cliMinimalSPDXDocument()), 0o644); err != nil { + t.Fatalf("write sbom: %v", err) + } + subjectPath := filepath.Join(dir, "artifact.txt") + if err := os.WriteFile(subjectPath, []byte("artifact"), 0o644); err != nil { + t.Fatalf("write subject: %v", err) + } + bundlePath := filepath.Join(dir, "sbom.att.json") + extractedPath := filepath.Join(dir, "verified.spdx.json") + + root, err := newRootCmd("0.9.0-test") + if err != nil { + t.Fatalf("newRootCmd() error = %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"sbom", "attest", "--sbom", sbomPath, "--subject", "file:" + subjectPath, "--output", bundlePath, "--keyless"}) + if err := root.Execute(); err != nil { + t.Fatalf("attest error = %v; stderr=%s", err, stderr.String()) + } + if _, err := os.Stat(bundlePath); err != nil { + t.Fatalf("expected bundle output: %v", err) + } + + stdout.Reset() + stderr.Reset() + root, err = newRootCmd("0.9.0-test") + if err != nil { + t.Fatalf("newRootCmd() error = %v", err) + } + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"sbom", "verify", "--attestation", bundlePath, "--subject", "file:" + subjectPath, "--extract-sbom", extractedPath}) + if err := root.Execute(); err != nil { + t.Fatalf("verify error = %v; stderr=%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Verified SBOM attestation") { + t.Fatalf("unexpected verify output: %s", stdout.String()) + } + if _, err := os.Stat(extractedPath); err != nil { + t.Fatalf("expected extracted sbom: %v", err) + } +} + +func cliMinimalSPDXDocument() string { + return `{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "demo", + "documentNamespace": "https://bomly.dev/test/demo-cli", + "creationInfo": { + "created": "2026-01-01T00:00:00Z", + "creators": ["Tool: bomly-test"] + }, + "packages": [] +}` +} From a336c562e0c383f91c20aa1f3eafea24f398f06b Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Wed, 3 Jun 2026 00:45:05 -0700 Subject: [PATCH 2/7] chore: fix attestation lint failure --- AGENTS.md | 4 +++- CLAUDE.md | 4 ++-- internal/attestation/subject_test.go | 4 ---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cd57112..6edad2f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,8 @@ Bomly is a **customer-facing, security-sensitive CLI** for dependency intelligen ```sh make build # build both `bin/bomly` (builtin Syft/Grype) and `bin/bomly-lite` make build-lite # go build -tags "bomly_external_syft,bomly_external_grype" -o bin/bomly-lite ./cmd/bomly +make fmt # format Go code with the repo formatter +make lint # run golangci-lint make test # go test ./... make smoke # end-to-end smoke tests against real repos/containers (slow, needs network) make smoke ARGS="-update" # regenerate golden files for smoke tests @@ -16,7 +18,7 @@ make run ARGS="scan" # go run ./cmd/bomly make generate # regenerate config reference, JSON schemas, schema docs, and support matrix ``` -Always run `make test` after changes. All tests must pass before marking work is done. +Always run `make fmt`, `make lint`, and `make test` after changes and before pushing. All checks must pass before marking work is done. If you change `internal/cli/config.go`, `internal/output/*`, `sdk/catalog.go`, `sdk/support_matrix.go`, or `internal/registry/support.go`, also run `make generate`. ### Git Worktrees diff --git a/CLAUDE.md b/CLAUDE.md index dd186f82..44f095c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,11 +18,11 @@ make benchmark # Hidden local dependency-graph benchmark make benchmark-report # Analyze benchmark artifacts with Copilot CLI make run ARGS="scan" # Run the CLI directly make fmt # Format code -make lint # golangci-lint v1.64.8 +make lint # golangci-lint v2.12.0 make generate # Regenerate config reference, JSON schemas, support matrix ``` -**Always run `make test` after changes.** If you change `internal/config/config.go`, `internal/output/*`, `sdk/catalog.go`, `sdk/support_matrix.go`, or `internal/registry/support.go`, also run `make generate`. +**Always run `make fmt`, `make lint`, and `make test` after changes and before pushing.** If you change `internal/config/config.go`, `internal/output/*`, `sdk/catalog.go`, `sdk/support_matrix.go`, or `internal/registry/support.go`, also run `make generate`. **Go version**: 1.25.8 (pinned — use exactly this to match CI formatting and build behavior). diff --git a/internal/attestation/subject_test.go b/internal/attestation/subject_test.go index 7097e743..25c255c3 100644 --- a/internal/attestation/subject_test.go +++ b/internal/attestation/subject_test.go @@ -115,10 +115,6 @@ func requireGitForAttestation(t *testing.T) { if runtime.GOOS == "js" { t.Skip("git unavailable") } - if _, err := os.Stat(".git"); err != nil { - // This package can be tested outside a checkout; the git binary check - // below still decides whether the actual git command can run. - } } func runGitForAttestation(t *testing.T, dir string, args ...string) string { From 1e782ddd425b09b9ddadbe56cf50181055f626ff Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Wed, 3 Jun 2026 01:43:27 -0700 Subject: [PATCH 3/7] fix: preserve extracted attested SBOM bytes --- docs/SBOM_ATTESTATIONS.md | 1 + internal/attestation/bundle.go | 55 +++++++++++++++++++++++++---- internal/attestation/bundle_test.go | 24 +++++++++++-- internal/cli/sbom_cmd_test.go | 11 ++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/SBOM_ATTESTATIONS.md b/docs/SBOM_ATTESTATIONS.md index 13169582..926baac3 100644 --- a/docs/SBOM_ATTESTATIONS.md +++ b/docs/SBOM_ATTESTATIONS.md @@ -55,6 +55,7 @@ bomly sbom verify \ ``` `--extract-sbom` writes only after signature, subject, predicate, and SBOM checks pass. +Bundles created by current Bomly versions preserve the original SBOM bytes for extraction, including JSON key ordering and whitespace. Older experimental bundles may extract a semantically equivalent JSON document with different formatting. ## Subjects diff --git a/internal/attestation/bundle.go b/internal/attestation/bundle.go index ed51d7a3..cd3ceb6f 100644 --- a/internal/attestation/bundle.go +++ b/internal/attestation/bundle.go @@ -32,6 +32,7 @@ const ( payloadTypeInToto = "application/vnd.in-toto+json" bundleMediaType = "application/vnd.bomly.sbom-attestation.v1+json" sigstoreBundleV03Type = "application/vnd.dev.sigstore.bundle.v0.3+json" + sbomRawBase64Field = "sbomRawBase64" ) // AttestRequest describes an SBOM attestation request. @@ -79,7 +80,8 @@ func BuildStatement(subject Subject, sbomBytes []byte) (*intoto.Statement, error "sbomDigest": map[string]any{ "sha256": hex.EncodeToString(sum[:]), }, - "sbom": sbomObject, + sbomRawBase64Field: base64.StdEncoding.EncodeToString(sbomBytes), + "sbom": sbomObject, }) if err != nil { return nil, fmt.Errorf("build sbom predicate: %w", err) @@ -213,13 +215,12 @@ func sbomFromPredicate(predicate *structpb.Struct) ([]byte, sbom.Target, error) return nil, "", fmt.Errorf("sbom predicate is missing") } formatValue := predicate.Fields["sbomFormat"].GetStringValue() - sbomValue := predicate.Fields["sbom"] - if formatValue == "" || sbomValue == nil { + if formatValue == "" { return nil, "", fmt.Errorf("sbom predicate is missing required fields") } - raw, err := json.Marshal(sbomValue.AsInterface()) + raw, err := sbomBytesFromPredicate(predicate) if err != nil { - return nil, "", fmt.Errorf("marshal embedded sbom: %w", err) + return nil, "", err } target, err := detectSupportedSBOM(raw) if err != nil { @@ -228,9 +229,51 @@ func sbomFromPredicate(predicate *structpb.Struct) ([]byte, sbom.Target, error) if string(target) != formatValue { return nil, "", fmt.Errorf("sbom predicate format %q does not match embedded sbom %q", formatValue, target) } + if err := verifySBOMDigest(predicate, raw); err != nil { + return nil, "", err + } return raw, target, nil } +func sbomBytesFromPredicate(predicate *structpb.Struct) ([]byte, error) { + if rawBase64 := predicate.Fields[sbomRawBase64Field].GetStringValue(); rawBase64 != "" { + raw, err := base64.StdEncoding.DecodeString(rawBase64) + if err != nil { + return nil, fmt.Errorf("decode embedded sbom bytes: %w", err) + } + return raw, nil + } + sbomValue := predicate.Fields["sbom"] + if sbomValue == nil { + return nil, fmt.Errorf("sbom predicate is missing required fields") + } + raw, err := json.Marshal(sbomValue.AsInterface()) + if err != nil { + return nil, fmt.Errorf("marshal embedded sbom: %w", err) + } + return raw, nil +} + +func verifySBOMDigest(predicate *structpb.Struct, raw []byte) error { + digestValue := predicate.Fields["sbomDigest"] + if digestValue == nil { + return nil + } + digestStruct := digestValue.GetStructValue() + if digestStruct == nil { + return fmt.Errorf("sbom predicate digest is malformed") + } + expected := digestStruct.Fields["sha256"].GetStringValue() + if expected == "" { + return fmt.Errorf("sbom predicate digest is missing sha256") + } + sum := sha256.Sum256(raw) + if !strings.EqualFold(expected, hex.EncodeToString(sum[:])) { + return fmt.Errorf("sbom digest does not match predicate") + } + return nil +} + func subjectMatches(expected Subject, actual []*intoto.ResourceDescriptor) bool { if len(actual) != 1 { return false @@ -395,7 +438,7 @@ func WriteVerifiedSBOM(path string, data []byte) error { return fmt.Errorf("create verified sbom directory: %w", err) } } - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("write verified sbom: %w", err) } return nil diff --git a/internal/attestation/bundle_test.go b/internal/attestation/bundle_test.go index 7d18a6d4..3f41784b 100644 --- a/internal/attestation/bundle_test.go +++ b/internal/attestation/bundle_test.go @@ -1,6 +1,7 @@ package attestation import ( + "bytes" "context" "encoding/json" "os" @@ -74,8 +75,12 @@ func TestAttestAndVerifyKeylessRoundTrip(t *testing.T) { if result.SBOMFormat != "spdx-2.3+json" { t.Fatalf("SBOMFormat = %q", result.SBOMFormat) } - if len(result.SBOM) == 0 { - t.Fatal("expected verified SBOM bytes") + original, err := os.ReadFile(sbomPath) + if err != nil { + t.Fatalf("read original sbom: %v", err) + } + if !bytes.Equal(result.SBOM, original) { + t.Fatalf("verified SBOM bytes differ from original\noriginal: %s\nverified: %s", original, result.SBOM) } } @@ -111,6 +116,21 @@ func TestVerifyRejectsWrongSubject(t *testing.T) { } } +func TestWriteVerifiedSBOMPreservesBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "verified.spdx.json") + data := []byte(`{"spdxVersion":"SPDX-2.3","packages":[]}`) + if err := WriteVerifiedSBOM(path, data); err != nil { + t.Fatalf("WriteVerifiedSBOM() error = %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read verified sbom: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("written SBOM bytes differ from verified bytes\ngot: %q\nwant: %q", got, data) + } +} + func minimalSPDXDocument() string { return `{ "spdxVersion": "SPDX-2.3", diff --git a/internal/cli/sbom_cmd_test.go b/internal/cli/sbom_cmd_test.go index 73e3a89b..dd3181ff 100644 --- a/internal/cli/sbom_cmd_test.go +++ b/internal/cli/sbom_cmd_test.go @@ -101,6 +101,17 @@ func TestSBOMAttestAndVerifyCommandsRoundTrip(t *testing.T) { if _, err := os.Stat(extractedPath); err != nil { t.Fatalf("expected extracted sbom: %v", err) } + original, err := os.ReadFile(sbomPath) + if err != nil { + t.Fatalf("read original sbom: %v", err) + } + extracted, err := os.ReadFile(extractedPath) + if err != nil { + t.Fatalf("read extracted sbom: %v", err) + } + if !bytes.Equal(extracted, original) { + t.Fatalf("extracted SBOM differs from original\noriginal: %s\nextracted: %s", original, extracted) + } } func cliMinimalSPDXDocument() string { From 5d36baebb41f084b2ccf6f355cb7431bc6df8a34 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Wed, 3 Jun 2026 01:50:10 -0700 Subject: [PATCH 4/7] fix: avoid duplicate sbom payload in attestations --- docs/SBOM_ATTESTATIONS.md | 2 +- internal/attestation/bundle.go | 20 ++++---------------- internal/attestation/bundle_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/SBOM_ATTESTATIONS.md b/docs/SBOM_ATTESTATIONS.md index 926baac3..02b52b31 100644 --- a/docs/SBOM_ATTESTATIONS.md +++ b/docs/SBOM_ATTESTATIONS.md @@ -55,7 +55,7 @@ bomly sbom verify \ ``` `--extract-sbom` writes only after signature, subject, predicate, and SBOM checks pass. -Bundles created by current Bomly versions preserve the original SBOM bytes for extraction, including JSON key ordering and whitespace. Older experimental bundles may extract a semantically equivalent JSON document with different formatting. +Bundles preserve the original SBOM bytes for extraction, including JSON key ordering and whitespace, without duplicating the SBOM as a second parsed JSON object in the predicate. ## Subjects diff --git a/internal/attestation/bundle.go b/internal/attestation/bundle.go index cd3ceb6f..4bbcec27 100644 --- a/internal/attestation/bundle.go +++ b/internal/attestation/bundle.go @@ -69,10 +69,6 @@ func BuildStatement(subject Subject, sbomBytes []byte) (*intoto.Statement, error if err != nil { return nil, err } - var sbomObject map[string]any - if err := json.Unmarshal(sbomBytes, &sbomObject); err != nil { - return nil, fmt.Errorf("parse sbom json: %w", err) - } sum := sha256.Sum256(sbomBytes) predicate, err := structpb.NewStruct(map[string]any{ "schemaVersion": "experimental/v1", @@ -81,7 +77,6 @@ func BuildStatement(subject Subject, sbomBytes []byte) (*intoto.Statement, error "sha256": hex.EncodeToString(sum[:]), }, sbomRawBase64Field: base64.StdEncoding.EncodeToString(sbomBytes), - "sbom": sbomObject, }) if err != nil { return nil, fmt.Errorf("build sbom predicate: %w", err) @@ -236,20 +231,13 @@ func sbomFromPredicate(predicate *structpb.Struct) ([]byte, sbom.Target, error) } func sbomBytesFromPredicate(predicate *structpb.Struct) ([]byte, error) { - if rawBase64 := predicate.Fields[sbomRawBase64Field].GetStringValue(); rawBase64 != "" { - raw, err := base64.StdEncoding.DecodeString(rawBase64) - if err != nil { - return nil, fmt.Errorf("decode embedded sbom bytes: %w", err) - } - return raw, nil - } - sbomValue := predicate.Fields["sbom"] - if sbomValue == nil { + rawBase64 := predicate.Fields[sbomRawBase64Field].GetStringValue() + if rawBase64 == "" { return nil, fmt.Errorf("sbom predicate is missing required fields") } - raw, err := json.Marshal(sbomValue.AsInterface()) + raw, err := base64.StdEncoding.DecodeString(rawBase64) if err != nil { - return nil, fmt.Errorf("marshal embedded sbom: %w", err) + return nil, fmt.Errorf("decode embedded sbom bytes: %w", err) } return raw, nil } diff --git a/internal/attestation/bundle_test.go b/internal/attestation/bundle_test.go index 3f41784b..35701444 100644 --- a/internal/attestation/bundle_test.go +++ b/internal/attestation/bundle_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "google.golang.org/protobuf/types/known/structpb" ) func TestBuildStatementEmbedsSupportedSBOM(t *testing.T) { @@ -29,6 +31,12 @@ func TestBuildStatementEmbedsSupportedSBOM(t *testing.T) { if got := statement.Predicate.Fields["sbomFormat"].GetStringValue(); got != "spdx-2.3+json" { t.Fatalf("sbomFormat = %q", got) } + if raw := statement.Predicate.Fields[sbomRawBase64Field].GetStringValue(); raw == "" { + t.Fatal("expected byte-preserving SBOM payload") + } + if statement.Predicate.Fields["sbom"] != nil { + t.Fatal("new predicates should not duplicate the SBOM as a parsed JSON object") + } } func TestBuildStatementRejectsUnsupportedSBOM(t *testing.T) { @@ -38,6 +46,22 @@ func TestBuildStatementRejectsUnsupportedSBOM(t *testing.T) { } } +func TestSBOMFromPredicateRequiresRawSBOMBytes(t *testing.T) { + predicate, err := structpb.NewStruct(map[string]any{ + "schemaVersion": "experimental/v1", + "sbomFormat": "spdx-2.3+json", + "sbomDigest": map[string]any{ + "sha256": strings.Repeat("a", 64), + }, + }) + if err != nil { + t.Fatalf("build predicate: %v", err) + } + if _, _, err := sbomFromPredicate(predicate); err == nil || !strings.Contains(err.Error(), "missing required fields") { + t.Fatalf("expected missing raw SBOM error, got %v", err) + } +} + func TestAttestAndVerifyKeylessRoundTrip(t *testing.T) { dir := t.TempDir() sbomPath := filepath.Join(dir, "bomly.spdx.json") From 3e2e9634b64ad3e07005a49061f565a40d3a4a30 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Wed, 3 Jun 2026 02:10:05 -0700 Subject: [PATCH 5/7] fix: require public key for key-signed attestations --- docs/SBOM_ATTESTATIONS.md | 11 +++++ internal/attestation/bundle.go | 9 ++-- internal/attestation/bundle_test.go | 74 +++++++++++++++++++++++++++++ internal/cli/sbom_cmd.go | 3 +- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/docs/SBOM_ATTESTATIONS.md b/docs/SBOM_ATTESTATIONS.md index 02b52b31..82aa3f66 100644 --- a/docs/SBOM_ATTESTATIONS.md +++ b/docs/SBOM_ATTESTATIONS.md @@ -35,6 +35,8 @@ bomly sbom attest \ --key signing-key.pem ``` +Key-signed attestations do not embed the public verification key. Keep the matching public key and pass it during verification. + ## Verify an attestation Verify the bundle against the same subject: @@ -45,6 +47,15 @@ bomly sbom verify \ --subject dir:. ``` +For an attestation created with `--key`, pass the matching ECDSA P-256 PEM public key: + +```bash +bomly sbom verify \ + --attestation sbom.att.json \ + --subject git \ + --key signing-key.pub.pem +``` + To extract the verified embedded SBOM: ```bash diff --git a/internal/attestation/bundle.go b/internal/attestation/bundle.go index 4bbcec27..f44c4881 100644 --- a/internal/attestation/bundle.go +++ b/internal/attestation/bundle.go @@ -119,9 +119,12 @@ func Attest(ctx context.Context, req AttestRequest) ([]byte, error) { if err != nil { return nil, fmt.Errorf("marshal sigstore bundle: %w", err) } - publicKeyPEM, err := keypair.GetPublicKeyPem() - if err != nil { - return nil, fmt.Errorf("marshal public key: %w", err) + var publicKeyPEM string + if req.Keyless { + publicKeyPEM, err = keypair.GetPublicKeyPem() + if err != nil { + return nil, fmt.Errorf("marshal public key: %w", err) + } } return json.MarshalIndent(bomlyBundle{ MediaType: bundleMediaType, diff --git a/internal/attestation/bundle_test.go b/internal/attestation/bundle_test.go index 35701444..9d9a7542 100644 --- a/internal/attestation/bundle_test.go +++ b/internal/attestation/bundle_test.go @@ -3,7 +3,12 @@ package attestation import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" "encoding/json" + "encoding/pem" "os" "path/filepath" "strings" @@ -108,6 +113,50 @@ func TestAttestAndVerifyKeylessRoundTrip(t *testing.T) { } } +func TestAttestWithKeyRequiresVerificationKey(t *testing.T) { + dir := t.TempDir() + sbomPath := filepath.Join(dir, "bomly.spdx.json") + if err := os.WriteFile(sbomPath, []byte(minimalSPDXDocument()), 0o644); err != nil { + t.Fatalf("write sbom: %v", err) + } + subjectPath := filepath.Join(dir, "artifact.txt") + if err := os.WriteFile(subjectPath, []byte("artifact"), 0o644); err != nil { + t.Fatalf("write subject: %v", err) + } + subject, err := ResolveSubject("file:"+subjectPath, SubjectOptions{}) + if err != nil { + t.Fatalf("ResolveSubject() error = %v", err) + } + privateKeyPath, publicKeyPath := writeTestECDSAKeypair(t, dir) + + bundle, err := Attest(context.Background(), AttestRequest{ + SBOMPath: sbomPath, + Subject: subject, + KeyPath: privateKeyPath, + }) + if err != nil { + t.Fatalf("Attest() error = %v", err) + } + var wrapped bomlyBundle + if err := json.Unmarshal(bundle, &wrapped); err != nil { + t.Fatalf("parse bundle: %v", err) + } + if wrapped.PublicKeyPEM != "" { + t.Fatal("key-signed bundles should not embed the public verification key") + } + + if _, err := Verify(context.Background(), VerifyRequest{Bundle: bundle, Subject: subject}); err == nil { + t.Fatal("expected key-signed bundle verification to require --key") + } + if _, err := Verify(context.Background(), VerifyRequest{ + Bundle: bundle, + Subject: subject, + KeyPath: publicKeyPath, + }); err != nil { + t.Fatalf("Verify() with public key error = %v", err) + } +} + func TestVerifyRejectsWrongSubject(t *testing.T) { dir := t.TempDir() sbomPath := filepath.Join(dir, "bomly.spdx.json") @@ -140,6 +189,31 @@ func TestVerifyRejectsWrongSubject(t *testing.T) { } } +func writeTestECDSAKeypair(t *testing.T, dir string) (string, string) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + privateBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + t.Fatalf("marshal private key: %v", err) + } + publicBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatalf("marshal public key: %v", err) + } + privatePath := filepath.Join(dir, "private.pem") + publicPath := filepath.Join(dir, "public.pem") + if err := os.WriteFile(privatePath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateBytes}), 0o600); err != nil { + t.Fatalf("write private key: %v", err) + } + if err := os.WriteFile(publicPath, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicBytes}), 0o644); err != nil { + t.Fatalf("write public key: %v", err) + } + return privatePath, publicPath +} + func TestWriteVerifiedSBOMPreservesBytes(t *testing.T) { path := filepath.Join(t.TempDir(), "verified.spdx.json") data := []byte(`{"spdxVersion":"SPDX-2.3","packages":[]}`) diff --git a/internal/cli/sbom_cmd.go b/internal/cli/sbom_cmd.go index e54dd66d..84ac4618 100644 --- a/internal/cli/sbom_cmd.go +++ b/internal/cli/sbom_cmd.go @@ -95,6 +95,7 @@ func newSBOMVerifyCmd() *cobra.Command { Use: "verify", Short: "[Experimental] Verify a signed SBOM attestation", Example: " bomly sbom verify --attestation bomly.att.json --subject git\n" + + " bomly sbom verify --attestation bomly.att.json --subject dir:. --key signing-key.pub.pem\n" + " bomly sbom verify --attestation bomly.att.json --subject dir:. --extract-sbom verified.spdx.json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -136,7 +137,7 @@ func newSBOMVerifyCmd() *cobra.Command { } cmd.Flags().StringVar(&attestationPath, "attestation", "", "Attestation bundle to verify") cmd.Flags().StringVar(&subjectSpec, "subject", "", "Expected attestation subject: file:, dir:, git, or container:") - cmd.Flags().StringVar(&keyPath, "key", "", "ECDSA P-256 PEM public key used to verify the attestation") + cmd.Flags().StringVar(&keyPath, "key", "", "ECDSA P-256 PEM public key used to verify a key-signed attestation") cmd.Flags().StringVar(&certIdentity, "certificate-identity", "", "Expected Sigstore certificate identity (reserved for future identity bundles)") cmd.Flags().StringVar(&certIssuer, "certificate-oidc-issuer", "", "Expected Sigstore OIDC issuer (reserved for future identity bundles)") cmd.Flags().StringVar(&extractPath, "extract-sbom", "", "Write the verified embedded SBOM to this path") From 2e103c909968e2e7c100d3b3bb581873c8832dbb Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Wed, 3 Jun 2026 02:24:20 -0700 Subject: [PATCH 6/7] docs: explain sbom attestation verification --- docs/SBOM_ATTESTATIONS.md | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/SBOM_ATTESTATIONS.md b/docs/SBOM_ATTESTATIONS.md index 82aa3f66..9df5f11f 100644 --- a/docs/SBOM_ATTESTATIONS.md +++ b/docs/SBOM_ATTESTATIONS.md @@ -68,6 +68,46 @@ bomly sbom verify \ `--extract-sbom` writes only after signature, subject, predicate, and SBOM checks pass. Bundles preserve the original SBOM bytes for extraction, including JSON key ordering and whitespace, without duplicating the SBOM as a second parsed JSON object in the predicate. +## How verification works + +An attestation is not an encrypted file. Anyone who has the attestation bundle can decode and read the signed payload, including the embedded SBOM. The key is used to prove authenticity, not secrecy. + +Think of the bundle as three parts: + +| Part | What it means | +| --- | --- | +| Payload | The readable claim: subject digest, SBOM digest, predicate type, and embedded SBOM bytes | +| Signature | A tamper-proof seal over that exact payload | +| Public key or identity material | Information used to check who made the seal | + +The private key creates the signature. The public key checks the signature. The public key does not hide or unlock the payload. + +Verification answers this question: + +> Was this exact SBOM attestation signed by the holder of the expected private key, and does it describe the exact subject I asked Bomly to verify? + +Bomly verifies an SBOM attestation in this order: + +1. Decode the readable DSSE payload from the bundle. +2. Verify the signature over that exact payload. +3. Resolve the requested subject, such as `file:`, `dir:`, `git`, or `container:`. +4. Hash the requested subject. +5. Compare that digest to the subject digest inside the signed payload. +6. Confirm the predicate type is Bomly's experimental SBOM predicate. +7. Decode the embedded SBOM bytes. +8. Parse the SBOM as supported SPDX or CycloneDX JSON. +9. Hash the embedded SBOM and compare it to the signed SBOM digest. +10. Write `--extract-sbom` only after all checks pass. + +If someone changes the subject digest, SBOM bytes, predicate type, or any other signed payload field, the signature check fails. If someone signs a new fake payload with a different key, verification fails unless you trust and pass that signer's public key. + +This means: + +- Decoding the payload proves nothing by itself. +- A valid signature proves the payload was not changed after signing. +- A trusted public key tells Bomly which signer you expected. +- An attestation proves a signed claim about an SBOM and subject; it does not prove the SBOM is complete, correct, or secret. + ## Subjects | Subject | Use for | Behavior | From cd4b42647545d03d749d4d8a2c2af96a271d92ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:46:42 +0000 Subject: [PATCH 7/7] chore: update merge conflict resolution progress --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 5fc28db0..0a079a6a 100644 --- a/go.mod +++ b/go.mod @@ -250,7 +250,6 @@ require ( github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/prometheus/procfs v0.17.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect diff --git a/go.sum b/go.sum index 8f3f70bc..328de8e7 100644 --- a/go.sum +++ b/go.sum @@ -862,8 +862,6 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=