Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
75b643e
Check git_commit_info against the API's GitCommit schema
tooky Nov 11, 2026
36e4f08
Read in-toto attestations and match them to an artifact
tooky Nov 11, 2026
a213c4e
Verify signed in-toto attestations with sigstore-go
tooky Nov 11, 2026
6b72452
Map provenance and SBOM predicates onto Kosli's terms
tooky Nov 11, 2026
7424c31
Look up a commit's details on GitHub by its SHA
tooky Nov 11, 2026
93a6392
Share how attest sbom builds its attestation data
tooky Nov 11, 2026
59ddd13
Add kosli attest in-toto, reading attestations from a bundle file
tooky Nov 11, 2026
4fe746c
Let attest sbom read an SBOM from a bundle or DSSE envelope
tooky Nov 11, 2026
2a220e0
no-mistakes(review): Drive provenance checks from Missing; drop unuse…
tooky Nov 11, 2026
88b6ac5
no-mistakes(ci): Root cause: the failure came from this PR's test, no…
tooky Nov 11, 2026
2103ac7
no-mistakes(ci): Root cause: the failure came from this PR's own test…
tooky Nov 11, 2026
8dce16f
Check an SBOM attestation's subject even when no signer is pinned
tooky Nov 11, 2026
7719a9e
Point attest sbom at attest in-toto for a file of several attestations
tooky Nov 11, 2026
290b885
Say that a GitHub Enterprise commit lookup needs --github-base-url
tooky Nov 11, 2026
5195238
Record nothing when any attestation in the file fails verification
tooky Nov 11, 2026
5aa2ad0
no-mistakes(document): Restore Verifier doc comment in intoto
tooky Nov 11, 2026
8cda42f
Tell a signature that cannot be checked apart from a bad one
tooky Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/kosli/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func newAttestCmd(out io.Writer) *cobra.Command {
newAttestSonarCmd(out),
newAttestCustomCmd(out),
newAttestSbomCmd(out),
newAttestInTotoCmd(out),
newAttestOverrideCmd(out),
newAttestDecisionCmd(out),
)
Expand Down
552 changes: 552 additions & 0 deletions cmd/kosli/attestInToto.go

Large diffs are not rendered by default.

443 changes: 443 additions & 0 deletions cmd/kosli/attestInToto_test.go

Large diffs are not rendered by default.

185 changes: 156 additions & 29 deletions cmd/kosli/attestSbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package main

import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/kosli-dev/cli/internal/intoto"
"github.com/kosli-dev/cli/internal/requests"
"github.com/kosli-dev/cli/internal/sbom"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -34,6 +36,7 @@ type SbomAttestationPayload struct {
type attestSbomOptions struct {
*CommonAttestationOptions
sbomFilePath string
policy intoto.Policy
payload SbomAttestationPayload
}

Expand All @@ -49,8 +52,20 @@ not compressed, and a gzipped file is rejected, because the format and the
summary below are read from it.

Kosli reads the format, the creation time, the tools that produced it, the
subject it describes and how many packages it lists. Nothing is checked against
the artifact; the SBOM is recorded as reported.
subject it describes and how many packages it lists. A plain SBOM file is not
checked against the artifact; it is recorded as reported.

The SBOM may also arrive inside an in-toto attestation, as a Sigstore bundle or
a DSSE envelope. The SPDX or CycloneDX document is then read from it, and the
bundle or envelope is what is uploaded and checksummed. When the SBOM is about
an artifact, the attestation must list that artifact as its subject, signed or
not. To verify its signature, pass ^--certificate-identity^ (or
^--certificate-identity-regexp^) with ^--certificate-oidc-issuer^, or ^--key^.
Without them the signature is not checked, and even a signed bundle is recorded
with ^verified^ set to false. The record is annotated with ^verified^ and the
verification details, as ^kosli attest in-toto^ annotates it. To pick the SBOM
from a file holding several attestations, or to record provenance, use
^kosli attest in-toto^.

The SBOM file is the only attachment: this command does not accept additional
attachments, because two or more would be compressed together.
Expand All @@ -74,6 +89,18 @@ kosli attest sbom yourDockerImageName \
--api-token yourAPIToken \
--org yourOrgName

# report an SBOM signed into a Sigstore bundle, verifying who signed it:
kosli attest sbom yourDockerImageName \
--artifact-type docker \
--name yourAttestationName \
--sbom-file yourSbom.sigstore.json \
--certificate-identity https://github.com/yourOrg/yourRepo/.github/workflows/build.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--flow yourFlowName \
--trail yourTrailName \
--api-token yourAPIToken \
--org yourOrgName

# report an SBOM about a trail:
kosli attest sbom \
--name yourAttestationName \
Expand Down Expand Up @@ -136,6 +163,11 @@ func newAttestSbomCmd(out io.Writer) *cobra.Command {
return ErrorBeforePrintingUsage(cmd, err.Error())
}

err = o.policy.Validate()
if err != nil {
return err
}

return ValidateRegistryFlags(cmd, o.fingerprintOptions)
},

