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 691a10862..1186fa12b 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 @@ -673,6 +673,10 @@ "reverted_by": { "$ref": "#/definitions/commit_sha", "description": "The commit that reverted this fix, established by reverse patch-id match against the artifact's known fixes" + }, + "trace_sessions": { + "$ref": "#/definitions/trace_sessions", + "description": "Chainloop Trace session IDs (session_external_id) declared on the FIX commit via the Chainloop-Trace-Sessions git trailer — the AI coding session(s) that produced the fix. Recorded verbatim by the producer (no DB lookup); sorted + deduped." } } }, @@ -838,6 +842,10 @@ "verified": { "type": "boolean", "description": "Proved to be an ancestor of the fix commit \u2014 NOT proved to be the true origin. Git can refute a commit that could not possibly have introduced the flaw; it cannot confirm that an ancestor is where the flaw actually entered. A consumer joining fingerprints on origin must require this." + }, + "trace_sessions": { + "$ref": "#/definitions/trace_sessions", + "description": "Chainloop Trace session IDs (session_external_id) declared on this INTRODUCING commit via the Chainloop-Trace-Sessions git trailer \u2014 the AI coding session(s) that introduced the flaw. Recorded verbatim by the producer (no DB lookup); sorted + deduped." } } }, @@ -909,6 +917,16 @@ "items": { "$ref": "#/definitions/commit_sha" } + }, + "trace_sessions": { + "type": "array", + "description": "Chainloop Trace session IDs (session_external_id) declared on a commit via the Chainloop-Trace-Sessions git trailer, sorted + deduped. No pattern is enforced: session_external_id has no upstream format guarantee, so the IDs are treated as opaque strings. maxItems / maxLength are a defensive cap against a pathological or forged commit body inflating the artifact.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 64 } } } diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index f04c28b8c..2d6e14b9d 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -647,3 +647,87 @@ func TestValidateSecurityContextL0Class(t *testing.T) { }) } } + +// TestValidateSecurityContextTraceSessions covers the trace_sessions array added +// to the fix commit (fingerprint) and to each introducing commit (commit_ref): +// the valid fixture already carries one on both, and the bounds (maxItems, +// per-item minLength/maxLength) reject a pathological or malformed trailer. +func TestValidateSecurityContextTraceSessions(t *testing.T) { + loadPayload := func(t *testing.T) map[string]any { + t.Helper() + f, err := os.ReadFile("./testdata/ai_security_context_valid.json") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(f, &payload)) + return payload + } + + // firstFingerprint returns fingerprints[0] so a case can mutate its + // trace_sessions (the fix commit) or its introduced_by[0] (an origin commit). + firstFingerprint := func(t *testing.T, payload map[string]any) map[string]any { + t.Helper() + fps, ok := payload["fingerprints"].([]any) + require.True(t, ok) + require.NotEmpty(t, fps) + fp, ok := fps[0].(map[string]any) + require.True(t, ok) + return fp + } + + firstIntroducedBy := func(t *testing.T, fp map[string]any) map[string]any { + t.Helper() + origins, ok := fp["introduced_by"].([]any) + require.True(t, ok) + require.NotEmpty(t, origins) + origin, ok := origins[0].(map[string]any) + require.True(t, ok) + return origin + } + + t.Run("the fixture carries trace_sessions on both commit roles", func(t *testing.T) { + payload := loadPayload(t) + fp := firstFingerprint(t, payload) + require.NotEmpty(t, fp["trace_sessions"], "fix commit should carry trace_sessions") + require.NotEmpty(t, firstIntroducedBy(t, fp)["trace_sessions"], "introducing commit should carry trace_sessions") + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("fix commit accepts several session ids", func(t *testing.T) { + payload := loadPayload(t) + firstFingerprint(t, payload)["trace_sessions"] = []any{ + "8b012929-ba7f-4986-815c-2fbe692a9a98", + "an-opaque-non-uuid-session-id", + } + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + overflow := make([]any, 65) + for i := range overflow { + overflow[i] = fmt.Sprintf("session-%02d", i) + } + + rejected := []struct { + name string + value any + }{ + {name: "more than maxItems session ids", value: overflow}, + {name: "an empty-string session id", value: []any{""}}, + {name: "a session id longer than maxLength", value: []any{strings.Repeat("a", 257)}}, + {name: "a non-array trace_sessions", value: "not-an-array"}, + } + + for _, tc := range rejected { + t.Run("fix commit rejects "+tc.name, func(t *testing.T) { + payload := loadPayload(t) + firstFingerprint(t, payload)["trace_sessions"] = tc.value + require.Error(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("introducing commit rejects "+tc.name, func(t *testing.T) { + payload := loadPayload(t) + firstIntroducedBy(t, firstFingerprint(t, payload))["trace_sessions"] = tc.value + require.Error(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + } +} diff --git a/internal/schemavalidators/testdata/ai_security_context_valid.json b/internal/schemavalidators/testdata/ai_security_context_valid.json index 0a0c2adb1..786f7931c 100644 --- a/internal/schemavalidators/testdata/ai_security_context_valid.json +++ b/internal/schemavalidators/testdata/ai_security_context_valid.json @@ -182,10 +182,16 @@ "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", "description": "GET /ls handler shell invocation", "committed_at": "2026-08-19T19:56:46-03:00", - "verified": true + "verified": true, + "trace_sessions": [ + "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718" + ] } ], - "introduced_to_fixed_seconds": 102 + "introduced_to_fixed_seconds": 102, + "trace_sessions": [ + "8b012929-ba7f-4986-815c-2fbe692a9a98" + ] } ] } diff --git a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go index 1f3ecb4ca..ae2c27af9 100644 --- a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -147,6 +147,12 @@ type CommitRef struct { Description string `json:"description,omitempty"` CommittedAt string `json:"committed_at,omitempty"` Verified bool `json:"verified"` + + // TraceSessions are the Chainloop Trace session IDs (session_external_id) + // declared on this introducing commit via the Chainloop-Trace-Sessions git + // trailer — the AI coding session(s) that introduced the flaw. Recorded + // verbatim by the producer with no DB lookup; sorted + deduped. + TraceSessions []string `json:"trace_sessions,omitempty"` } // Anchor is a byte-verifiable evidence span. A consumer can re-verify it @@ -234,6 +240,12 @@ type Fingerprint struct { // annotated rather than dropped. Status string `json:"status,omitempty"` RevertedBy string `json:"reverted_by,omitempty"` + + // TraceSessions are the Chainloop Trace session IDs (session_external_id) + // declared on the fix commit via the Chainloop-Trace-Sessions git trailer — + // the AI coding session(s) that produced the fix. Recorded verbatim by the + // producer with no DB lookup; sorted + deduped. + TraceSessions []string `json:"trace_sessions,omitempty"` } // Data is the AI security context payload: the object the producer writes under diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context.json b/pkg/attestation/crafter/materials/testdata/ai-security-context.json index 2707251a5..7c8bea572 100644 --- a/pkg/attestation/crafter/materials/testdata/ai-security-context.json +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context.json @@ -544,10 +544,16 @@ "commit_sha": "d4bdebd22566dd9c871036eb39343033acd8c6bc", "description": "fixed-precision multipleOf conversion", "committed_at": "2025-05-09T02:19:30+02:00", - "verified": true + "verified": true, + "trace_sessions": [ + "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718" + ] } ], - "introduced_to_fixed_seconds": 40385717 + "introduced_to_fixed_seconds": 40385717, + "trace_sessions": [ + "8b012929-ba7f-4986-815c-2fbe692a9a98" + ] }, { "id": "fp_eda80e2676",