diff --git a/registry/types/data/skill.schema.json b/registry/types/data/skill.schema.json index 2550e7e..b70a117 100644 --- a/registry/types/data/skill.schema.json +++ b/registry/types/data/skill.schema.json @@ -85,6 +85,9 @@ "$ref": "#/$defs/skill_package" } }, + "provenance": { + "$ref": "#/$defs/provenance" + }, "metadata": { "type": "object", "description": "Official metadata from the SKILL.md file", @@ -168,6 +171,52 @@ } } }, + "provenance": { + "type": "object", + "description": "Expected signer identity for this skill, checked on first install instead of trust-on-first-use. Every field is optional: an absent or empty value leaves that dimension unconstrained. Mirrors the server provenance definition in publisher-provided.schema.json, with two deliberate differences. First, no 'format' keywords, because the Go Provenance struct serializes unset fields as empty strings and an empty string does not satisfy 'uri' or 'hostname'. Second, 'attestation' is not accepted and 'additionalProperties' is false: the verifier only compares an expected attestation when the artifact also carries one, so a declared attestation constraint would be silently skipped against a signature that has none. Rejecting the key keeps the contract fail-closed -- a constraint this path cannot enforce is refused loudly rather than ignored. The same strictness makes a misspelled constraint key an error instead of a silently dropped guarantee.", + "additionalProperties": false, + "properties": { + "sigstore_url": { + "type": "string", + "description": "Sigstore TUF repository host for provenance verification", + "examples": [ + "tuf-repo.github.com", + "tuf-repo-cdn.sigstore.dev" + ] + }, + "repository_uri": { + "type": "string", + "description": "Repository URI used for provenance verification" + }, + "repository_ref": { + "type": "string", + "description": "Repository reference used for provenance verification" + }, + "signer_identity": { + "type": "string", + "description": "Identity of the signer for provenance verification", + "examples": [ + "/.github/workflows/build-skills.yml" + ] + }, + "runner_environment": { + "type": "string", + "description": "Build environment where the skill was built", + "examples": [ + "github-hosted", + "gitlab-hosted", + "self-hosted" + ] + }, + "cert_issuer": { + "type": "string", + "description": "Certificate issuer for provenance verification", + "examples": [ + "https://token.actions.githubusercontent.com" + ] + } + } + }, "skill_repository": { "type": "object", "description": "Source repository metadata", diff --git a/registry/types/skills_types.go b/registry/types/skills_types.go index bf18994..5685aa8 100644 --- a/registry/types/skills_types.go +++ b/registry/types/skills_types.go @@ -77,6 +77,17 @@ type Skill struct { Icons []SkillIcon `json:"icons,omitempty"` // Packages is the list of packages for the skill. Packages []SkillPackage `json:"packages,omitempty"` + // Provenance is the expected signer identity for this skill, checked on + // first install instead of trust-on-first-use. Absent means unconstrained + // — most catalog entries won't have this for a while, and that must not + // break installs; it's an opt-in tightening per entry, not a requirement. + // + // Only the identity fields are honored. Setting Provenance.Attestation + // makes Validate fail: the verifier compares an expected attestation only + // when the artifact also carries one, so the constraint would be silently + // skipped against a signature that has none. Refusing it loudly keeps the + // contract fail-closed until that gap is fixed. + Provenance *Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"` // Metadata is the official metadata of the skill as reported in the // SKILL.md file. Metadata map[string]any `json:"metadata,omitempty"` diff --git a/registry/types/skills_types_test.go b/registry/types/skills_types_test.go new file mode 100644 index 0000000..a8f0684 --- /dev/null +++ b/registry/types/skills_types_test.go @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +const ( + testSkillName = "commit" + testSigstoreURL = "tuf-repo.github.com" + testRepositoryURI = "https://github.com/stacklok/dockyard" + testRepositoryRef = "refs/heads/main" + testSignerIdentity = "/.github/workflows/build-skills.yml" + testRunnerEnv = "github-hosted" + testCertIssuer = "https://token.actions.githubusercontent.com" + testSkillIdentifier = "ghcr.io/stacklok/dockyard/skills/commit:1.0.0" + + testCaseFullProvenance = "fully populated provenance" +) + +// fullProvenance returns a Provenance with every field populated, matching the +// shape a catalog entry would declare for a skill built by a known workflow. +func fullProvenance() *Provenance { + return &Provenance{ + SigstoreURL: testSigstoreURL, + RepositoryURI: testRepositoryURI, + RepositoryRef: testRepositoryRef, + SignerIdentity: testSignerIdentity, + RunnerEnvironment: testRunnerEnv, + CertIssuer: testCertIssuer, + } +} + +// baseSkill returns a minimally valid Skill that individual tests can extend. +func baseSkill() *Skill { + return &Skill{ + Namespace: testNamespace, + Name: testSkillName, + Description: testLongDesc, + Version: testVersion, + Status: testStatusActive, + Packages: []SkillPackage{ + { + RegistryType: testRegistryType, + Identifier: testSkillIdentifier, + }, + }, + } +} + +// TestSkill_ProvenanceJSONRoundTrip verifies that Skill.Provenance survives a +// JSON round trip intact, and that a nil Provenance is omitted entirely rather +// than serialized as an explicit null. +func TestSkill_ProvenanceJSONRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provenance *Provenance + // wantKey is whether the "provenance" key should appear in the JSON. + wantKey bool + }{ + { + name: "provenance unset is omitted", + provenance: nil, + wantKey: false, + }, + { + name: testCaseFullProvenance, + provenance: fullProvenance(), + wantKey: true, + }, + { + name: "partially populated provenance", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + CertIssuer: testCertIssuer, + }, + wantKey: true, + }, + { + // The shared Provenance type carries Attestation, so it still + // serializes here. Validate rejects it; see + // TestSkill_ValidateWithProvenance. + name: "provenance with attestation", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + Attestation: &VerifiedAttestation{ + PredicateType: "https://slsa.dev/provenance/v1", + Predicate: map[string]any{"buildType": "workflow"}, + }, + }, + wantKey: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + skill := baseSkill() + skill.Provenance = tc.provenance + + data, err := json.Marshal(skill) + require.NoError(t, err) + + // Assert on the raw shape so omitempty behavior is pinned down, + // not just the decoded value. + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + _, hasKey := raw["provenance"] + assert.Equal(t, tc.wantKey, hasKey, "unexpected presence of provenance key in %s", data) + + var decoded Skill + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, tc.provenance, decoded.Provenance) + assert.Equal(t, *skill, decoded) + }) + } +} + +// TestSkill_ProvenanceYAMLRoundTrip verifies the same omitempty and round-trip +// behavior for YAML, which the config-style consumers of the catalog use. +func TestSkill_ProvenanceYAMLRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provenance *Provenance + wantKey bool + }{ + { + name: "provenance unset is omitted", + provenance: nil, + wantKey: false, + }, + { + name: testCaseFullProvenance, + provenance: fullProvenance(), + wantKey: true, + }, + { + name: "partially populated provenance", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + }, + wantKey: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + skill := baseSkill() + skill.Provenance = tc.provenance + + data, err := yaml.Marshal(skill) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, yaml.Unmarshal(data, &raw)) + _, hasKey := raw["provenance"] + assert.Equal(t, tc.wantKey, hasKey, "unexpected presence of provenance key in %s", data) + + var decoded Skill + require.NoError(t, yaml.Unmarshal(data, &decoded)) + assert.Equal(t, tc.provenance, decoded.Provenance) + }) + } +} + +// TestSkill_ValidateWithProvenance verifies that Validate accepts skills with +// provenance in every degree of population. Provenance is an opt-in tightening +// per catalog entry, so neither its absence nor a partially filled value may +// cause validation to fail. +func TestSkill_ValidateWithProvenance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provenance *Provenance + expectError bool + }{ + { + name: "no provenance", + provenance: nil, + }, + { + name: testCaseFullProvenance, + provenance: fullProvenance(), + }, + { + // The five core Provenance fields have no omitempty, so this + // serializes empty strings for everything but signer_identity. + // The schema must tolerate that. + name: "only signer identity", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + }, + }, + { + name: "empty provenance object", + provenance: &Provenance{}, + }, + { + // The verifier only compares an expected attestation when the + // artifact also carries one, so this constraint would be silently + // skipped against a signature with no attestation. Reject it + // rather than let a catalog author believe it is enforced. + name: "attestation is refused, not silently ignored", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + CertIssuer: testCertIssuer, + Attestation: &VerifiedAttestation{ + PredicateType: "https://slsa.dev/provenance/v1", + Predicate: map[string]any{"buildType": "workflow"}, + }, + }, + expectError: true, + }, + { + name: "empty attestation is still refused", + provenance: &Provenance{ + SignerIdentity: testSignerIdentity, + Attestation: &VerifiedAttestation{}, + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + skill := baseSkill() + skill.Provenance = tc.provenance + + err := skill.Validate() + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestValidateSkillBytes_Provenance exercises the schema's provenance +// definition directly, including shapes the Go struct cannot express. +func TestValidateSkillBytes_Provenance(t *testing.T) { + t.Parallel() + + const skillPrefix = `{ + "namespace": "io.github.stacklok", + "name": "commit", + "description": "Extract text and tables from PDF files", + "version": "1.0.0"` + + tests := []struct { + name string + provenance string + expectError bool + }{ + { + name: "valid full provenance", + provenance: `{"sigstore_url":"tuf-repo.github.com","repository_uri":"https://github.com/stacklok/dockyard","repository_ref":"refs/heads/main","signer_identity":"/.github/workflows/build-skills.yml","runner_environment":"github-hosted","cert_issuer":"https://token.actions.githubusercontent.com"}`, //nolint:lll + }, + { + name: "valid partial provenance", + provenance: `{"signer_identity":"/.github/workflows/build-skills.yml"}`, + }, + { + name: "valid empty provenance object", + provenance: `{}`, + }, + { + name: "provenance must be an object", + provenance: `"not-an-object"`, + expectError: true, + }, + { + name: "signer_identity must be a string", + provenance: `{"signer_identity":42}`, + expectError: true, + }, + { + // Not "must be an object" -- the key is refused outright, because + // the verifier cannot enforce an attestation constraint against a + // signature that carries none. + name: "attestation key is rejected", + provenance: `{"attestation":{"predicate_type":"https://slsa.dev/provenance/v1"}}`, + expectError: true, + }, + { + // additionalProperties:false turns a typo into a loud failure + // instead of a silently dropped security constraint. + name: "misspelled constraint key is rejected", + provenance: `{"signer_identityy":"/.github/workflows/build-skills.yml"}`, + expectError: true, + }, + { + name: "unknown constraint key is rejected", + provenance: `{"expected_sbom":"whatever"}`, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data := []byte(skillPrefix + `,"provenance":` + tc.provenance + "}") + err := ValidateSkillBytes(data) + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +}