diff --git a/AGENTS.md b/AGENTS.md index 197bb7f0..c331a394 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 @@ -42,6 +44,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 +145,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 b1a59481..c0125752 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). @@ -77,6 +77,8 @@ internal/analyzers/* Reachability analyzers (govulncheck — Go; 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 @@ -140,6 +142,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 52499749..879590de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,6 +206,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..9df5f11f --- /dev/null +++ b/docs/SBOM_ATTESTATIONS.md @@ -0,0 +1,134 @@ +# 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 +``` + +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: + +```bash +bomly sbom verify \ + --attestation sbom.att.json \ + --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 +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. +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 | +| --- | --- | --- | +| `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 8dddb85f..0a079a6a 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,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 @@ -185,7 +187,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 diff --git a/go.sum b/go.sum index 2dccb4b0..328de8e7 100644 --- a/go.sum +++ b/go.sum @@ -902,6 +902,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..f44c4881 --- /dev/null +++ b/internal/attestation/bundle.go @@ -0,0 +1,436 @@ +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" + sbomRawBase64Field = "sbomRawBase64" +) + +// 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 + } + 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[:]), + }, + sbomRawBase64Field: base64.StdEncoding.EncodeToString(sbomBytes), + }) + 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) + } + 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, + 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() + if formatValue == "" { + return nil, "", fmt.Errorf("sbom predicate is missing required fields") + } + raw, err := sbomBytesFromPredicate(predicate) + if err != nil { + return nil, "", 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) + } + if err := verifySBOMDigest(predicate, raw); err != nil { + return nil, "", err + } + return raw, target, nil +} + +func sbomBytesFromPredicate(predicate *structpb.Struct) ([]byte, error) { + rawBase64 := predicate.Fields[sbomRawBase64Field].GetStringValue() + if rawBase64 == "" { + return nil, fmt.Errorf("sbom predicate is missing required fields") + } + raw, err := base64.StdEncoding.DecodeString(rawBase64) + if err != nil { + return nil, fmt.Errorf("decode embedded sbom bytes: %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 + } + 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, 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 new file mode 100644 index 00000000..9d9a7542 --- /dev/null +++ b/internal/attestation/bundle_test.go @@ -0,0 +1,245 @@ +package attestation + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/protobuf/types/known/structpb" +) + +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) + } + 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) { + 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 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") + 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) + } + 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) + } +} + +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") + 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 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":[]}`) + 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", + "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..25c255c3 --- /dev/null +++ b/internal/attestation/subject_test.go @@ -0,0 +1,123 @@ +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") + } +} + +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..84ac4618 --- /dev/null +++ b/internal/cli/sbom_cmd.go @@ -0,0 +1,178 @@ +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:. --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 { + 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 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") + 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..dd3181ff --- /dev/null +++ b/internal/cli/sbom_cmd_test.go @@ -0,0 +1,130 @@ +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) + } + 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 { + 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": [] +}` +}