From 041aad528e28d9a1c2f8a0cb3c97fea5cf3036d2 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Wed, 29 Apr 2026 15:42:18 +0100 Subject: [PATCH 1/3] refactor: rename internal/snyk to internal/sarif The package parses generic SARIF v2.1.0 (used by Snyk, Checkov, Trivy, Semgrep, etc.), not Snyk-specifically. Rename to reflect the actual scope ahead of adding a first-class `kosli attest sarif` command that will share this parser. No behaviour change: the snyk attestation wire format keeps the `snyk_results` JSON key, and the snyk command continues to invoke the shared parser via its new name (`sarif.ProcessSarifResultFile`). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/attestSnyk.go | 6 ++-- internal/{snyk => sarif}/sarif-code.json | 0 internal/{snyk => sarif}/sarif-container.json | 0 .../sarif-empty-container.json | 0 internal/{snyk => sarif}/sarif-helm.json | 0 internal/{snyk => sarif}/sarif-iac.json | 0 internal/{snyk => sarif}/sarif-os.json | 0 .../{snyk => sarif}/sarif-serverless.json | 0 internal/{snyk/snyk.go => sarif/sarif.go} | 31 ++++++++++--------- .../snyk_test.go => sarif/sarif_test.go} | 12 +++---- internal/{snyk => sarif}/snyk.json | 0 11 files changed, 26 insertions(+), 23 deletions(-) rename internal/{snyk => sarif}/sarif-code.json (100%) rename internal/{snyk => sarif}/sarif-container.json (100%) rename internal/{snyk => sarif}/sarif-empty-container.json (100%) rename internal/{snyk => sarif}/sarif-helm.json (100%) rename internal/{snyk => sarif}/sarif-iac.json (100%) rename internal/{snyk => sarif}/sarif-os.json (100%) rename internal/{snyk => sarif}/sarif-serverless.json (100%) rename internal/{snyk/snyk.go => sarif/sarif.go} (85%) rename internal/{snyk/snyk_test.go => sarif/sarif_test.go} (87%) rename internal/{snyk => sarif}/snyk.json (100%) diff --git a/cmd/kosli/attestSnyk.go b/cmd/kosli/attestSnyk.go index 3a83eaaff..6a3933c33 100644 --- a/cmd/kosli/attestSnyk.go +++ b/cmd/kosli/attestSnyk.go @@ -8,13 +8,13 @@ import ( "os" "github.com/kosli-dev/cli/internal/requests" - "github.com/kosli-dev/cli/internal/snyk" + "github.com/kosli-dev/cli/internal/sarif" "github.com/spf13/cobra" ) type SnykAttestationPayload struct { *CommonAttestationPayload - SnykResults *snyk.SnykData `json:"snyk_results"` + SnykResults *sarif.SarifData `json:"snyk_results"` } type attestSnykOptions struct { @@ -174,7 +174,7 @@ func (o *attestSnykOptions) run(args []string) error { return err } - o.payload.SnykResults, err = snyk.ProcessSnykResultFile(o.snykSarifFilePath) + o.payload.SnykResults, err = sarif.ProcessSarifResultFile(o.snykSarifFilePath) if err != nil { return fmt.Errorf("failed to parse Snyk sarif results file [%s]: %s", o.snykSarifFilePath, err) } diff --git a/internal/snyk/sarif-code.json b/internal/sarif/sarif-code.json similarity index 100% rename from internal/snyk/sarif-code.json rename to internal/sarif/sarif-code.json diff --git a/internal/snyk/sarif-container.json b/internal/sarif/sarif-container.json similarity index 100% rename from internal/snyk/sarif-container.json rename to internal/sarif/sarif-container.json diff --git a/internal/snyk/sarif-empty-container.json b/internal/sarif/sarif-empty-container.json similarity index 100% rename from internal/snyk/sarif-empty-container.json rename to internal/sarif/sarif-empty-container.json diff --git a/internal/snyk/sarif-helm.json b/internal/sarif/sarif-helm.json similarity index 100% rename from internal/snyk/sarif-helm.json rename to internal/sarif/sarif-helm.json diff --git a/internal/snyk/sarif-iac.json b/internal/sarif/sarif-iac.json similarity index 100% rename from internal/snyk/sarif-iac.json rename to internal/sarif/sarif-iac.json diff --git a/internal/snyk/sarif-os.json b/internal/sarif/sarif-os.json similarity index 100% rename from internal/snyk/sarif-os.json rename to internal/sarif/sarif-os.json diff --git a/internal/snyk/sarif-serverless.json b/internal/sarif/sarif-serverless.json similarity index 100% rename from internal/snyk/sarif-serverless.json rename to internal/sarif/sarif-serverless.json diff --git a/internal/snyk/snyk.go b/internal/sarif/sarif.go similarity index 85% rename from internal/snyk/snyk.go rename to internal/sarif/sarif.go index d8be5937c..ef86e53e3 100644 --- a/internal/snyk/snyk.go +++ b/internal/sarif/sarif.go @@ -1,4 +1,4 @@ -package snyk +package sarif import ( "fmt" @@ -8,7 +8,7 @@ import ( "github.com/owenrumney/go-sarif/v2/sarif" ) -type SnykTool struct { +type SarifTool struct { Name string `json:"name"` Version string `json:"version"` } @@ -25,7 +25,7 @@ type Vulnerability struct { PriorityScore float64 `json:"priority_score,omitempty"` } -type SnykResult struct { +type SarifResult struct { HighCount int `json:"high_count"` MediumCount int `json:"medium_count"` LowCount int `json:"low_count"` @@ -34,15 +34,18 @@ type SnykResult struct { Low []Vulnerability `json:"low,omitempty"` } -type SnykData struct { - SchemaVersion int `json:"schema_version"` - Tool SnykTool `json:"tool"` - Results []SnykResult `json:"results"` +type SarifData struct { + SchemaVersion int `json:"schema_version"` + Tool SarifTool `json:"tool"` + Results []SarifResult `json:"results"` } -// ProcessSnykResultFile takes a path to a Snyk scan results file -// and returns a processed SnykData object from it -func ProcessSnykResultFile(file string) (*SnykData, error) { +// ProcessSarifResultFile takes a path to a SARIF scan results file +// and returns a processed SarifData object from it. The parser is +// generic over SARIF v2.1.0 producers (Snyk, Checkov, Trivy, Semgrep, etc.) +// and uses Snyk-specific property fallbacks only when the standard SARIF +// `level` field is absent. +func ProcessSarifResultFile(file string) (*SarifData, error) { report, err := sarif.Open(file) if err != nil { return nil, err @@ -50,10 +53,10 @@ func ProcessSnykResultFile(file string) (*SnykData, error) { if !strings.HasPrefix(report.Schema, "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/") && !strings.HasPrefix(report.Schema, "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-") && !strings.HasPrefix(report.Schema, "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json") { return nil, fmt.Errorf("invalid sarif file") } - data := &SnykData{ + data := &SarifData{ SchemaVersion: 1, - Tool: SnykTool{}, - Results: []SnykResult{}, + Tool: SarifTool{}, + Results: []SarifResult{}, } if len(report.Runs) > 0 { @@ -64,7 +67,7 @@ func ProcessSnykResultFile(file string) (*SnykData, error) { } for _, run := range report.Runs { - result := SnykResult{} + result := SarifResult{} for _, r := range run.Results { level := r.Level vulnerability := createVulnerability(r) diff --git a/internal/snyk/snyk_test.go b/internal/sarif/sarif_test.go similarity index 87% rename from internal/snyk/snyk_test.go rename to internal/sarif/sarif_test.go index 83ca28b0e..8c815998a 100644 --- a/internal/snyk/snyk_test.go +++ b/internal/sarif/sarif_test.go @@ -1,10 +1,10 @@ -package snyk +package sarif import ( "testing" ) -func TestProcessSnykResultFile(t *testing.T) { +func TestProcessSarifResultFile(t *testing.T) { type result struct { high_count int medium_count int @@ -149,14 +149,14 @@ func TestProcessSnykResultFile(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ProcessSnykResultFile(tt.file) + got, err := ProcessSarifResultFile(tt.file) if (err != nil) != tt.wantErr { - t.Errorf("ProcessSnykResultFile() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ProcessSarifResultFile() error = %v, wantErr %v", err, tt.wantErr) return } if tt.want != nil && (tt.want.tool != got.Tool.Name || tt.want.version != got.Tool.Version) { - t.Errorf("ProcessSnykResultFile() failed, want: Tool: %s (got %s) -- Version: %s (got %s)", tt.want.tool, got.Tool.Name, tt.want.version, got.Tool.Version) + t.Errorf("ProcessSarifResultFile() failed, want: Tool: %s (got %s) -- Version: %s (got %s)", tt.want.tool, got.Tool.Name, tt.want.version, got.Tool.Version) } if tt.want != nil && len(tt.want.results) > 0 { @@ -164,7 +164,7 @@ func TestProcessSnykResultFile(t *testing.T) { if wantResult.high_count != got.Results[i].HighCount || wantResult.medium_count != got.Results[i].MediumCount || wantResult.low_count != got.Results[i].LowCount { - t.Errorf("ProcessSnykResultFile() failed for Result [%d], want %v -- got %v", i, wantResult, got.Results[i]) + t.Errorf("ProcessSarifResultFile() failed for Result [%d], want %v -- got %v", i, wantResult, got.Results[i]) } } } diff --git a/internal/snyk/snyk.json b/internal/sarif/snyk.json similarity index 100% rename from internal/snyk/snyk.json rename to internal/sarif/snyk.json From 59a38ff8a272b83c28d347379b4ac3e3a1ec24a5 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Wed, 29 Apr 2026 15:45:38 +0100 Subject: [PATCH 2/3] feat(cli): add `kosli attest sarif` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-class SARIF attestation type, parallel to `attest snyk`, that: - Accepts SARIF v2.1.0 from any compatible scanner (Checkov, Trivy, Semgrep, Snyk, CodeQL, etc.) and reports the parsed findings to Kosli. - Surfaces the tool name and version dynamically from the SARIF report's `runs[0].tool.driver` fields rather than hardcoding a label. - Adds a `-C, --compliant` flag (default true, mirroring `attest generic`) so the caller controls whether the attestation is compliant. The CLI does not derive compliance from the SARIF findings — that decision is the caller's, expressed via the flag (and typically driven by their own policy or rego rules). POSTs to a new server endpoint `api/v2/attestations/{org}/{flow}/trail/{trail}/sarif` which is added in a follow-up commit on the server side. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/attest.go | 1 + cmd/kosli/attestSarif.go | 217 ++++++++++++++++++++++++++++++++++ cmd/kosli/attestSarif_test.go | 152 ++++++++++++++++++++++++ cmd/kosli/root.go | 2 + 4 files changed, 372 insertions(+) create mode 100644 cmd/kosli/attestSarif.go create mode 100644 cmd/kosli/attestSarif_test.go diff --git a/cmd/kosli/attest.go b/cmd/kosli/attest.go index 367832544..8bb59f8c1 100644 --- a/cmd/kosli/attest.go +++ b/cmd/kosli/attest.go @@ -20,6 +20,7 @@ func newAttestCmd(out io.Writer) *cobra.Command { newAttestArtifactCmd(out), newAttestGenericCmd(out), newAttestSnykCmd(out), + newAttestSarifCmd(out), newAttestJunitCmd(out), newAttestJiraCmd(out), newAttestPRCmd(out), diff --git a/cmd/kosli/attestSarif.go b/cmd/kosli/attestSarif.go new file mode 100644 index 000000000..da167f771 --- /dev/null +++ b/cmd/kosli/attestSarif.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/kosli-dev/cli/internal/sarif" + "github.com/spf13/cobra" +) + +type SarifAttestationPayload struct { + *CommonAttestationPayload + SarifResults *sarif.SarifData `json:"sarif_results"` + Compliant bool `json:"is_compliant"` +} + +type attestSarifOptions struct { + *CommonAttestationOptions + sarifFilePath string + uploadResultsFile bool + payload SarifAttestationPayload +} + +const attestSarifShortDesc = `Report a SARIF attestation to an artifact or a trail in a Kosli flow. ` + +const attestSarifLongDesc = attestSarifShortDesc + ` +Accepts SARIF v2.1.0 scan results from any compatible tool (e.g. Checkov, Trivy, Semgrep, Snyk, CodeQL). +The tool name and version are taken from the SARIF report's runs[0].tool.driver fields and shown in +the Kosli UI alongside the parsed findings. + +The ^--scan-results^ .json file is analyzed and a summary of the scan results is reported to Kosli. + +By default, the ^--scan-results^ .json file is also uploaded to Kosli's evidence vault. +You can disable that by setting ^--upload-results=false^. + +Compliance is determined by the ^--compliant^ flag (default true). The CLI does not derive +compliance from the SARIF findings — the caller decides whether the scan should be treated +as compliant or not (e.g. based on its own policy or rego rules). +` + attestationBindingDesc + ` + +` + commitDescription + +const attestSarifExample = ` +# report a SARIF attestation about a trail (compliant by default): +kosli attest sarif \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourScanSARIFResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a non-compliant SARIF attestation about a trail: +kosli attest sarif \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourScanSARIFResults \ + --compliant=false \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a SARIF attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest sarif yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourScanSARIFResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a SARIF attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest sarif \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourScanSARIFResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a SARIF attestation about an artifact which has not been reported yet in a trail: +kosli attest sarif \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --commit yourArtifactGitCommit \ + --scan-results yourScanSARIFResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a SARIF attestation about a trail without uploading the results file: +kosli attest sarif \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourScanSARIFResults \ + --upload-results=false \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newAttestSarifCmd(out io.Writer) *cobra.Command { + o := &attestSarifOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: SarifAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + } + cmd := &cobra.Command{ + Use: "sarif [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestSarifShortDesc, + Long: attestSarifLongDesc, + Example: attestSarifExample, + PreRunE: func(cmd *cobra.Command, args []string) error { + + err := CustomMaximumNArgs(1, args) + if err != nil { + return err + } + + err = RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) + if err != nil { + return fmt.Errorf("%s for --redact-commit-info", err.Error()) + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.repoURLExplicit = cmd.Flags().Changed("repo-url") + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().StringVarP(&o.sarifFilePath, "scan-results", "R", "", sarifResultsFileFlag) + cmd.Flags().BoolVar(&o.uploadResultsFile, "upload-results", true, uploadSarifResultsFlag) + cmd.Flags().BoolVarP(&o.payload.Compliant, "compliant", "C", true, attestationCompliantFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", "scan-results"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestSarifOptions) run(args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/attestations", global.Org, o.flowName, "trail", o.trailName, "sarif") + if err != nil { + return err + } + + err = o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + o.payload.SarifResults, err = sarif.ProcessSarifResultFile(o.sarifFilePath) + if err != nil { + return fmt.Errorf("failed to parse SARIF results file [%s]: %s", o.sarifFilePath, err) + } + + if o.uploadResultsFile { + o.attachments = append(o.attachments, o.sarifFilePath) + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.attachments) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer func() { + if err := os.Remove(evidencePath); err != nil { + logger.Warn("failed to remove evidence file %s: %v", evidencePath, err) + } + }() + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("sarif attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return wrapAttestationError(err) +} diff --git a/cmd/kosli/attestSarif_test.go b/cmd/kosli/attestSarif_test.go new file mode 100644 index 000000000..9e440f02a --- /dev/null +++ b/cmd/kosli/attestSarif_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestSarifCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestSarifCommandTestSuite) SetupTest() { + suite.flowName = "attest-sarif" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestSarifCommandTestSuite) TestAttestSarifCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest sarif foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2 [foo bar]\n", + }, + { + wantError: true, + name: "fails when missing required flags", + cmd: fmt.Sprintf("attest sarif foo -t file %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"name\", \"scan-results\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest sarif testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD --origin-url https://example.com %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest sarif --name foo --fingerprint xxxx --commit HEAD --origin-url https://example.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest sarif [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf("attest sarif --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 does not exist in trail \"test-123\" of flow \"attest-sarif\" belonging to organization \"docs-cmd-test-user\"\n", + }, + { + wantError: true, + name: "fails when --scan-results is missing", + cmd: fmt.Sprintf("attest sarif testdata/file1 --artifact-type file --name foo --commit HEAD --origin-url https://example.com %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"scan-results\" not set\n", + }, + { + name: "can attest sarif against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf("attest sarif testdata/file1 --artifact-type file --name foo --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf("attest sarif testdata/file1 --artifact-type file --name bar --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against an artifact using --fingerprint", + cmd: fmt.Sprintf("attest sarif --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against a trail", + cmd: fmt.Sprintf("attest sarif --name bar --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against a trail with --compliant=false", + cmd: fmt.Sprintf("attest sarif --name bar --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json --compliant=false %s", suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against a trail with explicit --compliant=true", + cmd: fmt.Sprintf("attest sarif --name bar --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json --compliant=true %s", suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against a trail when name is not found in the trail template", + cmd: fmt.Sprintf("attest sarif --name additional --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest sarif against an artifact created using dot syntax in --name", + cmd: fmt.Sprintf("attest sarif --name cli.foo --commit HEAD --origin-url https://example.com --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "sarif attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest sarif with external-url and external-fingerprint against a trail", + cmd: fmt.Sprintf(`attest sarif --name bar --commit HEAD --origin-url https://example.com + --external-url file=https://example.com/file --external-url other=https://other.com + --external-fingerprint file=7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 + --scan-results testdata/snyk_sarif.json %s`, suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest sarif with annotations against a trail", + cmd: fmt.Sprintf(`attest sarif --name bar --commit HEAD --origin-url https://example.com + --annotate foo=bar --annotate baz=qux + --scan-results testdata/snyk_sarif.json %s`, suite.defaultKosliArguments), + golden: "sarif attestation 'bar' is reported to trail: test-123\n", + }, + { + wantError: true, + name: "fails when annotation is not valid", + cmd: fmt.Sprintf(`attest sarif --name bar --commit HEAD --origin-url https://example.com + --annotate foo.baz=bar + --scan-results testdata/snyk_sarif.json %s`, suite.defaultKosliArguments), + golden: "Error: --annotate flag should be in the format key=value. Invalid key: 'foo.baz'. Key can only contain [A-Za-z0-9_]\n", + }, + { + wantError: true, + name: "fails when --name has invalid dot format", + cmd: fmt.Sprintf("attest sarif --name .foo --scan-results testdata/snyk_sarif.json %s", suite.defaultKosliArguments), + golden: "Error: failed to parse attestation name: invalid attestation name format: .foo\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestSarifCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestSarifCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 01c376817..89b15cbe7 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -219,6 +219,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationCustomDataFileFlag = "The filepath of a json file containing the custom attestation data." 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." + sarifResultsFileFlag = "The path to a SARIF v2.1.0 scan results file (e.g. from Checkov, Trivy, Semgrep, Snyk, CodeQL). By default, the results file will be uploaded to Kosli's evidence vault." + uploadSarifResultsFlag = "[defaulted] Whether to upload the provided SARIF 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/ci-defaults, otherwise defaults to HEAD )." 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." From 22b6a5a087dc23eadb511f75367fd6d4977fa05d Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Thu, 30 Apr 2026 10:12:09 +0100 Subject: [PATCH 3/3] chore(cli): add debug logging around SARIF parse step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a debug line when SARIF parsing starts (with file path) and another once parsing succeeds with the tool name, severity counts, and the caller-supplied --compliant flag value. Useful when troubleshooting CI pipelines where the upload silently looks empty. The error path is unchanged — parse failures still surface via the wrapped error, consistent with `attest snyk`. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/attestSarif.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/kosli/attestSarif.go b/cmd/kosli/attestSarif.go index da167f771..29b10d771 100644 --- a/cmd/kosli/attestSarif.go +++ b/cmd/kosli/attestSarif.go @@ -180,10 +180,16 @@ func (o *attestSarifOptions) run(args []string) error { return err } + logger.Debug("parsing SARIF results file: %s", o.sarifFilePath) o.payload.SarifResults, err = sarif.ProcessSarifResultFile(o.sarifFilePath) if err != nil { return fmt.Errorf("failed to parse SARIF results file [%s]: %s", o.sarifFilePath, err) } + if len(o.payload.SarifResults.Results) > 0 { + r := o.payload.SarifResults.Results[0] + logger.Debug("SARIF parsed: tool=%s findings=%d high, %d medium, %d low (compliant=%t)", + o.payload.SarifResults.Tool.Name, r.HighCount, r.MediumCount, r.LowCount, o.payload.Compliant) + } if o.uploadResultsFile { o.attachments = append(o.attachments, o.sarifFilePath)