From 325d639fc01fef4e476d928d778b68450f8fde3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Mon, 7 Sep 2026 16:18:35 -0300 Subject: [PATCH 1/2] feat(materials): add trace_sessions to AI security context schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow the CHAINLOOP_AI_SECURITY_CONTEXT material to carry Chainloop Trace session IDs (session_external_id) declared on a commit via the Chainloop-Trace-Sessions git trailer. The optional trace_sessions array is added to both the fingerprint (fix commit) and commit_ref (introduced_by entries) definitions so a consumer can correlate a security-relevant change to the AI coding session(s) that produced or introduced it. Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, 7cf89fb4-1736-425d-bafb-bfb8b59155a4, df945bde-e982-472c-bb81-50055df403bb Signed-off-by: Matías Insaurralde --- .../ai-security-context-0.1.schema.json | 18 ++++ .../schemavalidators/schemavalidators_test.go | 83 +++++++++++++++++++ .../testdata/ai_security_context_valid.json | 10 ++- .../aisecuritycontext/aisecuritycontext.go | 12 +++ .../testdata/ai-security-context.json | 10 ++- 5 files changed, 129 insertions(+), 4 deletions(-) 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..fff74e32f 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -647,3 +647,86 @@ 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 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", From d71029ea287737285942e2472782b9b163d72c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Mon, 7 Sep 2026 16:30:07 -0300 Subject: [PATCH 2/2] test(materials): exercise trace_sessions maxLength bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TestValidateSecurityContextTraceSessions doc comment claimed per-item maxLength coverage, but the rejected cases only hit maxItems, minLength, and the array type. Add a >256-char session-id case on both commit roles so the maxLength bound is actually exercised. Assisted-by: Claude Code Signed-off-by: Matías Insaurralde Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, 7cf89fb4-1736-425d-bafb-bfb8b59155a4, df945bde-e982-472c-bb81-50055df403bb --- internal/schemavalidators/schemavalidators_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index fff74e32f..2d6e14b9d 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -713,6 +713,7 @@ func TestValidateSecurityContextTraceSessions(t *testing.T) { }{ {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"}, }