diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index c62ae105..3dd1f821 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -66,7 +66,7 @@ commits using the embedded attestations stored in git notes in your git reposito To verify the latest commit, run: ```bash - sourcetool verifycommit yourorg/yourrepo + sourcetool verify yourorg/yourrepo ``` ## Onboarding Guide @@ -151,6 +151,15 @@ was found and the current SLSA level of the repository: ![](docs/media/image04-status.png) +When the controls are eligible for a higher level than the policy verifies, +status explains the gap (for example, when the policy targets a lower level or +is not fully met). To print only the policy-verified SLSA source level, for +example when scripting, pass `--level`: + +```bash +sourcetool status yourorg/yourrepo --level +``` + ## One-shot Repository Set Up The quickest way to set up a repository is to use sourcetool’s one-shot set up @@ -238,22 +247,61 @@ sourcetool policy view yourorg/yourrepo ![](docs/media/image09-policy.png ) -## Verifying Commits +## Verifying Commits and Tags Once the SLSA controls are in place, each commit pushed into the repository will generate source provenance metadata and store it in git notes by default. These attestations and VSAs can be used to verify the SLSA level of the repository. -To verify a commit, use the `verifycommit` subcommand. Pass it a commit locator +To verify a revision, use the `verify` subcommand. Pass it a commit locator like this: ```bash -sourcetool verifycommit slsa-framework/slsa-source-poc@fc0f59a9332e7873bb146b95cc4b39232eada7d2 +sourcetool verify slsa-framework/source-tool@fc0f59a9332e7873bb146b95cc4b39232eada7d2 ``` ![](docs/media/image10-pcy-json.png) If you omit the commit SHA, sourcetool will verify the last commit in the branch. +You can also verify a tag with `--tag`: + +```bash +sourcetool verify yourorg/yourrepo --tag v1.0.0 +``` + +`verify` reports the policy-verified SLSA source level and exits with a non-zero +status when the revision cannot be verified, which makes it convenient to gate +scripts and CI. + +### Reading From Different Attestation Sources + +By default the reading subcommands (`verify`, `get`, `attest` and `audit`) read +attestations from the git notes stored in your repository. If your attestations +are also published to the GitHub attestations API (with `--push=github`), point +the reader at that source with `--from`: + +```bash +sourcetool verify yourorg/yourrepo --from=github +sourcetool verify yourorg/yourrepo --from=github,note +``` + +Git notes is the default because reading from the GitHub attestations API +requires the token to have attestations read access, which is not always granted +in CI. + +## Retrieving Attestations + +To fetch and print the raw attestations for a revision, use the `get` subcommand: + +```bash +sourcetool get yourorg/yourrepo +``` + +By default `get` prints both the source provenance and the VSA; use +`--provenance` or `--vsa` to select just one. It verifies every attestation it +prints and writes a warning to stderr when verification fails. Pass +`--require-verified` to make it exit with a non-zero status on a verification +failure. ## Troubleshooting diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 788e8b47..eaa52815 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -7,13 +7,13 @@ using GitHub's existing functionality. * Users create a [policy](#policy) for the repo & branches they want to protect, indicating their desired SLSA level. -* Users call the .github/workflows/slsa_with_provenance.yml reusable workflow on any +* Users call the `compute_slsa_source.yml` reusable workflow from + [source-actions](https://github.com/slsa-framework/source-actions) on any `push` changes to protected branches. -* The slsa_with_provenance workflow gets the attestations, if any, for the prior - commit. -* The slsa_with_provenance workflow evaluates their controls, the current commit, and - prior attestations, to determine the SLSA Source level of the current commit. -* A VSA, 'source provenance', are created within the workflow, and +* The workflow gets the attestations, if any, for the prior commit. +* The workflow evaluates their controls, the current commit, and prior + attestations, to determine the SLSA Source level of the current commit. +* A VSA and 'source provenance' are created within the workflow, and are stored in [git notes](https://git-scm.com/docs/git-notes) for the current commit. * Downstream users can get the VSA for the revision they're consuming by getting the git notes for that revision. @@ -39,7 +39,7 @@ purposes and may be deprecated. TODO: Should we cut this section and feature? -In the control-only approach the `sourcetool` with the `checklevel` command fetches the +In the control-only approach the `sourcetool status` command fetches the rulesets that are _currently_ enabled on the source repository. If all of the following are true: @@ -243,7 +243,7 @@ Source provenance covers changes to a branch. It indicates: } } ], - "predicateType": "https://github.com/slsa-framework/source-tool/source-provenance/v1-draft", + "predicateType": "https://github.com/slsa-framework/slsa-source-poc/source-provenance/v1-draft", "predicate": { "activity_type": "pr_merge", "actor": "TomHennen", diff --git a/internal/cmd/attest.go b/internal/cmd/attest.go new file mode 100644 index 00000000..0c54bea6 --- /dev/null +++ b/internal/cmd/attest.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright 2025 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "fmt" + "os" + "slices" + + "github.com/spf13/cobra" + + "github.com/slsa-framework/source-tool/pkg/sourcetool" +) + +type attestOptions struct { + revisionOpts + pushOptions + fromOptions + allowMergeCommitsOptions + provenance bool + vsa bool + sign bool + output string + useLocalPolicy string + silentDowngrade bool +} + +func (ao *attestOptions) Validate() error { + errs := []error{ + ao.revisionOpts.Validate(), + ao.pushOptions.Validate(), + ao.fromOptions.Validate(), + } + if !ao.provenance && !ao.vsa { + errs = append(errs, errors.New("nothing to generate: enable --provenance and/or --vsa")) + } + return errors.Join(errs...) +} + +func (ao *attestOptions) AddFlags(cmd *cobra.Command) { + ao.revisionOpts.AddFlags(cmd) + ao.pushOptions.AddFlags(cmd) + ao.fromOptions.AddFlags(cmd) + ao.allowMergeCommitsOptions.AddFlags(cmd) + cmd.PersistentFlags().BoolVar(&ao.provenance, "provenance", true, "write the provenance attestation") + cmd.PersistentFlags().BoolVar(&ao.vsa, "vsa", true, "write the verification summary attestation (VSA)") + cmd.PersistentFlags().BoolVar(&ao.sign, "sign", true, "sign the attestations") + cmd.PersistentFlags().StringVar(&ao.output, "output", "", "path to write the attestation bundle (default: stdout)") + cmd.PersistentFlags().StringVar(&ao.useLocalPolicy, "use-local-policy", "", "path to a local policy file to evaluate instead of the community policy") + cmd.PersistentFlags().BoolVar(&ao.silentDowngrade, "silent-downgrade", false, "warn instead of failing when the achieved level is below the policy target") +} + +func addAttest(parentCmd *cobra.Command) { + opts := attestOptions{} + attestCmd := &cobra.Command{ + Use: "attest [flags] owner/repo[@ref]", + GroupID: cmdGroupAttestation, + Short: "Generate the source attestations for a revision", + Long: `Generate the SLSA source attestations for a revision. + +attest creates the source provenance and the verification summary +attestation (VSA) for a commit or a tag. Use --provenance and --vsa to +select which of the two are written. When the VSA is disabled the +repository policy is not evaluated and only the provenance is produced. + +The attestations are written to stdout as a JSONL bundle unless --output +is given, and can be pushed to storage with --push.`, + SilenceUsage: true, + SilenceErrors: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + if err := opts.ParseLocator(args[0]); err != nil { + return err + } + } + + if err := opts.repoOptions.Validate(); err != nil { + return err + } + + return opts.EnsureDefaults() + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.Validate(); err != nil { + return fmt.Errorf("validating options: %w", err) + } + + var githubStorer, notesStorer, pushAttestations bool + if slices.Contains(opts.pushLocation, pushRepoGithub) { + pushAttestations = true + githubStorer = true + } + if slices.Contains(opts.pushLocation, pushRepoNote) { + pushAttestations = true + notesStorer = true + } + + authenticator, err := CheckAuth() + if err != nil { + return err + } + + srctool, err := sourcetool.New( + sourcetool.WithAuthenticator(authenticator), + sourcetool.WithAllowMergeCommits(opts.allowMergeCommits), + sourcetool.WithNotesStorer(notesStorer), + sourcetool.WithGithubStorer(githubStorer), + sourcetool.WithGithubCollector(opts.readGithub()), + sourcetool.WithNotesCollector(opts.readNotes()), + ) + if err != nil { + return fmt.Errorf("creating sourcetool: %w", err) + } + + result, err := srctool.AttestRevision( + cmd.Context(), opts.GetBranch(), opts.GetRevision(), + sourcetool.WithProvenance(opts.provenance), + sourcetool.WithVSA(opts.vsa), + sourcetool.WithSign(opts.sign), + sourcetool.WithLocalPolicy(opts.useLocalPolicy), + sourcetool.WithOutputPath(opts.output), + sourcetool.WithUseStdout(opts.output == ""), + sourcetool.WithPush(pushAttestations), + ) + if err != nil { + return fmt.Errorf("attesting revision: %w", err) + } + + // The attestations are generated (and optionally pushed) regardless + // of the policy outcome. When the achieved level is below the policy + // target return exit code 2, or just a warning with --silent-downgrade. + if result.Shortfall != nil { + msg := fmt.Sprintf( + "policy target level %s not met; achieved %s: %s", + result.Shortfall.TargetLevel, result.Shortfall.AchievedLevel, result.Shortfall.Reason, + ) + if opts.silentDowngrade { + fmt.Fprintf(os.Stderr, "warning: %s\n", msg) + return nil + } + return &exitError{code: 2, err: errors.New(msg)} + } + + return nil + }, + } + opts.AddFlags(attestCmd) + parentCmd.AddCommand(attestCmd) +} diff --git a/internal/cmd/audit.go b/internal/cmd/audit.go index 08a32fe6..8c5533d1 100644 --- a/internal/cmd/audit.go +++ b/internal/cmd/audit.go @@ -63,6 +63,7 @@ type auditOpts struct { branchOptions verifierOptions outputOptions + fromOptions auditDepth int endingCommit string auditMode AuditMode @@ -104,6 +105,7 @@ func (ao *auditOpts) Validate() error { ao.branchOptions.Validate(), ao.verifierOptions.Validate(), ao.outputOptions.Validate(), + ao.fromOptions.Validate(), } return errors.Join(errs...) } @@ -112,6 +114,7 @@ func (ao *auditOpts) AddFlags(cmd *cobra.Command) { ao.branchOptions.AddFlags(cmd) ao.verifierOptions.AddFlags(cmd) ao.outputOptions.AddFlags(cmd) + ao.fromOptions.AddFlags(cmd) cmd.PersistentFlags().IntVar(&ao.auditDepth, "depth", 0, "The max number of revisions to audit (depth <= audit all revisions).") cmd.PersistentFlags().StringVar(&ao.endingCommit, "ending-commit", "", "The commit to stop auditing at.") ao.auditMode = AuditModeBasic @@ -165,6 +168,8 @@ Future: srctool, err := sourcetool.New( sourcetool.WithAuthenticator(authenticator), sourcetool.WithExpectedIdentity(opts.expectedIssuer, opts.expectedSan), + sourcetool.WithGithubCollector(opts.readGithub()), + sourcetool.WithNotesCollector(opts.readNotes()), ) if err != nil { return err diff --git a/internal/cmd/checklevel.go b/internal/cmd/checklevel.go index be8c3e64..19be1f2b 100644 --- a/internal/cmd/checklevel.go +++ b/internal/cmd/checklevel.go @@ -43,9 +43,10 @@ func addCheckLevel(parentCmd *cobra.Command) { opts := checkLevelOpts{} checklevelCmd := &cobra.Command{ - Use: "checklevel", - GroupID: cmdGroupAssessment, - Short: "Determines the SLSA Source Level of the repo", + Use: "checklevel", + Hidden: true, + Deprecated: `use "sourcetool status --level" instead`, + Short: "Determines the SLSA Source Level of the repo", Long: `Determines the SLSA Source Level of the repo. This is meant to be run within the corresponding GitHub Actions workflow.`, diff --git a/internal/cmd/checklevelprov.go b/internal/cmd/checklevelprov.go index bd10b4d8..c1325c83 100644 --- a/internal/cmd/checklevelprov.go +++ b/internal/cmd/checklevelprov.go @@ -95,10 +95,11 @@ func addCheckLevelProv(parentCmd *cobra.Command) { opts := &checkLevelProvOpts{} checklevelprovCmd := &cobra.Command{ - Use: "checklevelprov", - GroupID: cmdGroupAssessment, - Example: `sourcetool checklevelprov owner/repo --push=note`, - Short: "Checks the given commit against policy using & creating provenance", + Use: "checklevelprov", + Hidden: true, + Deprecated: `use "sourcetool attest" instead`, + Example: `sourcetool checklevelprov owner/repo --push=note`, + Short: "Checks the given commit against policy using & creating provenance", Long: `Checks the given commit against policy using & creating provenance. The checklevelprov subcommand computes the SLSA level of a commit by retrieving diff --git a/internal/cmd/checktag.go b/internal/cmd/checktag.go index 0acb3bac..fae06bef 100644 --- a/internal/cmd/checktag.go +++ b/internal/cmd/checktag.go @@ -55,9 +55,10 @@ func addCheckTag(parentCmd *cobra.Command) { opts := &checkTagOptions{} checktagCmd := &cobra.Command{ - Use: "checktag", - GroupID: cmdGroupAssessment, - Short: "Checks to see if the tag operation should be allowed and issues a VSA", + Use: "checktag", + Hidden: true, + Deprecated: `use "sourcetool attest --tag " instead`, + Short: "Checks to see if the tag operation should be allowed and issues a VSA", PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { if err := opts.ParseLocator(args[0]); err != nil { diff --git a/internal/cmd/createpolicy.go b/internal/cmd/createpolicy.go index 8f87f3fa..14ca9686 100644 --- a/internal/cmd/createpolicy.go +++ b/internal/cmd/createpolicy.go @@ -30,12 +30,15 @@ func addCreatePolicy(parentCmd *cobra.Command) { opts := createPolicyOptions{} createpolicyCmd := &cobra.Command{ - Use: "createpolicy", - GroupID: cmdGroupPolicy, - Short: "Creates a policy in a local copy of source-policies", + Use: "createpolicy", + Short: "Creates a policy in a local copy of source-policies", Long: `Creates a SLSA source policy in a local copy of source-policies. The created policy should then be sent as a PR to slsa-framework/source-policies.`, + // Deprecated in favor of "sourcetool policy create". Kept hidden and + // functional during the phase-out period. + Hidden: true, + Deprecated: `use "sourcetool policy create" instead`, RunE: func(cmd *cobra.Command, args []string) error { if err := opts.Validate(); err != nil { return err diff --git a/internal/cmd/from.go b/internal/cmd/from.go new file mode 100644 index 00000000..4d4c1b2f --- /dev/null +++ b/internal/cmd/from.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2026 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "fmt" + "slices" + + "github.com/spf13/cobra" +) + +// supportedReadSources lists the attestation sources the --from flag accepts. +// They mirror the --push destinations. +var supportedReadSources = []string{pushRepoGithub, pushRepoNote} + +// defaultReadSources is the set of attestation sources. Bye default we +// reads git notes only as the GitHub attestations API requires the token +// to have attestations read access, so it must be opted into to avoid failing +// reads in CI where that permission is not granted. +var defaultReadSources = []string{pushRepoNote} + +// fromOptions selects the sources to read attestations from. +type fromOptions struct { + from []string +} + +func (fo *fromOptions) AddFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringSliceVar( + &fo.from, "from", defaultReadSources, + fmt.Sprintf("sources to read attestations from %v", supportedReadSources), + ) +} + +func (fo *fromOptions) Validate() error { + var errs []error + for _, s := range fo.from { + if !slices.Contains(supportedReadSources, s) { + errs = append(errs, fmt.Errorf("unsupported attestation source: %q", s)) + } + } + return errors.Join(errs...) +} + +// readGithub returns true when the GitHub attestations API is a selected source. +func (fo *fromOptions) readGithub() bool { + return slices.Contains(fo.from, pushRepoGithub) +} + +// readNotes returns true when git notes are a selected source. +func (fo *fromOptions) readNotes() bool { + return slices.Contains(fo.from, pushRepoNote) +} diff --git a/internal/cmd/get.go b/internal/cmd/get.go new file mode 100644 index 00000000..d879c2aa --- /dev/null +++ b/internal/cmd/get.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2026 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/slsa-framework/source-tool/pkg/sourcetool" +) + +type getOptions struct { + revisionOpts + fromOptions + provenance bool + vsa bool + requireVerified bool +} + +func (o *getOptions) Validate() error { + errs := []error{o.revisionOpts.Validate(), o.fromOptions.Validate()} + if !o.provenance && !o.vsa { + errs = append(errs, errors.New("nothing to get: enable --provenance and/or --vsa")) + } + return errors.Join(errs...) +} + +func (o *getOptions) AddFlags(cmd *cobra.Command) { + o.revisionOpts.AddFlags(cmd) + o.fromOptions.AddFlags(cmd) + cmd.PersistentFlags().BoolVar(&o.provenance, "provenance", true, "fetch the source provenance attestation") + cmd.PersistentFlags().BoolVar(&o.vsa, "vsa", true, "fetch the verification summary attestation (VSA)") + cmd.PersistentFlags().BoolVar(&o.requireVerified, "require-verified", false, "exit non-zero when a fetched attestation fails verification") +} + +func addGet(parentCmd *cobra.Command) { + opts := getOptions{} + getCmd := &cobra.Command{ + Use: "get [flags] owner/repo[@ref]", + GroupID: cmdGroupVerification, + Short: "Fetch and print the attestations for a revision", + Long: `Fetch and print the stored attestations for a revision. + +get retrieves the source provenance and the verification summary +attestation (VSA) for a commit or a tag and prints them to stdout. Use +--provenance and --vsa to select which of the two are fetched. + +Every attestation is verified: if verification fails, a message is +written to stderr but the attestation is still printed. Pass +--require-verified to exit non-zero when verification fails.`, + SilenceUsage: true, + SilenceErrors: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + if err := opts.ParseLocator(args[0]); err != nil { + return err + } + } + + if err := opts.repoOptions.Validate(); err != nil { + return err + } + + return opts.EnsureDefaults() + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.Validate(); err != nil { + return fmt.Errorf("validating options: %w", err) + } + + authenticator, err := CheckAuth() + if err != nil { + return err + } + + srctool, err := sourcetool.New( + sourcetool.WithAuthenticator(authenticator), + sourcetool.WithGithubCollector(opts.readGithub()), + sourcetool.WithNotesCollector(opts.readNotes()), + ) + if err != nil { + return fmt.Errorf("creating sourcetool: %w", err) + } + + fetched, err := srctool.GetRevisionAttestations( + cmd.Context(), opts.GetBranch(), opts.GetRevision(), opts.provenance, opts.vsa, + ) + if err != nil { + return fmt.Errorf("fetching attestations: %w", err) + } + + if len(fetched) == 0 { + fmt.Fprintln(os.Stderr, "no attestations found for the revision") + return nil + } + + verificationFailed := false + for _, att := range fetched { + fmt.Printf("%s\n", string(att.Data)) + if att.VerifyErr != nil { + verificationFailed = true + fmt.Fprintf(os.Stderr, "warning: %s attestation failed verification: %v\n", att.PredicateType, att.VerifyErr) + } + } + + if verificationFailed && opts.requireVerified { + return &exitError{code: 2, err: errors.New("one or more attestations failed verification")} + } + + return nil + }, + } + opts.AddFlags(getCmd) + parentCmd.AddCommand(getCmd) +} diff --git a/internal/cmd/options.go b/internal/cmd/options.go index 23a814cc..f844ee8b 100644 --- a/internal/cmd/options.go +++ b/internal/cmd/options.go @@ -225,8 +225,14 @@ func (vo *verifierOptions) Validate() error { } func (vo *verifierOptions) AddFlags(cmd *cobra.Command) { - cmd.PersistentFlags().StringVar(&vo.expectedIssuer, "expected_issuer", "", "The expected issuer of the attestation signer certificate") - cmd.PersistentFlags().StringVar(&vo.expectedSan, "expected_san", "", "The expected SAN string in the attestation signer certificate") + cmd.PersistentFlags().StringVar(&vo.expectedIssuer, "expected-issuer", "", "The expected issuer of the attestation signer certificate") + cmd.PersistentFlags().StringVar(&vo.expectedSan, "expected-san", "", "The expected SAN string in the attestation signer certificate") + + // Hidden snake_case aliases kept for backward compatibility. + cmd.PersistentFlags().StringVar(&vo.expectedIssuer, "expected_issuer", "", "") + cmd.PersistentFlags().MarkHidden("expected_issuer") //nolint:errcheck,gosec + cmd.PersistentFlags().StringVar(&vo.expectedSan, "expected_san", "", "") + cmd.PersistentFlags().MarkHidden("expected_san") //nolint:errcheck,gosec } type tagOptions struct { @@ -302,7 +308,7 @@ func (ro *revisionOpts) ParseLocator(lString string) error { } if components.Branch != "" { - ro.tag = components.Branch + ro.branch = components.Branch } return nil diff --git a/internal/cmd/prov.go b/internal/cmd/prov.go index 0da64ce3..5b211d61 100644 --- a/internal/cmd/prov.go +++ b/internal/cmd/prov.go @@ -38,9 +38,10 @@ func (po *provOptions) AddFlags(cmd *cobra.Command) { func addProv(parentCmd *cobra.Command) { opts := provOptions{} provCmd := &cobra.Command{ - Use: "prov", - GroupID: cmdGroupAssessment, - Short: "Creates provenance for the given commit, but does not check policy.", + Use: "prov", + Hidden: true, + Deprecated: `use "sourcetool attest --vsa=false" instead`, + Short: "Creates provenance for the given commit, but does not check policy.", PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { if err := opts.ParseLocator(args[0]); err != nil { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 57136524..f6c63078 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -18,7 +18,7 @@ var githubToken string // Command group IDs used to organize the subcommands in the help screen. const ( cmdGroupVerification = "verification" - cmdGroupAssessment = "assessment" + cmdGroupAttestation = "attestation" cmdGroupPolicy = "policy" cmdGroupConfiguration = "configuration" ) @@ -50,7 +50,11 @@ controls and much more. `, } - rootCmd.PersistentFlags().StringVar(&githubToken, "github_token", "", "the github token to use for auth") + rootCmd.PersistentFlags().StringVar(&githubToken, "github-token", "", "the github token to use for auth") + + // Hidden snake_case alias kept for backward compatibility. + rootCmd.PersistentFlags().StringVar(&githubToken, "github_token", "", "") + rootCmd.PersistentFlags().MarkHidden("github_token") //nolint:errcheck,gosec // Define command groups for better organization rootCmd.AddGroup( @@ -59,8 +63,8 @@ controls and much more. Title: "Verification Commands:", }, &cobra.Group{ - ID: cmdGroupAssessment, - Title: "Assessment Commands:", + ID: cmdGroupAttestation, + Title: "Attestation Commands:", }, &cobra.Group{ ID: cmdGroupPolicy, @@ -73,24 +77,28 @@ controls and much more. ) // Verification commands - addVerifyCommit(rootCmd) + addVerify(rootCmd) addAudit(rootCmd) - - // Assessment commands + addGet(rootCmd) addStatus(rootCmd) - addCheckLevel(rootCmd) - addCheckLevelProv(rootCmd) - addCheckTag(rootCmd) - addProv(rootCmd) + + // Attestation commands + addAttest(rootCmd) // Policy commands addPolicy(rootCmd) - addCreatePolicy(rootCmd) // Configuration & setup commands addSetup(rootCmd) addAuth(rootCmd) + // Hidden, deprecated commands kept for the phase-out period. + addCheckLevel(rootCmd) + addCheckLevelProv(rootCmd) + addCheckTag(rootCmd) + addProv(rootCmd) + addCreatePolicy(rootCmd) + return rootCmd } diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 9d0cad4e..59d3fadd 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -26,6 +26,7 @@ var ( // statusOptions type statusOptions struct { commitOptions + level bool } // Validate checks the options @@ -39,6 +40,7 @@ func (so *statusOptions) Validate() error { // AddFlags adds the subcommands flags func (so *statusOptions) AddFlags(cmd *cobra.Command) { so.commitOptions.AddFlags(cmd) + cmd.PersistentFlags().BoolVar(&so.level, "level", false, "print only the SLSA source level and exit") } // TODO(puerco): Most of the logic in this subcommand (except maybe the output) @@ -47,7 +49,7 @@ func (so *statusOptions) AddFlags(cmd *cobra.Command) { func addStatus(parentCmd *cobra.Command) { opts := &statusOptions{} statusCmd := &cobra.Command{ - GroupID: cmdGroupAssessment, + GroupID: cmdGroupVerification, Short: "Check the SLSA Source status of a repo/branch", Long: ` sourcetool status: Check the SLSA Source status of a repo/branch @@ -115,9 +117,26 @@ sourcetool status myorg/myrepo@mybranch return fmt.Errorf("fetching active controls: %w", err) } - // Compute the maximum level possible: + // The eligible level is the highest the active controls could + // support, ignoring the repository policy. toplevel := policy.ComputeEligibleSlsaLevel(controls.GetActiveControls()) + // The verified level is what the repository policy actually grants. + // It can be lower than the eligible level when the policy targets a + // lower level or when the policy is not fully met. + pe := policy.NewPolicyEvaluator() + evalResult, err := pe.EvaluateControl(cmd.Context(), opts.GetRepository(), opts.GetBranch(), controls) + if err != nil { + return fmt.Errorf("evaluating controls against policy: %w", err) + } + verifiedLevel := firstSourceLevel(evalResult.VerifiedLevels) + + // --level prints just the policy-verified level and exits, for scripting. + if opts.level { + fmt.Println(verifiedLevel) + return nil + } + title := fmt.Sprintf( "\nSLSA Source Status for %s/%s@%s", opts.owner, opts.repository, ghcontrol.BranchToFullRef(opts.branch), @@ -176,7 +195,8 @@ sourcetool status myorg/myrepo@mybranch fmt.Println() } - fmt.Println(w("Current SLSA Source level: " + toplevel)) + fmt.Println(w("Current SLSA Source level: " + verifiedLevel)) + printLevelGap(toplevel, verifiedLevel, evalResult.Shortfall) fmt.Println("") titled := false for _, status := range controls.Controls { @@ -216,3 +236,33 @@ sourcetool status myorg/myrepo@mybranch opts.AddFlags(statusCmd) parentCmd.AddCommand(statusCmd) } + +// firstSourceLevel returns the first SLSA source level among the verified +// levels, defaulting to level 0 when none is present. +func firstSourceLevel(levels slsa.SourceVerifiedLevels) string { + for _, level := range levels { + if slsa.IsSlsaSourceLevel(level) { + return string(level) + } + } + return string(slsa.SlsaSourceLevel0) +} + +// printLevelGap explains when the policy-verified level is below the level the +// active controls would otherwise support. +func printLevelGap(eligible slsa.SlsaSourceLevel, verified string, shortfall *policy.PolicyShortfall) { + if verified == string(eligible) { + return + } + if shortfall != nil { + fmt.Printf( + "%s The controls are eligible for %s, but the policy target %s is not met: %s\n", + w2("⚠️ "), eligible, shortfall.TargetLevel, shortfall.Reason, + ) + return + } + fmt.Printf( + "%s The controls are eligible for %s, but the policy only verifies %s. Raise the policy target to claim the higher level.\n", + w2("⚠️ "), eligible, verified, + ) +} diff --git a/internal/cmd/verifycommit.go b/internal/cmd/verifycommit.go index 4645185f..2e6ac3bd 100644 --- a/internal/cmd/verifycommit.go +++ b/internal/cmd/verifycommit.go @@ -16,6 +16,7 @@ type verifyCommitOptions struct { verifierOptions outputOptions revisionOpts + fromOptions } // Ref types reported in the verification results @@ -49,6 +50,7 @@ func (vco *verifyCommitOptions) Validate() error { vco.revisionOpts.Validate(), vco.verifierOptions.Validate(), vco.outputOptions.Validate(), + vco.fromOptions.Validate(), } return errors.Join(errs...) } @@ -58,14 +60,25 @@ func (vco *verifyCommitOptions) AddFlags(cmd *cobra.Command) { vco.commitOptions.AddFlags(cmd) vco.verifierOptions.AddFlags(cmd) vco.outputOptions.AddFlags(cmd) + vco.fromOptions.AddFlags(cmd) } -func addVerifyCommit(cmd *cobra.Command) { +// addVerify registers the verify command along with its deprecated +// "verifycommit" alias, which is kept hidden and functional during the +// phase-out period. +func addVerify(cmd *cobra.Command) { + cmd.AddCommand(newVerifyCommand("verify", false, "")) + cmd.AddCommand(newVerifyCommand("verifycommit", true, `use "sourcetool verify" instead`)) +} + +func newVerifyCommand(use string, hidden bool, deprecated string) *cobra.Command { opts := verifyCommitOptions{} verifyCommitCmd := &cobra.Command{ - Use: "verifycommit", - GroupID: cmdGroupVerification, - Short: "Verifies the specified commit is valid", + Use: use, + GroupID: cmdGroupVerification, + Hidden: hidden, + Deprecated: deprecated, + Short: "Verifies the specified commit is valid", PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { if err := opts.ParseLocator(args[0]); err != nil { @@ -98,20 +111,25 @@ func addVerifyCommit(cmd *cobra.Command) { srctool, err := sourcetool.New( sourcetool.WithAuthenticator(authenticator), sourcetool.WithExpectedIdentity(opts.expectedIssuer, opts.expectedSan), + sourcetool.WithGithubCollector(opts.readGithub()), + sourcetool.WithNotesCollector(opts.readNotes()), ) if err != nil { return err } + // A tag is the more specific selector, so check it first: the + // branch is populated with the repository default when the user + // does not pass one, which would otherwise mask a --tag request. var refType string var refName string switch { - case opts.branch != "": - refType = refTypeBranch - refName = opts.branch case opts.tag != "": refType = refTypeTag refName = opts.tag + case opts.branch != "": + refType = refTypeBranch + refName = opts.branch default: return fmt.Errorf("must specify either branch or tag") } @@ -142,6 +160,6 @@ func addVerifyCommit(cmd *cobra.Command) { return opts.writeResult(result) }, } - opts.AddFlags(cmd) - cmd.AddCommand(verifyCommitCmd) + opts.AddFlags(verifyCommitCmd) + return verifyCommitCmd } diff --git a/pkg/attest/provenance.go b/pkg/attest/provenance.go index 88464c2a..f7ef4cba 100644 --- a/pkg/attest/provenance.go +++ b/pkg/attest/provenance.go @@ -393,3 +393,66 @@ func (a *Attester) CreateTagProvenance(ctx context.Context, branch *models.Branc return addPredToStatement(&curProvPred, provenance.TagProvPredicateType, tag.Commit.SHA) } + +// FetchedEnvelope carries an attestation fetched for a revision together with +// its predicate type, its serialized predicate data, and the result of +// verifying its signature and signer identity. +type FetchedEnvelope struct { + PredicateType string + Data []byte + VerifyErr error +} + +// Predicate type sets used when fetching attestations for a revision. +var ( + ProvenancePredicateTypes = []attestation.PredicateType{ + attestation.PredicateType(provenance.SourceProvPredicateType), + attestation.PredicateType(provenance.TagProvPredicateType), + } + VSAPredicateTypes = []attestation.PredicateType{ + attestation.PredicateType(VsaPredicateType), + } +) + +// FetchRevisionAttestations returns the attestations stored for a commit that +// match any of the given predicate types. Unlike GetRevisionVSA and +// GetRevisionProvenance it does not discard envelopes that fail verification. +// Each returned entry carries the result of verifying it so callers can decide +// what to do with an attestation that fails. +func (a *Attester) FetchRevisionAttestations(ctx context.Context, branch *models.Branch, commit *models.Commit, predicateTypes ...attestation.PredicateType) ([]FetchedEnvelope, error) { + if commit == nil { + return nil, errors.New("commit is nil") + } + c, err := a.getCollector(branch) + if err != nil { + return nil, fmt.Errorf("unable to get collector: %w", err) + } + + types := map[attestation.PredicateType]struct{}{} + for _, pt := range predicateTypes { + types[pt] = struct{}{} + } + matcher := &filters.PredicateTypeMatcher{PredicateTypes: types} + + atts, err := c.FetchAttestationsBySubject( + ctx, []attestation.Subject{commit.ToResourceDescriptor()}, + collector.WithQuery(attestation.NewQuery().WithFilter(matcher)), + ) + if err != nil { + return nil, fmt.Errorf("fetching attestations: %w", err) + } + + result := make([]FetchedEnvelope, 0, len(atts)) + for _, att := range atts { + pred := att.GetPredicate() + if pred == nil { + continue + } + result = append(result, FetchedEnvelope{ + PredicateType: string(pred.GetType()), + Data: pred.GetData(), + VerifyErr: a.verifier.VerifyEnvelope(att), + }) + } + return result, nil +} diff --git a/pkg/sourcetool/implementation.go b/pkg/sourcetool/implementation.go index 89acec56..3b04d8a9 100644 --- a/pkg/sourcetool/implementation.go +++ b/pkg/sourcetool/implementation.go @@ -267,7 +267,7 @@ func (impl *defaultToolImplementation) GetPolicyStatus( Message: fmt.Sprintf("Repository policy not found for %s", r.Path), RecommendedAction: &slsa.ControlRecommendedAction{ Message: "Create a policy for the repository", - Command: fmt.Sprintf("buildtool policy create %s", r.Path), + Command: fmt.Sprintf("sourcetool policy create %s", r.Path), }, }, nil } diff --git a/pkg/sourcetool/tool.go b/pkg/sourcetool/tool.go index aace6e71..cada8f7d 100644 --- a/pkg/sourcetool/tool.go +++ b/pkg/sourcetool/tool.go @@ -351,10 +351,30 @@ type AttestOptions struct { OutputPath string UseStdOut bool Push bool + + // Provenance and VSA select which attestations are written to the output. + // VSA also gates policy evaluation: when false, the policy is not consulted + // and only the provenance is produced. + Provenance bool + VSA bool } type AttOpFn func(*AttestOptions) error +func WithProvenance(s bool) AttOpFn { + return func(ao *AttestOptions) error { + ao.Provenance = s + return nil + } +} + +func WithVSA(s bool) AttOpFn { + return func(ao *AttestOptions) error { + ao.VSA = s + return nil + } +} + func WithLocalPolicy(p string) AttOpFn { return func(ao *AttestOptions) error { ao.LocalPolicy = p @@ -391,8 +411,10 @@ func WithPush(s bool) AttOpFn { } var defaultAttestOptions = AttestOptions{ - Sign: true, - UseStdOut: true, + Sign: true, + UseStdOut: true, + Provenance: true, + VSA: true, } // AttestationResult is the outcome of attesting a revision. @@ -428,6 +450,11 @@ func (t *Tool) AttestRevision( } } + // At least one attestation kind must be selected. + if !opts.Provenance && !opts.VSA { + return nil, errors.New("nothing to generate: enable provenance and/or the VSA") + } + var vsaData string var provenanceData []byte var verifiedLevels slsa.SourceVerifiedLevels @@ -438,32 +465,36 @@ func (t *Tool) AttestRevision( tag, isTag := rev.(*models.Tag) if isCommit { - // 1. Create the provenance attestation + // Create the provenance attestation. It is always built: it is emitted + // when requested and it is the basis for the policy evaluation below. prov, err := t.Attester().CreateSourceProvenance(ctx, branch, rev.GetCommit()) if err != nil { return nil, err } - // 2. Run the provenance against the policy to determine the verified - // levels. Any level below the policy target is reported as a shortfall, not - // an error. We still emit the provenance so the chain is never broken - // just because the policy levels are not met immediately. - pe := policy.NewPolicyEvaluator() - pe.UseLocalPolicy = opts.LocalPolicy - result, err := pe.EvaluateSourceProv(ctx, branch.Repository, branch, prov) - if err != nil { - return nil, fmt.Errorf("evaluating provenance with policy: %w", err) + if opts.Provenance { + provenanceData, err = protojson.Marshal(prov) + if err != nil { + return nil, fmt.Errorf("generating provenance attestation: %w", err) + } } - verifiedLevels, policyPath, shortfall = result.VerifiedLevels, result.PolicyPath, result.Shortfall - provenanceData, err = protojson.Marshal(prov) - if err != nil { - return nil, fmt.Errorf("generating provenance attestation: %w", err) + // The VSA is the only policy-dependent output. Run the provenance + // against the policy only when a VSA is requested. Any level below the + // policy target is reported as a shortfall, not an error. + if opts.VSA { + pe := policy.NewPolicyEvaluator() + pe.UseLocalPolicy = opts.LocalPolicy + result, err := pe.EvaluateSourceProv(ctx, branch.Repository, branch, prov) + if err != nil { + return nil, fmt.Errorf("evaluating provenance with policy: %w", err) + } + verifiedLevels, policyPath, shortfall = result.VerifiedLevels, result.PolicyPath, result.Shortfall } } if isTag { - // 1. Create the provenance attestation + // Create the tag provenance attestation. prov, err := t.Attester().CreateTagProvenance(ctx, branch, tag, "") if err != nil { return nil, err @@ -479,63 +510,79 @@ func (t *Tool) AttestRevision( ) } - // 2. Run the provenance against the policy to determine the verified levels. - pe := policy.NewPolicyEvaluator() - pe.UseLocalPolicy = opts.LocalPolicy - result, err := pe.EvaluateTagProv(ctx, branch.Repository, prov) - if err != nil { - return nil, fmt.Errorf("evaluating provenance with policy: %w", err) + if opts.Provenance { + provenanceData, err = protojson.Marshal(prov) + if err != nil { + return nil, fmt.Errorf("generating tag provenance attestation: %w", err) + } } - verifiedLevels, policyPath, shortfall = result.VerifiedLevels, result.PolicyPath, result.Shortfall - provenanceData, err = protojson.Marshal(prov) + // Run the provenance against the policy only when a VSA is requested. + if opts.VSA { + pe := policy.NewPolicyEvaluator() + pe.UseLocalPolicy = opts.LocalPolicy + result, err := pe.EvaluateTagProv(ctx, branch.Repository, prov) + if err != nil { + return nil, fmt.Errorf("evaluating provenance with policy: %w", err) + } + verifiedLevels, policyPath, shortfall = result.VerifiedLevels, result.PolicyPath, result.Shortfall + } + } + + if opts.VSA { + vsaData, err = attest.CreateUnsignedSourceVsa( + branch, rev.GetCommit(), verifiedLevels, policyPath, + ) if err != nil { - return nil, fmt.Errorf("generating tag provenance attestation: %w", err) + return nil, fmt.Errorf("creating VSA: %w", err) } } - // create vsa - vsaData, err = attest.CreateUnsignedSourceVsa( - branch, rev.GetCommit(), verifiedLevels, policyPath, - ) - if err != nil { - return nil, fmt.Errorf("creating VSA: %w", err) + // Assemble the selected attestations into the output bundle. + var attestations [][]byte + if opts.Provenance { + attestations = append(attestations, provenanceData) + } + if opts.VSA { + attestations = append(attestations, []byte(vsaData)) } if opts.Sign { - provenanceDataString, err := attest.Sign(string(provenanceData)) - if err != nil { - return nil, err + for i, data := range attestations { + signed, err := attest.Sign(string(data)) + if err != nil { + return nil, err + } + attestations[i] = []byte(signed) } - provenanceData = []byte(provenanceDataString) + } - vsaData, err = attest.Sign(vsaData) - if err != nil { - return nil, err - } + // Serialize the bundle as JSONL (one attestation per line). + var bundle []byte + for _, data := range attestations { + bundle = append(bundle, data...) + bundle = append(bundle, '\n') } if opts.UseStdOut { - fmt.Printf("%s\n%s\n", string(provenanceData), vsaData) + fmt.Printf("%s", string(bundle)) } // Write the bundle to disk only when an output path was requested. The push // below works from memory, so there is no longer a need for a temp file. if opts.OutputPath != "" { - if err := os.WriteFile( - opts.OutputPath, fmt.Appendf(nil, "%s\n%s\n", string(provenanceData), vsaData), os.FileMode(0o600), - ); err != nil { + if err := os.WriteFile(opts.OutputPath, bundle, os.FileMode(0o600)); err != nil { return nil, fmt.Errorf("writing attestations: %w", err) } } if opts.Push { // Push the attestations from memory rather than re-reading the bundle - // file: the file holds two records (provenance + VSA) as JSONL, which the - // storer's single-document parser can't ingest. Parse each signed bundle + // file: the bundle holds the records as JSONL, which the storer's + // single-document parser can't ingest. Parse each signed bundle // individually and store the envelopes directly. var envelopes []attestation.Envelope - for _, data := range [][]byte{provenanceData, []byte(vsaData)} { + for _, data := range attestations { parsed, err := envelope.Parsers.Parse(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("parsing attestation to push: %w", err) @@ -554,6 +601,22 @@ func (t *Tool) AttestRevision( }, nil } +// GetRevisionAttestations fetches the stored attestations for a revision. The +// includeProvenance and includeVSA flags select which predicate types are +// requested. The returned envelopes carry their verification result and are not +// filtered by it, so callers can print or inspect attestations that fail to +// verify. +func (t *Tool) GetRevisionAttestations(ctx context.Context, branch *models.Branch, rev models.Revision, includeProvenance, includeVSA bool) ([]attest.FetchedEnvelope, error) { + var types []attestation.PredicateType + if includeProvenance { + types = append(types, attest.ProvenancePredicateTypes...) + } + if includeVSA { + types = append(types, attest.VSAPredicateTypes...) + } + return t.Attester().FetchRevisionAttestations(ctx, branch, rev.GetCommit(), types...) +} + // getAttestationStore returns a collector with storer reposistories to push // the generated attestations. func (t *Tool) getAttestationStore(branch *models.Branch) (*collector.Agent, error) {