Expand All @@ -149,6 +181,7 @@ func newAttestSbomCmd(out io.Writer) *cobra.Command {
ci := WhichCI()
addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci)
cmd.Flags().StringVar(&o.sbomFilePath, "sbom-file", "", attestationSbomFileFlag)
addSignerPolicyFlags(cmd, &o.policy)
// Required --sbom-file and mutually exclusive --attachments can never both be
// satisfied, so the flag is hidden from help. Passing it still gets the error.
_ = cmd.Flags().MarkHidden("attachments")
Expand Down Expand Up @@ -213,20 +246,135 @@ func (o *attestSbomOptions) loadSbom() ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("failed to read SBOM file [%s]: %s", o.sbomFilePath, err)
}
fingerprint := fmt.Sprintf("%x", sha256.Sum256(content))

data, err := sbom.ProcessSBOM(content)
document, evidence, err := o.unwrap(content)
if err != nil {
return nil, err
}
o.payload.AttestationData, err = sbomAttestationData(content, document)
if err != nil {
return nil, fmt.Errorf("failed to parse SBOM file [%s]: %s", o.sbomFilePath, err)
}
// After CommonAttestationOptions.run, which assigns payload.Annotations
// wholesale from --annotate; called before it, both keys are discarded.
o.payload.Annotations = withAnnotations(o.payload.Annotations, sbomAnnotations(o.payload.AttestationData))
if evidence != nil {
if err := o.recordVerification(evidence); err != nil {
return nil, err
}
}
return content, nil
}

// unwrap returns the SBOM document content holds. A Sigstore bundle or DSSE
// envelope holds it as an in-toto attestation, which must be an SPDX or
// CycloneDX one about the artifact, when there is one, and is verified when a
// signer is pinned; evidence then says what was established, and is nil for a
// plain SBOM file.
func (o *attestSbomOptions) unwrap(content []byte) (document []byte, evidence *intoto.Evidence, err error) {
attestation, wrapped, err := intoto.ReadEnvelope(content)
if errors.Is(err, intoto.ErrSeveralDocuments) {
return nil, nil, fmt.Errorf("SBOM file [%s] %s. To record the SBOM from a file holding several attestations, such as an .intoto.jsonl file, use kosli attest in-toto --predicate-type sbom", o.sbomFilePath, err)
}
if err != nil {
return nil, nil, fmt.Errorf("failed to read SBOM file [%s]: %s", o.sbomFilePath, err)
}
if !wrapped {
if o.policy.PinsSigner() {
return nil, nil, fmt.Errorf("SBOM file [%s] is not a Sigstore bundle or DSSE envelope, so it has no signature to verify against --certificate-identity, --certificate-identity-regexp or --key", o.sbomFilePath)
}
return content, nil, nil
}

document, err = intoto.SBOMDocument(attestation)
if err != nil {
return nil, nil, fmt.Errorf("SBOM file [%s]: %s", o.sbomFilePath, err)
}
if o.payload.ArtifactFingerprint != "" {
if err := attestation.RequireSubject(o.payload.ArtifactFingerprint); err != nil {
return nil, nil, fmt.Errorf("SBOM file [%s]: %s", o.sbomFilePath, err)
}
}
if !o.policy.PinsSigner() {
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
return document, &intoto.Evidence{}, nil
}
if !attestation.Signed() {
return nil, nil, fmt.Errorf("SBOM file [%s] is not signed, so it cannot be verified against --certificate-identity, --certificate-identity-regexp or --key", o.sbomFilePath)
}
evidence, err = intoto.NewVerifier(o.policy, intotoTrustRoots()).Verify(attestation, o.payload.ArtifactFingerprint)
if err != nil {
return nil, nil, fmt.Errorf("SBOM file [%s]: %s", o.sbomFilePath, err)
}
return document, evidence, nil
}

