Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
}
},
Expand Down
75 changes: 75 additions & 0 deletions internal/schemavalidators/schemavalidators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading