diff --git a/cmd/kosli/attestCustom.go b/cmd/kosli/attestCustom.go index 0391aeff7..f9e442b94 100644 --- a/cmd/kosli/attestCustom.go +++ b/cmd/kosli/attestCustom.go @@ -137,6 +137,7 @@ func newAttestCustomCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go index dcc9a3a2a..376067e5f 100644 --- a/cmd/kosli/attestDecision.go +++ b/cmd/kosli/attestDecision.go @@ -133,6 +133,7 @@ func newAttestDecisionCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestGeneric.go b/cmd/kosli/attestGeneric.go index e5dc6bbf5..d7399dd2e 100644 --- a/cmd/kosli/attestGeneric.go +++ b/cmd/kosli/attestGeneric.go @@ -131,6 +131,7 @@ func newAttestGenericCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index b300c57f7..46a699b59 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -247,6 +247,7 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } @@ -292,6 +293,10 @@ func (o *attestJiraOptions) run(args []string) error { return err } + if o.payload.Commit == nil { + return fmt.Errorf("failed to get commit info, which is required to search for Jira issue keys. Pass --commit and point --repo-root at a repository containing it") + } + gv, err := gitview.New(o.srcRepoRoot) if err != nil { return err diff --git a/cmd/kosli/attestJunit.go b/cmd/kosli/attestJunit.go index a074c622b..4ce50389a 100644 --- a/cmd/kosli/attestJunit.go +++ b/cmd/kosli/attestJunit.go @@ -135,6 +135,7 @@ func newAttestJunitCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestOverride.go b/cmd/kosli/attestOverride.go index 55fea5c7c..393110e7a 100644 --- a/cmd/kosli/attestOverride.go +++ b/cmd/kosli/attestOverride.go @@ -105,6 +105,7 @@ func newAttestOverrideCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestPRAzure.go b/cmd/kosli/attestPRAzure.go index e13de8162..38159bb48 100644 --- a/cmd/kosli/attestPRAzure.go +++ b/cmd/kosli/attestPRAzure.go @@ -148,6 +148,7 @@ func newAttestAzurePRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.retriever = azUtils.NewAzureConfig(azureFlagsValues.Token, azureFlagsValues.OrgUrl, azureFlagsValues.Project, o.repoName) return o.run(args) diff --git a/cmd/kosli/attestPRBitbucket.go b/cmd/kosli/attestPRBitbucket.go index dd79ab993..37d81f1ee 100644 --- a/cmd/kosli/attestPRBitbucket.go +++ b/cmd/kosli/attestPRBitbucket.go @@ -169,6 +169,7 @@ func newAttestBitbucketPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.getRetriever().(*bbUtils.Config).Repository = o.repoName return o.run(args) }, diff --git a/cmd/kosli/attestPRGithub.go b/cmd/kosli/attestPRGithub.go index aae2fac2a..be15ceb9a 100644 --- a/cmd/kosli/attestPRGithub.go +++ b/cmd/kosli/attestPRGithub.go @@ -143,6 +143,7 @@ func newAttestGithubPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.retriever = ghUtils.NewGithubRetrieverFunc(githubFlagsValues.Token, githubFlagsValues.BaseURL, githubFlagsValues.Org, o.repoName, global.Debug) return o.run(args) diff --git a/cmd/kosli/attestPRGitlab.go b/cmd/kosli/attestPRGitlab.go index cbbb1476f..98b7ce6ea 100644 --- a/cmd/kosli/attestPRGitlab.go +++ b/cmd/kosli/attestPRGitlab.go @@ -143,6 +143,7 @@ func newAttestGitlabPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") // GitlabConfig.Repository is the short project name (CI_PROJECT_NAME); // combined with Org (CI_PROJECT_NAMESPACE) it forms the API ProjectID. // This is separate from repo_info.name, which uses the full CI_PROJECT_PATH. diff --git a/cmd/kosli/attestSnyk.go b/cmd/kosli/attestSnyk.go index 6c1bb7afb..85939c323 100644 --- a/cmd/kosli/attestSnyk.go +++ b/cmd/kosli/attestSnyk.go @@ -147,6 +147,7 @@ func newAttestSnykCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestSonar.go b/cmd/kosli/attestSonar.go index 1c354e048..6275b6312 100644 --- a/cmd/kosli/attestSonar.go +++ b/cmd/kosli/attestSonar.go @@ -207,6 +207,7 @@ func newAttestSonarCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestation.go b/cmd/kosli/attestation.go index e0d747156..8e5905e7e 100644 --- a/cmd/kosli/attestation.go +++ b/cmd/kosli/attestation.go @@ -56,6 +56,7 @@ type CommonAttestationOptions struct { repoProvider string repoURLExplicit bool repoNameExplicit bool + commitSHAExplicit bool } func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestationPayload) error { @@ -80,15 +81,10 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation } if o.commitSHA != "" { - gv, err := gitview.New(o.srcRepoRoot) + payload.Commit, err = resolveCommitInfo(o.srcRepoRoot, o.commitSHA, o.commitSHAExplicit, o.redactedCommitInfo) if err != nil { - return fmt.Errorf("failed to get commit info. %s", err) + return err } - commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.commitSHA, false, o.redactedCommitInfo) - if err != nil { - return fmt.Errorf("failed to get commit info. %s", err) - } - payload.Commit = &commitInfo.BasicCommitInfo } payload.GitRepoInfo, err = getGitRepoInfoFromEnvironment() @@ -117,6 +113,25 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation return err } +// resolveCommitInfo returns nil when git cannot supply the commit info and the +// commit was not asked for explicitly, so a CI-defaulted --commit does not fail +// the command in a job with no checked-out repository (#6094). +func resolveCommitInfo(srcRepoRoot, commitSHA string, explicit bool, redactedCommitInfo []string) (*gitview.BasicCommitInfo, error) { + gv, err := gitview.New(srcRepoRoot) + if err == nil { + var commitInfo *gitview.CommitInfo + commitInfo, err = gv.GetCommitInfoFromCommitSHA(commitSHA, false, redactedCommitInfo) + if err == nil { + return &commitInfo.BasicCommitInfo, nil + } + } + if explicit { + return nil, fmt.Errorf("failed to get commit info. %s", err) + } + logger.Warn("attesting without commit info: --commit defaulted to %s from the CI environment, but %s. Point --repo-root at a repository containing that commit to attach it.", commitSHA, err.Error()) + return nil, nil +} + // mergeGitRepoInfo applies flag overrides onto base (which may be nil) and // returns nil if ID, Name, or URL is still empty after merging, so that the // field is omitted from the JSON payload. diff --git a/cmd/kosli/beginTrail.go b/cmd/kosli/beginTrail.go index 8a796b34f..b281c02b4 100644 --- a/cmd/kosli/beginTrail.go +++ b/cmd/kosli/beginTrail.go @@ -50,6 +50,7 @@ type beginTrailOptions struct { repoURL string repoProvider string repoNameExplicit bool + commitSHAExplicit bool } type TrailPayload struct { @@ -85,6 +86,7 @@ func newBeginTrailCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } @@ -128,15 +130,10 @@ func (o *beginTrailOptions) run(args []string) error { } if o.commitSHA != "" { - gv, err := gitview.New(o.srcRepoRoot) + o.payload.Commit, err = resolveCommitInfo(o.srcRepoRoot, o.commitSHA, o.commitSHAExplicit, o.redactedCommitInfo) if err != nil { return err } - commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.commitSHA, false, o.redactedCommitInfo) - if err != nil { - return err - } - o.payload.Commit = &commitInfo.BasicCommitInfo } base, err := getGitRepoInfoFromEnvironment() diff --git a/cmd/kosli/commitInfoResolution_test.go b/cmd/kosli/commitInfoResolution_test.go new file mode 100644 index 000000000..092ff8de9 --- /dev/null +++ b/cmd/kosli/commitInfoResolution_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/suite" +) + +// CommitInfoResolutionTestSuite guards that a --commit which was defaulted from +// the CI environment does not fail the command when git cannot supply its info, +// while an explicitly passed --commit still does. +// +// The production trigger (a CI-defaulted --commit in a job with no checked-out +// repository) cannot be reproduced through the command harness, because +// DefaultValue returns "" whenever KOSLI_TESTS is set. resolveCommitInfo is +// therefore exercised directly, and the command cases below guard only that +// each command assigns commitSHAExplicit. +type CommitInfoResolutionTestSuite struct { + suite.Suite + headHash string + defaultKosliArguments string +} + +func (suite *CommitInfoResolutionTestSuite) SetupTest() { + repo, err := git.PlainOpen("../..") + suite.Require().NoError(err) + head, err := repo.Head() + suite.Require().NoError(err) + suite.headHash = head.Hash().String() + + global = &GlobalOpts{ + ApiToken: "DRY_RUN", + Org: "test-org", + Host: "http://localhost:8001", + DryRun: true, + } + suite.defaultKosliArguments = " --dry-run --host http://localhost:8001 --org test-org --api-token DRY_RUN" +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoWithoutRepository() { + const noRepo = "testdata" + + info, err := resolveCommitInfo(noRepo, suite.headHash, false, []string{}) + suite.Require().NoError(err, "a CI-defaulted commit must not fail when there is no repository") + suite.Nil(info) + + _, err = resolveCommitInfo(noRepo, suite.headHash, true, []string{}) + suite.Require().Error(err, "an explicit --commit must still fail when there is no repository") + suite.Contains(err.Error(), "repository does not exist") +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoWithUnresolvableCommit() { + // A well-formed SHA that is not in this repository, as in a shallow clone. + const absentSHA = "0d4c1e1b7f5c2a9e8b3d6f0a1c4e7b2d5a8f3c60" + + info, err := resolveCommitInfo("../..", absentSHA, false, []string{}) + suite.Require().NoError(err, "a CI-defaulted commit must not fail when it cannot be resolved") + suite.Nil(info) + + _, err = resolveCommitInfo("../..", absentSHA, true, []string{}) + suite.Require().Error(err, "an explicit --commit must still fail when it cannot be resolved") +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoSucceeds() { + info, err := resolveCommitInfo("../..", suite.headHash, false, []string{}) + suite.Require().NoError(err) + suite.Require().NotNil(info) + suite.Equal(suite.headHash, info.Sha1) +} + +func (suite *CommitInfoResolutionTestSuite) TestExplicitCommitWiring() { + tests := []cmdTestCase{ + { + wantError: true, + name: "attest generic: an explicit --commit fails when --repo-root has no repository", + cmd: fmt.Sprintf("attest generic --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --flow f --trail t --commit %s --repo-root testdata%s", suite.headHash, suite.defaultKosliArguments), + goldenRegex: "Error: failed to get commit info\\. .*repository does not exist\n", + }, + { + wantError: true, + name: "begin trail: an explicit --commit fails when --repo-root has no repository", + cmd: fmt.Sprintf("begin trail t --flow f --commit %s --repo-root testdata%s", suite.headHash, suite.defaultKosliArguments), + goldenRegex: "Error: failed to get commit info\\. .*repository does not exist\n", + }, + } + runTestCmd(suite.T(), tests) +} + +// commitRequiredOptions builds the shared attestation options for a command run +// whose --commit came from the CI default and cannot be resolved, which is the +// only way payload.Commit reaches these commands as nil. +func (suite *CommitInfoResolutionTestSuite) commitRequiredOptions() *CommonAttestationOptions { + return &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + attestationNameTemplate: "foo", + flowName: "f", + trailName: "t", + commitSHA: suite.headHash, + srcRepoRoot: "testdata", + commitSHAExplicit: false, + } +} + +func (suite *CommitInfoResolutionTestSuite) TestCommandsNeedingCommitReportIt() { + pr := &attestPROptions{ + CommonAttestationOptions: suite.commitRequiredOptions(), + payload: PRAttestationPayload{CommonAttestationPayload: &CommonAttestationPayload{}}, + } + err := pr.run([]string{}) + suite.Require().Error(err) + suite.Contains(err.Error(), "required to find pull requests") + + jira := &attestJiraOptions{ + CommonAttestationOptions: suite.commitRequiredOptions(), + payload: JiraAttestationPayload{CommonAttestationPayload: &CommonAttestationPayload{}}, + } + err = jira.run([]string{}) + suite.Require().Error(err) + suite.Contains(err.Error(), "required to search for Jira issue keys") +} + +func TestCommitInfoResolutionTestSuite(t *testing.T) { + suite.Run(t, new(CommitInfoResolutionTestSuite)) +} diff --git a/cmd/kosli/pullrequest.go b/cmd/kosli/pullrequest.go index 5f1c329d5..44c4e2d78 100644 --- a/cmd/kosli/pullrequest.go +++ b/cmd/kosli/pullrequest.go @@ -38,6 +38,10 @@ func (o *attestPROptions) run(args []string) error { return err } + if o.payload.Commit == nil { + return fmt.Errorf("failed to get commit info, which is required to find pull requests. Pass --commit and point --repo-root at a repository containing it") + } + label := "" o.payload.GitProvider, label = o.getRetriever().ProviderAndLabel() diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f8efa6e7d..c601516b2 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -258,7 +258,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, intervalFlag = "[optional] Expression to define specified snapshots range." showUnchangedArtifactsFlag = "[defaulted] Show the unchanged artifacts present in both snapshots within the diff output." attestationFingerprintFlag = "[conditional] The SHA256 fingerprint of the artifact to attach the attestation to. Only required if the attestation is for an artifact and --artifact-type and artifact name/path are not used." - attestationCommitFlag = "[conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." + attestationCommitFlag = "[conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd ). When it is defaulted from the CI environment and no git repository is available at --repo-root, the attestation is sent without commit info." attestationRedactCommitInfoFlag = "[optional] The list of commit info to be redacted before sending to Kosli. Allowed values are one or more of [author, message, branch]." attestationOriginUrlFlag = "[optional] The url pointing to where the attestation came from or is related. (defaulted to the CI url in some CIs: https://docs.kosli.com/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables )." attestationNameFlag = "The name of the attestation as declared in the flow or trail yaml template." @@ -269,7 +269,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, uploadJunitResultsFlag = "[defaulted] Whether to upload the provided Junit results directory as an attachment to Kosli or not." uploadSnykResultsFlag = "[defaulted] Whether to upload the provided Snyk results file as an attachment to Kosli or not." attestationAssertFlag = "[optional] Exit with non-zero code if the attestation is non-compliant" - beginTrailCommitFlag = "[defaulted] The git commit from which the trail is begun. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd, otherwise defaults to HEAD )." + beginTrailCommitFlag = "[defaulted] The git commit from which the trail is begun. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd, otherwise unset ). When it is defaulted from the CI environment and no git repository is available at --repo-root, the trail is begun without commit info." attachmentsFlag = "[optional] The comma-separated list of paths of attachments for the reported attestation. Attachments can be files or directories. All attachments are compressed and uploaded to Kosli's evidence vault." externalFingerprintFlag = "[optional] A SHA256 fingerprint of an external attachment represented by --external-url. The format is label=fingerprint (labels cannot contain '.' or '='). This flag can be set multiple times. There must be an external url with a matching label for each external fingerprint." externalURLFlag = "[optional] Add labeled reference URL for an external resource. The format is label=url (labels cannot contain '.' or '='). This flag can be set multiple times. If the resource is a file or dir, you can optionally add its fingerprint via --external-fingerprint" diff --git a/cmd/kosli/testdata/output/docs/mintlify/snyk.md b/cmd/kosli/testdata/output/docs/mintlify/snyk.md index ead3107ad..c55b4c129 100644 --- a/cmd/kosli/testdata/output/docs/mintlify/snyk.md +++ b/cmd/kosli/testdata/output/docs/mintlify/snyk.md @@ -38,7 +38,7 @@ In other CI systems, set them explicitly to capture repository metadata. | `--annotate` | stringToString | [optional] Annotate the attestation with data using key=value. | | `-t`, `--artifact-type` | string | The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '`--fingerprint`' on commands that allow it). | | `--attachments` | strings | [optional] The comma-separated list of paths of attachments for the reported attestation. Attachments can be files or directories. All attachments are compressed and uploaded to Kosli's evidence vault. | -| `-g`, `--commit` | string | [conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: [docs](/integrations/ci_cd) ). | +| `-g`, `--commit` | string | [conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: [docs](/integrations/ci_cd) ). When it is defaulted from the CI environment and no git repository is available at `--repo-root`, the attestation is sent without commit info. | | `--description` | string | [optional] attestation description | | `-D`, `--dry-run` | bool | [optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors. | | `-x`, `--exclude` | strings | [optional] The comma separated list of directories and files to exclude from fingerprinting. Can take glob patterns. Only applicable for `--artifact-type` dir. | diff --git a/internal/testHelpers/testHelpers.go b/internal/testHelpers/testHelpers.go index 534d7e486..fa53bae96 100644 --- a/internal/testHelpers/testHelpers.go +++ b/internal/testHelpers/testHelpers.go @@ -53,10 +53,15 @@ func GithubPRNumber() int { } func CloneGitRepo(url, cloneTo string) (*git.Repository, error) { + // Resolved for the same reason as in InitializeGitRepo below. + resolvedCloneTo, err := filepath.EvalSymlinks(cloneTo) + if err != nil { + return nil, err + } // the repo worktree filesystem. It has to be osfs so that we can give it a path - fs := osfs.New(cloneTo) + fs := osfs.New(resolvedCloneTo) // the filesystem for git database - storerFS := osfs.New(filepath.Join(cloneTo, ".git")) + storerFS := osfs.New(filepath.Join(resolvedCloneTo, ".git")) storer := filesystem.NewStorage(storerFS, cache.NewObjectLRUDefault()) return git.Clone(storer, fs, &git.CloneOptions{URL: url}) }