// recordVerification annotates the record with what verifying the
// attestation established, and links its transparency log entry. These keys
// are reserved only for an SBOM that arrives in an attestation, so a plain
// SBOM file can still be annotated with them.
func (o *attestSbomOptions) recordVerification(evidence *intoto.Evidence) error {
for _, reserved := range []string{verifiedAnnotation, signerIdentityAnnotation, signerIssuerAnnotation,
logIndexAnnotation, integratedTimeAnnotation, signedTimestampAnnotation} {
if _, taken := o.annotations[reserved]; taken {
return fmt.Errorf("annotation key '%s' is set by this command from the attestation and cannot be provided with --annotate", reserved)
}
}
o.payload.Annotations = withAnnotations(o.payload.Annotations, verificationAnnotations(evidence))

if evidence.LogEntryURL == "" {
return nil
}
if _, taken := o.externalURLs[attestationExternalURL]; taken {
return fmt.Errorf("external URL label '%s' is set by this command from the attestation and cannot be provided with --external-url", attestationExternalURL)
}
if o.payload.ExternalURLs == nil {
o.payload.ExternalURLs = map[string]*URLInfo{}
}
o.payload.ExternalURLs[attestationExternalURL] = &URLInfo{Href: evidence.LogEntryURL, Fingerprint: evidence.RawSHA256}
return nil
}

o.payload.AttestationData = SbomAttestationData{
// sbomAttestationData summarises document, the SBOM itself, and fingerprints
// attached, the bytes uploaded with the attestation, so the recorded checksum
// is one a reader can check against the attachment. The two are the same
// bytes unless the SBOM arrives inside an attestation bundle, which is then
// what is attached.
func sbomAttestationData(attached, document []byte) (SbomAttestationData, error) {
data, err := sbom.ProcessSBOM(document)
if err != nil {
return SbomAttestationData{}, err
}
return SbomAttestationData{
Format: data.Format,
OriginalFingerprint: fingerprint,
OriginalFingerprint: fmt.Sprintf("%x", sha256.Sum256(attached)),
Document: data.Document,
}, nil
}

// sbomAnnotations records what the SBOM said about itself where a reader sees
// it on the trail page. The same two values are carried inside
// attestation_data.
func sbomAnnotations(data SbomAttestationData) map[string]string {
return map[string]string{
sbomFormatAnnotation: data.Format,
sbomSha256Annotation: data.OriginalFingerprint,
}
o.annotate(data.Format, fingerprint)
return content, nil
}

// withAnnotations returns base with extra added, in a new map. base is often
// the --annotate flag's own map -- processAnnotations returns its argument --
// so writing into it would put derived keys into the map a reserved-key check
// reads, and a second run of the same options would be refused for a key
// nobody supplied.
func withAnnotations(base, extra map[string]string) map[string]string {
merged := make(map[string]string, len(base)+len(extra))
for key, value := range base {
merged[key] = value
}
for key, value := range extra {
merged[key] = value
}
return merged
}

// rejectAttachments refuses --attachments however it was set. Two or more
Expand Down Expand Up @@ -275,24 +423,3 @@ func (o *attestSbomOptions) rejectReservedAnnotations() error {
}
return nil
}

