From 0c2599654d73b665c0a2c055d678624334d3f0ba Mon Sep 17 00:00:00 2001 From: Faye Date: Tue, 18 Aug 2026 11:24:28 +0200 Subject: [PATCH 1/5] feat(attestation-type): add --summary-json to create attestation-type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend accepts an optional `summary` list at the top level of the create/update endpoint's data_json body — ordered {name, expression} entries whose expressions are jq, evaluated against attestation data. Because it lives in that JSON blob rather than the uploaded schema file, there was no user-facing way to set it. Adds --summary-json, taking the list as JSON so it maps 1:1 to the API. The repeatable --summary "Name=.expr" alternative was rejected: jq expressions contain both `=` and `,`, and cobra's StringToStringVar CSV-splits values and returns an unordered map, where summary is an ordered list. Parsing validates that each entry has a non-empty name and expression, reporting the offending entry by 1-based index. Without that, a typo such as {"name":"X","expr":".y"} unmarshals silently into an empty expression and ships a broken summary to the API, surfacing far from its cause. DisallowUnknownFields would catch that more directly but would hard-reject payloads if the backend later adds an optional field to the entry shape, which it has reserved the right to do. The field uses omitempty, so existing invocations produce a byte- identical request. Verified against the local server that the endpoint replaces rather than merges: re-running without the flag clears an existing summary, so omitempty loses no expressiveness. Refs #1097 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/createAttestationType.go | 60 ++++++++++ cmd/kosli/createAttestationType_test.go | 147 ++++++++++++++++++++++++ cmd/kosli/root.go | 1 + 3 files changed, 208 insertions(+) diff --git a/cmd/kosli/createAttestationType.go b/cmd/kosli/createAttestationType.go index 897aa991e..a8bad3aab 100644 --- a/cmd/kosli/createAttestationType.go +++ b/cmd/kosli/createAttestationType.go @@ -1,9 +1,13 @@ package main import ( + "encoding/json" + "errors" + "fmt" "io" "net/http" "net/url" + "strings" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" @@ -26,6 +30,12 @@ See an example schema file These rules specify acceptable values for attestation data, e.g. ^.age >= 21^ or ^.failing_tests == 0^. When a custom attestation is reported, the provided data is evaluated according to the rules defined in its attestation-type. All rules must return ^true^ for the evaluation to pass and the attestation to be determined compliant. + +^--summary-json^ defines the summary shown for attestations of this type, given as a JSON array of +^{"name": ..., "expression": ...}^ entries. Each expression is a jq expression evaluated against the +attestation data, and entries are displayed in the order given, e.g. +^'[{"name":"Critical","expression":".critical_count"}]'^. +Attestation types created without a summary fall back to the jq evaluation rules checklist. ` const createAttestationTypeExample = ` @@ -38,12 +48,25 @@ kosli create attestation-type customTypeName \ --schema person-schema.json \ --jq ".age >= 18" --jq ".age < 65" + +# create/update a custom attestation type with a summary: +kosli create attestation-type customTypeName \ + --schema scan-schema.json \ + --summary-json '[{"name":"Critical","expression":".critical_count"},{"name":"Tool","expression":".scanner.name"}]' ` type createAttestationTypeOptions struct { payload CreateAttestationTypePayload schemaFilePath string jqRules []string + summaryJSON string +} + +// SummaryEntry is one named jq expression displayed in the summary of +// attestations made using a custom attestation type. +type SummaryEntry struct { + Name string `json:"name"` + Expression string `json:"expression"` } type JQEvaluatorPayload struct { @@ -59,6 +82,36 @@ type CreateAttestationTypePayload struct { TypeName string `json:"name"` Description string `json:"description,omitempty"` Evaluator *JQEvaluatorPayload `json:"evaluator,omitempty"` + Summary []SummaryEntry `json:"summary,omitempty"` +} + +// parseSummaryJSON parses the --summary-json flag value into an ordered list of +// summary entries. The value must be a JSON array of {name, expression} objects, +// both fields non-empty. +func parseSummaryJSON(value string) ([]SummaryEntry, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + var summary []SummaryEntry + if err := json.Unmarshal([]byte(value), &summary); err != nil { + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return nil, fmt.Errorf("--summary-json is not valid JSON: %s", err.Error()) + } + return nil, fmt.Errorf("--summary-json must be a JSON array of {name, expression} entries") + } + + for i, entry := range summary { + if strings.TrimSpace(entry.Name) == "" { + return nil, fmt.Errorf("--summary-json entry %d is missing a name", i+1) + } + if strings.TrimSpace(entry.Expression) == "" { + return nil, fmt.Errorf("--summary-json entry %d is missing an expression", i+1) + } + } + + return summary, nil } func newCreateAttestationTypeCmd(out io.Writer) *cobra.Command { @@ -84,6 +137,7 @@ func newCreateAttestationTypeCmd(out io.Writer) *cobra.Command { cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", attestationTypeDescriptionFlag) cmd.Flags().StringVarP(&o.schemaFilePath, "schema", "s", "", attestationTypeSchemaFlag) cmd.Flags().StringArrayVar(&o.jqRules, "jq", []string{}, attestationTypeJqFlag) + cmd.Flags().StringVar(&o.summaryJSON, "summary-json", "", attestationTypeSummaryJsonFlag) addDryRunFlag(cmd) return cmd @@ -95,6 +149,12 @@ func (o *createAttestationTypeOptions) run(args []string) error { o.payload.Evaluator = NewJQEvaluatorPayload(o.jqRules) } + summary, err := parseSummaryJSON(o.summaryJSON) + if err != nil { + return err + } + o.payload.Summary = summary + form, err := prepareAttestationTypeForm(o.payload, o.schemaFilePath) if err != nil { return err diff --git a/cmd/kosli/createAttestationType_test.go b/cmd/kosli/createAttestationType_test.go index 3f962372b..f2f142026 100644 --- a/cmd/kosli/createAttestationType_test.go +++ b/cmd/kosli/createAttestationType_test.go @@ -1,9 +1,11 @@ package main import ( + "encoding/json" "fmt" "testing" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -57,6 +59,60 @@ func (suite *CreateAttestationTypeTestSuite) TestCustomAttestationTypeCmd() { cmd: `create attestation-type wibble-6 --jq '.name | startswith("B")'` + suite.defaultKosliArguments, golden: "attestation-type wibble-6 was created\n", }, + { + name: "summary json is provided", + cmd: `create attestation-type wibble-7 --summary-json '[{"name":"Critical","expression":".critical_count"}]'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-7 was created\n", + }, + { + name: "summary json expressions can contain commas and equals", + cmd: `create attestation-type wibble-8 --summary-json '[{"name":"Tool","expression":"[.a, .b] | map(select(.x == 1)) | length"}]'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-8 was created\n", + }, + { + name: "empty summary json array is accepted", + cmd: `create attestation-type wibble-9 --summary-json '[]'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-9 was created\n", + }, + { + name: "empty summary json string is accepted", + cmd: `create attestation-type wibble-10 --summary-json ''` + suite.defaultKosliArguments, + golden: "attestation-type wibble-10 was created\n", + }, + { + name: "summary json and jq rules can be combined", + cmd: `create attestation-type wibble-11 --jq '.critical_count == 0' --summary-json '[{"name":"Critical","expression":".critical_count"}]'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-11 was created\n", + }, + { + name: "summary json and schema can be combined", + cmd: `create attestation-type wibble-12 --schema testdata/person-schema.json --summary-json '[{"name":"Age","expression":".age"}]'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-12 was created\n", + }, + { + wantError: true, + name: "fails when summary json is malformed", + cmd: `create attestation-type wibble-bad --summary-json '[{"name":'` + suite.defaultKosliArguments, + golden: "Error: --summary-json is not valid JSON: unexpected end of JSON input\n", + }, + { + wantError: true, + name: "fails when summary json is an object not an array", + cmd: `create attestation-type wibble-bad --summary-json '{"name":"Critical","expression":".critical_count"}'` + suite.defaultKosliArguments, + golden: "Error: --summary-json must be a JSON array of {name, expression} entries\n", + }, + { + wantError: true, + name: "fails when a summary entry has no name", + cmd: `create attestation-type wibble-bad --summary-json '[{"expression":".critical_count"}]'` + suite.defaultKosliArguments, + golden: "Error: --summary-json entry 1 is missing a name\n", + }, + { + wantError: true, + name: "fails when a summary entry has no expression", + cmd: `create attestation-type wibble-bad --summary-json '[{"name":"Critical"},{"name":"Tool","expression":".t"}]'` + suite.defaultKosliArguments, + golden: "Error: --summary-json entry 1 is missing an expression\n", + }, } runTestCmd(suite.T(), tests) @@ -67,3 +123,94 @@ func (suite *CreateAttestationTypeTestSuite) TestCustomAttestationTypeCmd() { func TestCreateAttestationTypeTestSuite(t *testing.T) { suite.Run(t, new(CreateAttestationTypeTestSuite)) } + +func TestParseSummaryJSON(t *testing.T) { + t.Run("omitted flag leaves summary out of the payload", func(t *testing.T) { + payload := CreateAttestationTypePayload{TypeName: "wibble"} + body, err := json.Marshal(payload) + require.NoError(t, err) + require.NotContains(t, string(body), "summary") + }) + + t.Run("empty array leaves summary out of the payload", func(t *testing.T) { + summary, err := parseSummaryJSON("[]") + require.NoError(t, err) + require.Empty(t, summary) + + payload := CreateAttestationTypePayload{TypeName: "wibble", Summary: summary} + body, err := json.Marshal(payload) + require.NoError(t, err) + require.NotContains(t, string(body), "summary") + }) + + t.Run("entries keep their order and round-trip unmangled", func(t *testing.T) { + summary, err := parseSummaryJSON(`[{"name":"Critical","expression":"[.a, .b] | map(select(.x == 1)) | length"},{"name":"Tool","expression":".scanner.name"}]`) + require.NoError(t, err) + require.Equal(t, []SummaryEntry{ + {Name: "Critical", Expression: "[.a, .b] | map(select(.x == 1)) | length"}, + {Name: "Tool", Expression: ".scanner.name"}, + }, summary) + }) + + // An unset flag arrives here as "". Parsing it must stay a no-op rather than + // falling through to json.Unmarshal, which rejects an empty string. + t.Run("blank values are treated as no summary", func(t *testing.T) { + for _, value := range []string{"", " ", "\t\n"} { + summary, err := parseSummaryJSON(value) + require.NoErrorf(t, err, "value %q", value) + require.Emptyf(t, summary, "value %q", value) + } + }) + + t.Run("rejects entries that are not usable", func(t *testing.T) { + for _, tc := range []struct { + name string + value string + wantErr string + }{ + { + name: "whitespace-only name", + value: `[{"name":" ","expression":".x"}]`, + wantErr: "--summary-json entry 1 is missing a name", + }, + { + name: "whitespace-only expression", + value: `[{"name":"Critical","expression":" "}]`, + wantErr: "--summary-json entry 1 is missing an expression", + }, + { + // Proves the reported index tracks the entry's position rather + // than being coincidentally right for a single-entry list. + name: "second entry is the bad one", + value: `[{"name":"A","expression":".a"},{"name":"B"}]`, + wantErr: "--summary-json entry 2 is missing an expression", + }, + { + name: "null entry", + value: `[null]`, + wantErr: "--summary-json entry 1 is missing a name", + }, + { + name: "array of numbers", + value: `[1,2,3]`, + wantErr: "--summary-json must be a JSON array of {name, expression} entries", + }, + { + name: "array of strings", + value: `["a"]`, + wantErr: "--summary-json must be a JSON array of {name, expression} entries", + }, + { + name: "trailing data after the array", + value: `[] trailing`, + wantErr: "--summary-json is not valid JSON: invalid character 't' after top-level value", + }, + } { + t.Run(tc.name, func(t *testing.T) { + summary, err := parseSummaryJSON(tc.value) + require.EqualError(t, err, tc.wantErr) + require.Empty(t, summary) + }) + } + }) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index df48f7e78..c7fc0f210 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -309,6 +309,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeDescriptionFlag = "[optional] The attestation type description." attestationTypeSchemaFlag = "[optional] Path to the attestation type schema in JSON Schema format." attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." + attestationTypeSummaryJsonFlag = "[optional] The attestation type summary, given as a JSON array of {name, expression} entries, e.g. '[{\"name\":\"Critical\",\"expression\":\".critical_count\"}]'." controlNameFlag = "[required] The control name." updateControlNameFlag = "[optional] The new control name." controlDescriptionFlag = "[optional] The control description." From 7543d1498f6afa39a6119dcf58a9a192cfa1b3d7 Mon Sep 17 00:00:00 2001 From: Faye Date: Tue, 18 Aug 2026 11:48:07 +0200 Subject: [PATCH 2/5] feat(attestation-type): show summary in get attestation-type table output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary was write-only in the default view: --summary-json could set it, but `kosli get attestation-type X` printed type_schema and evaluator with no summary branch, so the only way to see what had been set was --output json. Combined with the endpoint's full-replace semantics, a later create without the flag silently cleared the summary with nothing in the table output to show it had gone. Prints entries under a Summary block after Evaluator, one name/expression pair per line, in the order the server returns them. Types created without a summary have a null "summary" in the response, which fails the []interface{} type assertion and prints nothing, so their output is unchanged — the existing golden files still pass untouched. Refs #1097 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/getAttestationType.go | 13 +++++++++++++ cmd/kosli/getAttestationType_test.go | 9 +++++++++ cmd/kosli/testHelpers.go | 9 +++++++++ .../get/get-attestation-type-with-summary.txt | 19 +++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 cmd/kosli/testdata/output/get/get-attestation-type-with-summary.txt diff --git a/cmd/kosli/getAttestationType.go b/cmd/kosli/getAttestationType.go index 93f4ff74b..fbac38279 100644 --- a/cmd/kosli/getAttestationType.go +++ b/cmd/kosli/getAttestationType.go @@ -190,5 +190,18 @@ func printVersionedAttestationTypeAsTable(raw map[string]interface{}, rows []str } } + // Types created without a summary have a null "summary", which fails this + // type assertion and prints nothing, leaving their output unchanged. + if summary, ok := attestationType["summary"].([]interface{}); ok && len(summary) > 0 { + rows = append(rows, " Summary:\t") + for _, entry := range summary { + entryMap, ok := entry.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, fmt.Sprintf(" %s:\t%s", entryMap["name"], entryMap["expression"])) + } + } + return rows, nil } diff --git a/cmd/kosli/getAttestationType_test.go b/cmd/kosli/getAttestationType_test.go index 85b434a0c..d9b5e2a21 100644 --- a/cmd/kosli/getAttestationType_test.go +++ b/cmd/kosli/getAttestationType_test.go @@ -14,12 +14,14 @@ type GetAttestationTypeCommandTestSuite struct { suite.Suite attestationTypeName string archivedTypeName string + summaryTypeName string defaultKosliArguments string } func (suite *GetAttestationTypeCommandTestSuite) SetupTest() { suite.attestationTypeName = "custom-attestation-type-1" suite.archivedTypeName = "archived-type" + suite.summaryTypeName = "summary-type" global = &GlobalOpts{ ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", Org: "docs-cmd-test-user-shared", @@ -30,6 +32,8 @@ func (suite *GetAttestationTypeCommandTestSuite) SetupTest() { CreateCustomAttestationType(suite.attestationTypeName, "testdata/person-schema.json", []string{".age > 21"}, suite.T()) CreateCustomAttestationType(suite.archivedTypeName, "testdata/person-schema.json", []string{".age < 21"}, suite.T()) ArchiveCustomAttestationType(suite.archivedTypeName, suite.T()) + CreateCustomAttestationTypeWithSummary(suite.summaryTypeName, "testdata/person-schema.json", []string{".age > 21"}, + `[{"name":"Age","expression":".age"},{"name":"Name","expression":".name"}]`, suite.T()) } func (suite *GetAttestationTypeCommandTestSuite) TestGetAttestationTypeCmd() { @@ -73,6 +77,11 @@ func (suite *GetAttestationTypeCommandTestSuite) TestGetAttestationTypeCmd() { cmd: fmt.Sprintf(`get attestation-type %s@v1 %s`, suite.attestationTypeName, suite.defaultKosliArguments), goldenFile: "output/get/get-attestation-type-version.txt", }, + { + name: "summary entries are shown in the table output", + cmd: fmt.Sprintf(`get attestation-type %s %s`, suite.summaryTypeName, suite.defaultKosliArguments), + goldenFile: "output/get/get-attestation-type-with-summary.txt", + }, { name: "getting an existing attestation type with --output json works", cmd: fmt.Sprintf(`get attestation-type %s --output json %s`, suite.attestationTypeName, suite.defaultKosliArguments), diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 73caf4c1c..8da6f50a1 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -336,6 +336,14 @@ func ArchiveCustomAttestationType(typeName string, t *testing.T) { } func CreateCustomAttestationType(typeName, schemaFilePath string, jqEvaluators []string, t *testing.T) { + t.Helper() + CreateCustomAttestationTypeWithSummary(typeName, schemaFilePath, jqEvaluators, "", t) +} + +// CreateCustomAttestationTypeWithSummary creates an attestation type whose summary +// is set from summaryJSON, in the same form the --summary-json flag takes. An empty +// summaryJSON creates a type without a summary. +func CreateCustomAttestationTypeWithSummary(typeName, schemaFilePath string, jqEvaluators []string, summaryJSON string, t *testing.T) { t.Helper() o := &createAttestationTypeOptions{ payload: CreateAttestationTypePayload{ @@ -343,6 +351,7 @@ func CreateCustomAttestationType(typeName, schemaFilePath string, jqEvaluators [ }, schemaFilePath: schemaFilePath, jqRules: jqEvaluators, + summaryJSON: summaryJSON, } err := o.run([]string{typeName}) require.NoError(t, err, "attestation type should be created without error") diff --git a/cmd/kosli/testdata/output/get/get-attestation-type-with-summary.txt b/cmd/kosli/testdata/output/get/get-attestation-type-with-summary.txt new file mode 100644 index 000000000..fdc34df76 --- /dev/null +++ b/cmd/kosli/testdata/output/get/get-attestation-type-with-summary.txt @@ -0,0 +1,19 @@ +Name: summary-type +Organization: docs-cmd-test-user-shared +Archived: false +Created By: docs-cmd-test-user +Created at: .* +Last modified at: .* +Versions: + Version: 1 + Timestamp: .* + Created By: docs-cmd-test-user + Type schema: \{"additionalProperties":true,"properties":\{"age":\{"type":"integer"\},"name":\{"type":"string"\}\},"type":"object"\} + Evaluator: + Content Type: jq + Rules: + .age > 21 + Summary: + Age: .age + Name: .name + \ No newline at end of file From 20e472dc09121c6a38b7d4cae864abbe1005f6f0 Mon Sep 17 00:00:00 2001 From: Faye Date: Tue, 18 Aug 2026 12:02:04 +0200 Subject: [PATCH 3/5] fix(deps): bump go directive to 1.26.6 for stdlib security fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snyk Dependency Test fails on every branch, flagging seven vulnerabilities in the Go standard library at 1.26.5: High DoS in std/crypto/tls CVE-2026-56862 High DoS in std/net/http CVE-2026-56858 High DoS in std/net/url CVE-2026-56859 High Uncaught Exception in std/net CVE-2026-56860 High Uncontrolled Recursion in std/encoding/asn1 High Uncontrolled Recursion in std/encoding/xml CVE-2026-56853 Medium XSS in std/html/template Snyk derives the stdlib version from the `go` directive, so go.mod was the only place needing a change — .go-version and the Dockerfile's GO_VERSION arg both float on 1.26 and pick up the patch release on their own. Verified locally: `snyk test --policy-path=.snyk` reports 7 issues and 940 vulnerable paths on 1.26.5, and no vulnerable paths on 1.26.6. Full suite and lint pass on the new toolchain. Not caused by this branch — the unrelated empty-flag-audit branch fails the same check. Included here to unblock CI; can be split into its own PR if preferred. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e43d03c81..c8ca06b42 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kosli-dev/cli -go 1.26.5 +go 1.26.6 require ( cloud.google.com/go/run v1.22.0 From 5ccf5726f687339e0246939077d43ed836b0914f Mon Sep 17 00:00:00 2001 From: Faye Date: Wed, 19 Aug 2026 13:37:24 +0200 Subject: [PATCH 4/5] feat(attestation-type): add repeatable --summary to create attestation-type Completes the CLI support asked for in #1097. --summary-json (added in 0c259965) maps 1:1 to the API but is awkward to type by hand, so add the ergonomic shorthand from the issue's proposal: --summary "Critical=.critical_count" --summary "Tool=.scanner.name" Each value is split on its FIRST "=" only, so jq expressions containing "==" or assignments keep their meaning, and whitespace around both halves is trimmed. Registered as StringArrayVar rather than StringToStringVar: the latter CSV-splits values (mangling jq expressions containing commas) and returns an unordered map, where summary is an ordered list. The flip side of that rule is that a bare jq expression using "==" carries its own separator, so a forgotten name in --summary '.failing == 0' would quietly parse to {".failing", "= 0"}. No valid jq expression starts with "=" (it is only ever infix), so reject a leading one rather than let the server answer with a raw jq syntax error. --summary and --summary-json set the same payload field, so they are mutually exclusive via MuXRequiredFlags rather than letting one silently win. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/createAttestationType.go | 80 ++++++++++-- cmd/kosli/createAttestationType_test.go | 156 ++++++++++++++++++++++++ cmd/kosli/root.go | 3 +- 3 files changed, 229 insertions(+), 10 deletions(-) diff --git a/cmd/kosli/createAttestationType.go b/cmd/kosli/createAttestationType.go index a8bad3aab..e91357cb1 100644 --- a/cmd/kosli/createAttestationType.go +++ b/cmd/kosli/createAttestationType.go @@ -31,10 +31,16 @@ These rules specify acceptable values for attestation data, e.g. ^.age >= 21^ or When a custom attestation is reported, the provided data is evaluated according to the rules defined in its attestation-type. All rules must return ^true^ for the evaluation to pass and the attestation to be determined compliant. -^--summary-json^ defines the summary shown for attestations of this type, given as a JSON array of -^{"name": ..., "expression": ...}^ entries. Each expression is a jq expression evaluated against the -attestation data, and entries are displayed in the order given, e.g. -^'[{"name":"Critical","expression":".critical_count"}]'^. +^--summary^ defines one entry of the summary shown for attestations of this type, given as +^'NAME=EXPRESSION'^ where the expression is a jq expression evaluated against the attestation data. +The flag can be repeated to add further entries, which are displayed in the order given, e.g. +^--summary "Critical=.critical_count" --summary "Tool=.scanner.name"^. +Each value is split on its first ^=^ only, so jq expressions containing ^==^ are unaffected. + +^--summary-json^ is an alternative to ^--summary^ for summaries that are easier to express as JSON, +given as a JSON array of ^{"name": ..., "expression": ...}^ entries, e.g. +^'[{"name":"Critical","expression":".critical_count"}]'^. The two summary flags cannot be combined. + Attestation types created without a summary fall back to the jq evaluation rules checklist. ` @@ -50,16 +56,23 @@ kosli create attestation-type customTypeName \ --jq ".age < 65" # create/update a custom attestation type with a summary: +kosli create attestation-type customTypeName \ + --schema scan-schema.json \ + --summary "Critical=.critical_count" \ + --summary "Tool=.scanner.name" + +# create/update a custom attestation type with a summary given as JSON: kosli create attestation-type customTypeName \ --schema scan-schema.json \ --summary-json '[{"name":"Critical","expression":".critical_count"},{"name":"Tool","expression":".scanner.name"}]' ` type createAttestationTypeOptions struct { - payload CreateAttestationTypePayload - schemaFilePath string - jqRules []string - summaryJSON string + payload CreateAttestationTypePayload + schemaFilePath string + jqRules []string + summaryJSON string + summaryKeyValue []string } // SummaryEntry is one named jq expression displayed in the summary of @@ -114,6 +127,43 @@ func parseSummaryJSON(value string) ([]SummaryEntry, error) { return summary, nil } +// parseSummaryFlags parses repeated --summary NAME=EXPRESSION values into an +// ordered list of summary entries, preserving the order the flags were given in. +// Each value is split on its first "=" only, so JQ expressions containing "==" +// or assignments keep their meaning; whitespace around both halves is trimmed. +func parseSummaryFlags(values []string) ([]SummaryEntry, error) { + if len(values) == 0 { + return nil, nil + } + + summary := make([]SummaryEntry, 0, len(values)) + for i, value := range values { + name, expression, found := strings.Cut(value, "=") + if !found { + return nil, fmt.Errorf("--summary entry %d must be in the form NAME=EXPRESSION", i+1) + } + + name = strings.TrimSpace(name) + expression = strings.TrimSpace(expression) + if name == "" { + return nil, fmt.Errorf("--summary entry %d is missing a name", i+1) + } + if expression == "" { + return nil, fmt.Errorf("--summary entry %d is missing an expression", i+1) + } + // Catches a bare jq expression passed without a name: ".failing == 0" + // splits to {".failing", "= 0"}. No valid jq expression starts with "=", + // so a leading one always means the value was not NAME=EXPRESSION. + if strings.HasPrefix(expression, "=") { + return nil, fmt.Errorf("--summary entry %d expression cannot start with '='", i+1) + } + + summary = append(summary, SummaryEntry{Name: name, Expression: expression}) + } + + return summary, nil +} + func newCreateAttestationTypeCmd(out io.Writer) *cobra.Command { o := new(createAttestationTypeOptions) cmd := &cobra.Command{ @@ -127,7 +177,10 @@ func newCreateAttestationTypeCmd(out io.Writer) *cobra.Command { if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - return nil + + // Both flags set the same payload field, so letting one silently win + // would be a trap. + return MuXRequiredFlags(cmd, []string{"summary", "summary-json"}, false) }, RunE: func(cmd *cobra.Command, args []string) error { return o.run(args) @@ -137,6 +190,7 @@ func newCreateAttestationTypeCmd(out io.Writer) *cobra.Command { cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", attestationTypeDescriptionFlag) cmd.Flags().StringVarP(&o.schemaFilePath, "schema", "s", "", attestationTypeSchemaFlag) cmd.Flags().StringArrayVar(&o.jqRules, "jq", []string{}, attestationTypeJqFlag) + cmd.Flags().StringArrayVar(&o.summaryKeyValue, "summary", []string{}, attestationTypeSummaryFlag) cmd.Flags().StringVar(&o.summaryJSON, "summary-json", "", attestationTypeSummaryJsonFlag) addDryRunFlag(cmd) @@ -153,6 +207,14 @@ func (o *createAttestationTypeOptions) run(args []string) error { if err != nil { return err } + if summary == nil { + // --summary and --summary-json are mutually exclusive, so at most one of + // these two parses can yield entries. + summary, err = parseSummaryFlags(o.summaryKeyValue) + if err != nil { + return err + } + } o.payload.Summary = summary form, err := prepareAttestationTypeForm(o.payload, o.schemaFilePath) diff --git a/cmd/kosli/createAttestationType_test.go b/cmd/kosli/createAttestationType_test.go index f2f142026..f09b0d546 100644 --- a/cmd/kosli/createAttestationType_test.go +++ b/cmd/kosli/createAttestationType_test.go @@ -113,6 +113,56 @@ func (suite *CreateAttestationTypeTestSuite) TestCustomAttestationTypeCmd() { cmd: `create attestation-type wibble-bad --summary-json '[{"name":"Critical"},{"name":"Tool","expression":".t"}]'` + suite.defaultKosliArguments, golden: "Error: --summary-json entry 1 is missing an expression\n", }, + { + name: "repeatable summary is provided", + cmd: `create attestation-type wibble-13 --summary 'Critical=.critical_count' --summary 'Tool=.scanner.name'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-13 was created\n", + }, + { + name: "repeatable summary expressions can contain commas and equals", + cmd: `create attestation-type wibble-14 --summary 'Tool=[.a, .b] | map(select(.x == 1)) | length'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-14 was created\n", + }, + { + name: "repeatable summary and jq rules can be combined", + cmd: `create attestation-type wibble-15 --jq '.critical_count == 0' --summary 'Critical=.critical_count'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-15 was created\n", + }, + { + name: "repeatable summary and schema can be combined", + cmd: `create attestation-type wibble-16 --schema testdata/person-schema.json --summary 'Age=.age'` + suite.defaultKosliArguments, + golden: "attestation-type wibble-16 was created\n", + }, + { + wantError: true, + name: "fails when a repeatable summary entry has no equals sign", + cmd: `create attestation-type wibble-bad --summary 'Critical'` + suite.defaultKosliArguments, + golden: "Error: --summary entry 1 must be in the form NAME=EXPRESSION\n", + }, + { + wantError: true, + name: "fails when a repeatable summary entry has no name", + cmd: `create attestation-type wibble-bad --summary '=.critical_count'` + suite.defaultKosliArguments, + golden: "Error: --summary entry 1 is missing a name\n", + }, + { + wantError: true, + name: "fails when a repeatable summary entry has no expression", + cmd: `create attestation-type wibble-bad --summary 'Critical='` + suite.defaultKosliArguments, + golden: "Error: --summary entry 1 is missing an expression\n", + }, + { + wantError: true, + name: "fails when a repeatable summary value is a bare expression", + cmd: `create attestation-type wibble-bad --summary '.failing == 0'` + suite.defaultKosliArguments, + golden: "Error: --summary entry 1 expression cannot start with '='\n", + }, + { + wantError: true, + name: "fails when both summary flags are provided", + cmd: `create attestation-type wibble-bad --summary 'Critical=.critical_count' --summary-json '[{"name":"Critical","expression":".critical_count"}]'` + suite.defaultKosliArguments, + golden: "Error: only one of --summary, --summary-json is allowed\n", + }, } runTestCmd(suite.T(), tests) @@ -214,3 +264,109 @@ func TestParseSummaryJSON(t *testing.T) { } }) } + +func TestParseSummaryFlags(t *testing.T) { + t.Run("no flags leaves summary out of the payload", func(t *testing.T) { + summary, err := parseSummaryFlags(nil) + require.NoError(t, err) + require.Empty(t, summary) + + payload := CreateAttestationTypePayload{TypeName: "wibble", Summary: summary} + body, err := json.Marshal(payload) + require.NoError(t, err) + require.NotContains(t, string(body), "summary") + }) + + t.Run("entries keep the order the flags were given in", func(t *testing.T) { + summary, err := parseSummaryFlags([]string{"Critical=.critical_count", "Tool=.scanner.name"}) + require.NoError(t, err) + require.Equal(t, []SummaryEntry{ + {Name: "Critical", Expression: ".critical_count"}, + {Name: "Tool", Expression: ".scanner.name"}, + }, summary) + }) + + // The whole reason for splitting on the first "=" only: JQ expressions use + // "==" for comparison, and commas are meaningful inside them. Both must + // survive into the expression untouched. + t.Run("splits on the first equals only", func(t *testing.T) { + summary, err := parseSummaryFlags([]string{"Tool=[.a, .b] | map(select(.x == 1)) | length"}) + require.NoError(t, err) + require.Equal(t, []SummaryEntry{ + {Name: "Tool", Expression: "[.a, .b] | map(select(.x == 1)) | length"}, + }, summary) + }) + + t.Run("whitespace around name and expression is trimmed", func(t *testing.T) { + summary, err := parseSummaryFlags([]string{" Critical = .critical_count "}) + require.NoError(t, err) + require.Equal(t, []SummaryEntry{ + {Name: "Critical", Expression: ".critical_count"}, + }, summary) + }) + + t.Run("rejects entries that are not usable", func(t *testing.T) { + for _, tc := range []struct { + name string + values []string + wantErr string + }{ + { + name: "no separator", + values: []string{"Critical"}, + wantErr: "--summary entry 1 must be in the form NAME=EXPRESSION", + }, + { + name: "empty value", + values: []string{""}, + wantErr: "--summary entry 1 must be in the form NAME=EXPRESSION", + }, + { + name: "empty name", + values: []string{"=.critical_count"}, + wantErr: "--summary entry 1 is missing a name", + }, + { + name: "whitespace-only name", + values: []string{" =.critical_count"}, + wantErr: "--summary entry 1 is missing a name", + }, + { + name: "empty expression", + values: []string{"Critical="}, + wantErr: "--summary entry 1 is missing an expression", + }, + { + name: "whitespace-only expression", + values: []string{"Critical= "}, + wantErr: "--summary entry 1 is missing an expression", + }, + { + // A bare jq expression using "==" carries a separator, so it + // would otherwise parse to {".failing", "= 0"}. No valid jq + // expression starts with "=" — it is only ever infix. + name: "bare expression using ==", + values: []string{".failing == 0"}, + wantErr: "--summary entry 1 expression cannot start with '='", + }, + { + name: "doubled separator", + values: []string{"Critical== 0"}, + wantErr: "--summary entry 1 expression cannot start with '='", + }, + { + // Proves the reported index tracks the flag's position rather + // than being coincidentally right for a single entry. + name: "second entry is the bad one", + values: []string{"A=.a", "B"}, + wantErr: "--summary entry 2 must be in the form NAME=EXPRESSION", + }, + } { + t.Run(tc.name, func(t *testing.T) { + summary, err := parseSummaryFlags(tc.values) + require.EqualError(t, err, tc.wantErr) + require.Empty(t, summary) + }) + } + }) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index c7fc0f210..ae448c96e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -309,7 +309,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeDescriptionFlag = "[optional] The attestation type description." attestationTypeSchemaFlag = "[optional] Path to the attestation type schema in JSON Schema format." attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." - attestationTypeSummaryJsonFlag = "[optional] The attestation type summary, given as a JSON array of {name, expression} entries, e.g. '[{\"name\":\"Critical\",\"expression\":\".critical_count\"}]'." + attestationTypeSummaryFlag = "[optional] An attestation type summary entry, given as 'NAME=EXPRESSION'. Can be repeated. Cannot be used with --summary-json." + attestationTypeSummaryJsonFlag = "[optional] The attestation type summary, given as a JSON array of {name, expression} entries, e.g. '[{\"name\":\"Critical\",\"expression\":\".critical_count\"}]'. Cannot be used with --summary." controlNameFlag = "[required] The control name." updateControlNameFlag = "[optional] The new control name." controlDescriptionFlag = "[optional] The control description." From ff30b3a6aab571cc84ca36103e7e052855127f8f Mon Sep 17 00:00:00 2001 From: Faye Date: Wed, 19 Aug 2026 13:49:31 +0200 Subject: [PATCH 5/5] test(attestation-type): pin --summary empty-value and exclusion behaviour Two characterization tests for behaviour that already works but was unpinned, both verified non-vacuous by mutating the production code and confirming only the new test failed. --summary '' errors while --summary-json '' is a no-op. The asymmetry is deliberate (a blank JSON blob reads as "not given", a blank key=value entry as malformed) but it has CI consequences: --summary "$VAR" with VAR unset fails rather than silently producing a type with no summary. Pinned on both sides so it stays a decision rather than drifting by accident. MuXRequiredFlags keys off flag.Changed, so --summary combined with an explicitly empty --summary-json still trips the exclusion despite the latter being a no-op. Consistent with every other MuX use in the repo. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/createAttestationType_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/kosli/createAttestationType_test.go b/cmd/kosli/createAttestationType_test.go index f09b0d546..35f95f498 100644 --- a/cmd/kosli/createAttestationType_test.go +++ b/cmd/kosli/createAttestationType_test.go @@ -75,6 +75,8 @@ func (suite *CreateAttestationTypeTestSuite) TestCustomAttestationTypeCmd() { golden: "attestation-type wibble-9 was created\n", }, { + // Deliberately asymmetric with the empty --summary case below: a blank + // JSON blob reads as "not given", a blank key=value entry as malformed. name: "empty summary json string is accepted", cmd: `create attestation-type wibble-10 --summary-json ''` + suite.defaultKosliArguments, golden: "attestation-type wibble-10 was created\n", @@ -157,12 +159,28 @@ func (suite *CreateAttestationTypeTestSuite) TestCustomAttestationTypeCmd() { cmd: `create attestation-type wibble-bad --summary '.failing == 0'` + suite.defaultKosliArguments, golden: "Error: --summary entry 1 expression cannot start with '='\n", }, + { + // Unlike --summary-json '', which is a no-op. Matters in CI, where + // --summary "$VAR" with VAR unset fails instead of silently no-opping. + wantError: true, + name: "fails when a repeatable summary value is empty", + cmd: `create attestation-type wibble-bad --summary ''` + suite.defaultKosliArguments, + golden: "Error: --summary entry 1 must be in the form NAME=EXPRESSION\n", + }, { wantError: true, name: "fails when both summary flags are provided", cmd: `create attestation-type wibble-bad --summary 'Critical=.critical_count' --summary-json '[{"name":"Critical","expression":".critical_count"}]'` + suite.defaultKosliArguments, golden: "Error: only one of --summary, --summary-json is allowed\n", }, + { + // MuXRequiredFlags keys off flag.Changed, so an explicitly empty + // --summary-json still trips the exclusion despite being a no-op. + wantError: true, + name: "fails when both summary flags are provided and summary json is empty", + cmd: `create attestation-type wibble-bad --summary 'Critical=.critical_count' --summary-json ''` + suite.defaultKosliArguments, + golden: "Error: only one of --summary, --summary-json is allowed\n", + }, } runTestCmd(suite.T(), tests)