diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json index ac79ee432..581e11156 100644 --- a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -183,6 +183,101 @@ "anchor_verification": { "type": "string", "description": "Anchor checking mode, e.g. \"relocating\" \u2014 model line numbers may be corrected when the quoted bytes are found elsewhere in the same blob" + }, + "trace_discover": { + "$ref": "#/definitions/trace_discover" + } + } + }, + "trace_discover": { + "type": "object", + "title": "Trace discover outcome", + "description": "Outcome of the Chainloop graph lookup that resolves fingerprint commits without a Chainloop-Trace-Sessions trailer to AI coding sessions. Lets a reader tell 'no AI sessions' from 'the lookup could not run or was refused'. Absent in artifacts produced before the field existed.", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ran", + "disabled", + "cli_not_found" + ], + "description": "ran: the lookup ran and the counters are set. disabled: the scan ran with --trace-discover=false. cli_not_found: no chainloop CLI was found, so the lookup could not run." + }, + "commits": { + "type": "integer", + "minimum": 0, + "description": "Distinct fix and introducing commits looked up: those whose trailer declared no session." + }, + "matched": { + "type": "integer", + "minimum": 0, + "description": "Commits that resolved to at least one AI coding session. Never more than commits." + }, + "not_found": { + "type": "integer", + "minimum": 0, + "description": "Commits with no node in the Chainloop graph, that is commits that are not attested." + }, + "denied": { + "type": "integer", + "minimum": 0, + "description": "Lookup calls the control plane refused (PermissionDenied or Unauthenticated). Non-zero means the graph was not readable, not that it was empty. Counts calls, so it can exceed commits." + }, + "failed": { + "type": "integer", + "minimum": 0, + "description": "Other failed lookup calls: CLI errors, unparseable output, a referenced attestation or session material that cannot be read, or a material without a session id. Counts calls, so it can exceed commits." + } + }, + "if": { + "properties": { + "status": { + "const": "ran" + } + } + }, + "then": { + "required": [ + "commits", + "matched", + "not_found", + "denied", + "failed" + ] + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "commits" + ] + }, + { + "required": [ + "matched" + ] + }, + { + "required": [ + "not_found" + ] + }, + { + "required": [ + "denied" + ] + }, + { + "required": [ + "failed" + ] + } + ] } } }, diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index cca1936a7..5bf0a51d2 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -641,6 +641,81 @@ func TestValidateSecurityContextTriageFields(t *testing.T) { }) } +// TestValidateSecurityContextTraceDiscover covers the optional +// provenance.trace_discover object: the counters are required when the lookup +// ran and forbidden otherwise, and an artifact without the object stays valid. +func TestValidateSecurityContextTraceDiscover(t *testing.T) { + const status = "status" + onlyStatus := func(s string) map[string]any { + return map[string]any{status: s} + } + ran := func(commits, matched, notFound, denied, failed int) map[string]any { + return map[string]any{ + status: "ran", + "commits": commits, + "matched": matched, + "not_found": notFound, + "denied": denied, + "failed": failed, + } + } + // with and without vary a lookup where every call was denied. + with := func(key string, value any) map[string]any { + td := ran(88, 0, 0, 88, 0) + td[key] = value + return td + } + without := func(keys ...string) map[string]any { + td := ran(88, 0, 0, 88, 0) + for _, k := range keys { + delete(td, k) + } + return td + } + + testCases := []struct { + name string + traceDiscover map[string]any // nil leaves the field out + wantErr string + }{ + {name: "absent, as in older artifacts"}, + {name: "ran with every call denied", traceDiscover: ran(88, 0, 0, 88, 0)}, + {name: "ran with matches", traceDiscover: ran(12, 3, 9, 0, 0)}, + {name: "disabled without counters", traceDiscover: onlyStatus("disabled")}, + {name: "cli_not_found without counters", traceDiscover: onlyStatus("cli_not_found")}, + {name: "missing status", traceDiscover: without(status), wantErr: "missing properties"}, + {name: "unknown property", traceDiscover: with("session_ids", []any{"x"}), wantErr: "additionalProperties"}, + {name: "unknown status", traceDiscover: onlyStatus("skipped"), wantErr: "enum"}, + {name: "negative counter", traceDiscover: with("denied", -1), wantErr: "minimum"}, + {name: "ran without counters", traceDiscover: onlyStatus("ran"), wantErr: "missing properties"}, + {name: "ran with some counters", traceDiscover: without("not_found", "failed"), wantErr: "missing properties"}, + {name: "disabled with counters", traceDiscover: with(status, "disabled"), wantErr: "not failed"}, + {name: "cli_not_found with counters", traceDiscover: with(status, "cli_not_found"), wantErr: "not failed"}, + {name: "counter as a string", traceDiscover: with("commits", "3"), wantErr: "expected integer"}, + {name: "counter as a non-integer number", traceDiscover: with("matched", 1.5), wantErr: "expected integer"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, ok := loadJSONFile(t, "./testdata/ai_security_context_valid.json").(map[string]any) + require.True(t, ok, "the fixture must be a JSON object") + + provenance, ok := payload["provenance"].(map[string]any) + require.True(t, ok, "the fixture must carry a provenance object") + if tc.traceDiscover != nil { + provenance["trace_discover"] = tc.traceDiscover + } + + err := schemavalidators.ValidateSecurityContext(payload, "") + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + func TestValidateOpenAPI(t *testing.T) { testCases := []struct { name string