Skip to content

Commit ed71396

Browse files
authored
fix(cli): evaluate policies against the material on disk in policy devel eval (#3379)
1 parent 37a179f commit ed71396

4 files changed

Lines changed: 197 additions & 1 deletion

File tree

‎app/cli/internal/policydevel/eval.go‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"encoding/json"
2020
"fmt"
21+
"strings"
2122

2223
controlplanev1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2324
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
@@ -79,7 +80,7 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
7980
if err != nil {
8081
return nil, err
8182
}
82-
material.Annotations = opts.Annotations
83+
mergeAnnotations(material, opts.Annotations, &logger)
8384

8485
// 3. Verify material against policy
8586
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, opts.ProjectName, opts.ProjectVersionName, &logger)
@@ -90,6 +91,40 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
9091
return summary, nil
9192
}
9293

94+
// mergeAnnotations layers the user's --annotation flags on top of the ones the
95+
// crafter produced, rather than replacing them.
96+
//
97+
// The crafter's annotations carry more than metadata: chainloop.material.redacted
98+
// is what tells the policy engine to evaluate the untouched file on disk instead
99+
// of the sanitized copy staged for upload. Dropping it would silently feed
100+
// policies redacted input, which is exactly what a secret-hunting policy must
101+
// not see.
102+
//
103+
// The chainloop.* namespace is therefore crafter-owned and not overridable, so
104+
// that a --annotation flag cannot put back the behaviour this guards against.
105+
// Crafter.stageMaterial protects the equivalent invariant on `attestation add`
106+
// by refusing to override annotations that come from the contract.
107+
func mergeAnnotations(material *v12.Attestation_Material, annotations map[string]string, logger *zerolog.Logger) {
108+
if len(annotations) == 0 {
109+
return
110+
}
111+
112+
// Crafters that do not go through uploadAndCraft (container image, string)
113+
// leave the map nil.
114+
if material.Annotations == nil {
115+
material.Annotations = make(map[string]string, len(annotations))
116+
}
117+
118+
for k, v := range annotations {
119+
if strings.HasPrefix(k, v12.AnnotationPrefix) {
120+
logger.Info().Str("annotation", k).Msg("reserved annotation namespace, it is set by the crafter and can not be overridden, skipping")
121+
continue
122+
}
123+
124+
material.Annotations[k] = v
125+
}
126+
}
127+
93128
func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies, error) {
94129
// Check if the policy path already has a scheme (chainloop://, http://, https://, file://)
95130
ref := policyPath

‎app/cli/internal/policydevel/eval_test.go‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
package policydevel
1616

1717
import (
18+
"bytes"
1819
"encoding/json"
1920
"os"
2021
"path/filepath"
2122
"testing"
2223

24+
v12 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2325
"github.com/rs/zerolog"
2426
"github.com/stretchr/testify/assert"
2527
"github.com/stretchr/testify/require"
@@ -230,3 +232,117 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
230232
assert.Contains(t, string(result.Result.Violations[0]), "too few components")
231233
})
232234
}
235+
236+
// fixtureGitHubPAT is assembled from fragments so that this file does not itself
237+
// carry a credential-shaped literal for secret scanners to flag.
238+
const fixtureGitHubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf"
239+
240+
// writeSessionFixture materialises an AI coding session fixture with its
241+
// credential placeholder resolved, so that the crafter sees a real secret on disk.
242+
func writeSessionFixture(t *testing.T) string {
243+
t.Helper()
244+
245+
content, err := os.ReadFile("testdata/ai-coding-session-with-secret.json")
246+
require.NoError(t, err)
247+
content = bytes.ReplaceAll(content, []byte("__GITHUB_PAT__"), []byte(fixtureGitHubPAT))
248+
249+
path := filepath.Join(t.TempDir(), "ai-coding-session.json")
250+
require.NoError(t, os.WriteFile(path, content, 0600))
251+
252+
return path
253+
}
254+
255+
// Policies must be evaluated against the material as it sits on disk. The crafter
256+
// redacts the copy it stages for upload, and a dry run always stages inline, so
257+
// evaluating the staged bytes would hide from a policy the very secret it exists
258+
// to catch.
259+
func TestEvaluateReadsUnredactedMaterialFromDisk(t *testing.T) {
260+
testCases := []struct {
261+
name string
262+
annotations map[string]string
263+
}{
264+
// The bug this pins: with no --annotation flags the crafter's annotation
265+
// map was replaced by an empty one, so the marker that selects the file on
266+
// disk was lost and policies evaluated the sanitized copy.
267+
{name: "without user annotations", annotations: nil},
268+
{name: "with user annotations", annotations: map[string]string{"custom": "value"}},
269+
{
270+
name: "with an attempt to override the redaction marker",
271+
annotations: map[string]string{v12.AnnotationMaterialRedacted: "false"},
272+
},
273+
}
274+
275+
for _, tc := range testCases {
276+
t.Run(tc.name, func(t *testing.T) {
277+
opts := &EvalOptions{
278+
PolicyPath: "testdata/ai-coding-session-no-secrets-policy.yaml",
279+
MaterialKind: "CHAINLOOP_AI_CODING_SESSION",
280+
MaterialPath: writeSessionFixture(t),
281+
Annotations: tc.annotations,
282+
}
283+
284+
result, err := Evaluate(opts, zerolog.New(os.Stderr))
285+
require.NoError(t, err)
286+
require.NotNil(t, result)
287+
288+
assert.False(t, result.Result.Skipped)
289+
require.Len(t, result.Result.Violations, 1)
290+
assert.Contains(t, result.Result.Violations[0], "GitHub token found")
291+
})
292+
}
293+
}
294+
295+
func TestMergeAnnotations(t *testing.T) {
296+
testCases := []struct {
297+
name string
298+
existing map[string]string
299+
user map[string]string
300+
want map[string]string
301+
}{
302+
{
303+
name: "crafter annotations survive when the user supplies none",
304+
existing: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue},
305+
user: nil,
306+
want: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue},
307+
},
308+
{
309+
name: "user annotations are added alongside the crafter's",
310+
existing: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue},
311+
user: map[string]string{"custom": "value"},
312+
want: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue, "custom": "value"},
313+
},
314+
{
315+
name: "user annotations win on conflict outside the reserved namespace",
316+
existing: map[string]string{"custom": "crafted"},
317+
user: map[string]string{"custom": "user"},
318+
want: map[string]string{"custom": "user"},
319+
},
320+
{
321+
name: "the reserved chainloop namespace can not be overridden",
322+
existing: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue},
323+
user: map[string]string{v12.AnnotationMaterialRedacted: "false", "custom": "value"},
324+
want: map[string]string{v12.AnnotationMaterialRedacted: v12.AnnotationValueTrue, "custom": "value"},
325+
},
326+
{
327+
name: "a material with no annotations gets the user's",
328+
existing: nil,
329+
user: map[string]string{"custom": "value"},
330+
want: map[string]string{"custom": "value"},
331+
},
332+
{
333+
name: "nothing to merge leaves the material untouched",
334+
existing: nil,
335+
user: nil,
336+
want: nil,
337+
},
338+
}
339+
340+
logger := zerolog.New(os.Stderr)
341+
for _, tc := range testCases {
342+
t.Run(tc.name, func(t *testing.T) {
343+
material := &v12.Attestation_Material{Annotations: tc.existing}
344+
mergeAnnotations(material, tc.user, &logger)
345+
assert.Equal(t, tc.want, material.GetAnnotations())
346+
})
347+
}
348+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
apiVersion: chainloop.dev/v1
2+
kind: Policy
3+
metadata:
4+
name: ai-coding-session-no-secrets
5+
description: Policy that fails when a GitHub token is present in the session
6+
spec:
7+
policies:
8+
- kind: CHAINLOOP_AI_CODING_SESSION
9+
embedded: |
10+
package main
11+
12+
import rego.v1
13+
14+
# The token is matched by shape rather than by value so that this file
15+
# does not itself carry a credential-looking literal.
16+
violations contains msg if {
17+
regex.match(`ghp_[A-Za-z0-9]{36}`, json.marshal(input))
18+
msg := "GitHub token found in the coding session"
19+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION",
3+
"schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json",
4+
"data": {
5+
"schema_version": "v1",
6+
"agent": {
7+
"name": "cursor"
8+
},
9+
"session": {
10+
"id": "abc-123",
11+
"started_at": "2026-03-25T15:10:49.161Z",
12+
"duration_seconds": 100
13+
},
14+
"raw_session": {
15+
"main": [
16+
{
17+
"type": "user",
18+
"message": {
19+
"role": "user",
20+
"content": "push the branch with GITHUB_TOKEN=__GITHUB_PAT__"
21+
}
22+
}
23+
]
24+
}
25+
}
26+
}

0 commit comments

Comments
 (0)