// annotate records what the file said about itself where a reader sees it on
// the trail page. The same two values are carried inside attestation_data.
//
// It must run after CommonAttestationOptions.run, which assigns
// payload.Annotations wholesale from the --annotate flag. Called before that,
// both keys are discarded.
//
// It builds a new map rather than writing into that one. The two are the same
// map -- processAnnotations returns its argument -- so writing would put these
// keys into the --annotate map that rejectReservedAnnotations reads, and a
// second run of the same options would be refused for a key nobody supplied.
func (o *attestSbomOptions) annotate(format, fingerprint string) {
merged := make(map[string]string, len(o.payload.Annotations)+2)
for key, value := range o.payload.Annotations {
merged[key] = value
}
merged[sbomFormatAnnotation] = format
merged[sbomSha256Annotation] = fingerprint
o.payload.Annotations = merged
}
100 changes: 100 additions & 0 deletions cmd/kosli/attestSbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"testing"

"github.com/kosli-dev/cli/internal/intoto"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
Expand Down Expand Up @@ -67,6 +69,104 @@ func (suite *AttestSbomCommandTestSuite) TestAttestSbomBuildsTheRightRequest() {
})
}

// An SBOM signed into an attestation is recorded with the attestation as the
// attachment, and verified when a signer is pinned.
func (suite *AttestSbomCommandTestSuite) TestAttestSbomUnwrapsAnAttestation() {
trustedRoot, err := root.NewTrustedRootFromPath(filepath.Join(intotoFixtures, "trusted_root.json"))
require.NoError(suite.T(), err)
saved := intotoTrustRoots
intotoTrustRoots = func() *intoto.TrustRoots { return intoto.FixedTrustRoots(trustedRoot) }
defer func() { intotoTrustRoots = saved }()

bundle := filepath.Join(intotoFixtures, "spdx-sbom.sigstore.json")
bundleSHA256 := fixtureSHA256(suite.T(), "spdx-sbom.sigstore.json")
envelope := filepath.Join(intotoFixtures, "unsigned-spdx-sbom.dsse.json")
command := func(flags string) string {
return "attest sbom --name my-sbom " + flags + suite.defaultKosliArguments
}

runTestCmd(suite.T(), []cmdTestCase{
{
name: "reads the SBOM from a Sigstore bundle and fingerprints the bundle it attaches",
cmd: command("--dry-run --sbom-file " + bundle),
goldenRegex: `(?s)"filename": "spdx-sbom.sigstore.json".*"sbom_sha256": "` + bundleSHA256 + `",\s+"verified": "false".*"format": "spdx-2\.3",\s+"original_fingerprint": "` + bundleSHA256 + `"`,
},
{
name: "verifies the bundle against the pinned signer and records the evidence",
cmd: command("--dry-run --sbom-file " + bundle + " " + intotoSigner),
goldenRegex: `(?s)"external_urls": \{\s+"attestation": \{\s+"href": "` + regexpQuote(intotoLogEntryURL) + `",\s+"fingerprint": "` + bundleSHA256 + `".*"signer_identity": "https://github.com/acme/widget/.github/workflows/build.yml@refs/heads/main".*"verified": "true"`,
},
{
name: "checks the subject when the SBOM is about an artifact",
cmd: command("--dry-run --sbom-file " + bundle + " " + intotoSigner + " --fingerprint " + intotoDigest),
goldenRegex: `"verified": "true"`,
},
{
wantError: true,
name: "refuses a verified SBOM about another artifact",
cmd: command("--sbom-file " + bundle + " " + intotoSigner + " --fingerprint 11de96226896b0fcf4ea66a11a63a5fd9f4e211fe94b62b7eebf4485c817b2d8"),
golden: "Error: SBOM file [" + bundle + "]: the attestation does not list the artifact (sha256:11de96226896b0fcf4ea66a11a63a5fd9f4e211fe94b62b7eebf4485c817b2d8) as its subject\n",
},
{
wantError: true,
name: "refuses a bundle signed by someone other than the pinned signer",
cmd: command("--sbom-file " + bundle + " --certificate-identity https://github.com/mallory/widget/.github/workflows/build.yml@refs/heads/main --certificate-oidc-issuer https://token.actions.githubusercontent.com"),
goldenRegex: `^Error: SBOM file \[.*spdx-sbom.sigstore.json\]: the attestation failed verification: `,
},
{
name: "reads the SBOM from an unsigned DSSE envelope, marked unverified",
cmd: command("--dry-run --sbom-file " + envelope),
goldenRegex: `(?s)"verified": "false".*"format": "spdx-2\.3"`,
},
{
name: "accepts an unsigned SBOM about the artifact",
cmd: command("--dry-run --sbom-file " + envelope + " --fingerprint " + intotoDigest),
goldenRegex: `"verified": "false"`,
},
{
wantError: true,
name: "refuses an unsigned SBOM about another artifact, though no signer is pinned",
cmd: command("--sbom-file " + envelope + " --fingerprint 11de96226896b0fcf4ea66a11a63a5fd9f4e211fe94b62b7eebf4485c817b2d8"),
golden: "Error: SBOM file [" + envelope + "]: the attestation does not list the artifact (sha256:11de96226896b0fcf4ea66a11a63a5fd9f4e211fe94b62b7eebf4485c817b2d8) as its subject\n",
},
{
wantError: true,
name: "refuses to verify an unsigned DSSE envelope",
cmd: command("--sbom-file " + envelope + " " + intotoSigner),
golden: "Error: SBOM file [" + envelope + "] is not signed, so it cannot be verified against --certificate-identity, --certificate-identity-regexp or --key\n",
},
{
wantError: true,
name: "points a file holding several attestations to attest in-toto",
cmd: command("--sbom-file " + filepath.Join(intotoFixtures, "attestations.intoto.jsonl")),
golden: "Error: SBOM file [" + filepath.Join(intotoFixtures, "attestations.intoto.jsonl") + "] holds several JSON documents, not a single bundle or envelope. To record the SBOM from a file holding several attestations, such as an .intoto.jsonl file, use kosli attest in-toto --predicate-type sbom\n",
},
{
wantError: true,
name: "refuses an attestation that is not an SBOM",
cmd: command("--sbom-file " + filepath.Join(intotoFixtures, "github-provenance.sigstore.json")),
golden: "Error: SBOM file [" + filepath.Join(intotoFixtures, "github-provenance.sigstore.json") + "]: the attestation is https://slsa.dev/provenance/v1, not an SPDX or CycloneDX SBOM\n",
},
{
wantError: true,
name: "refuses to verify a plain SBOM file, which has no signature",
cmd: command("--sbom-file testdata/sbom/cyclonedx.json " + intotoSigner),
golden: "Error: SBOM file [testdata/sbom/cyclonedx.json] is not a Sigstore bundle or DSSE envelope, so it has no signature to verify against --certificate-identity, --certificate-identity-regexp or --key\n",
},
{
name: "still lets a plain SBOM file be annotated with a key the verification would set",
cmd: command("--dry-run --sbom-file testdata/sbom/cyclonedx.json --annotate verified=by-hand"),
goldenRegex: `"verified": "by-hand"`,
},
{
wantError: true,
name: "reserves the verification annotations for an SBOM in an attestation",
cmd: command("--sbom-file " + bundle + " --annotate verified=by-hand"),
golden: "Error: annotation key 'verified' is set by this command from the attestation and cannot be provided with --annotate\n",
},
})
}

func (suite *AttestSbomCommandTestSuite) TestAttestSbomRejectsBadInput() {
tests := []cmdTestCase{
{
Expand Down
2 changes: 2 additions & 0 deletions cmd/kosli/openapiContract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/kosli-dev/cli/internal/gitview"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
Expand Down Expand Up @@ -67,6 +68,7 @@ func (suite *OpenAPIContractTestSuite) TestPayloadsMatchSchema() {
registry := []driftCase{
{name: "create control", payload: ControlPayload{}, component: "ControlPostInput"},
{name: "create environment", payload: CreateEnvironmentPayload{}, component: "CreateEnvironmentPutInput"},
{name: "git commit info", payload: gitview.BasicCommitInfo{}, component: "GitCommit"},
}

for _, c := range registry {
Expand Down
Loading
Loading