Skip to content

Commit ddea58c

Browse files
leodidoona-agent
andcommitted
test(pkg/leeway/signing): test the extraction of job_workflow_ref/builder ID from OIDC sub claims
Signed-off-by: Leo Di Donato <120051+leodido@users.noreply.github.com> Co-authored-by: Ona <no-reply@ona.com>
1 parent 8b49f6a commit ddea58c

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

pkg/leeway/signing/attestation_test.go

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package signing
33
import (
44
"context"
55
"crypto/sha256"
6+
"encoding/base64"
67
"encoding/hex"
78
"encoding/json"
89
"fmt"
@@ -1210,3 +1211,360 @@ func TestFetchGitHubOIDCToken(t *testing.T) {
12101211
})
12111212
}
12121213
}
1214+
1215+
// TestExtractJobWorkflowRef tests the extraction of job_workflow_ref from OIDC sub claims
1216+
func TestExtractJobWorkflowRef(t *testing.T) {
1217+
tests := []struct {
1218+
name string
1219+
subClaim string
1220+
expected string
1221+
}{
1222+
{
1223+
name: "reusable workflow with job_workflow_ref",
1224+
subClaim: "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1225+
expected: "example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1226+
},
1227+
{
1228+
name: "direct workflow (non-reusable)",
1229+
subClaim: "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1230+
expected: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1231+
},
1232+
{
1233+
name: "workflow path with colons",
1234+
subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/build:test.yml@refs/heads/main",
1235+
expected: "org/repo/.github/workflows/build:test.yml@refs/heads/main",
1236+
},
1237+
{
1238+
name: "missing job_workflow_ref",
1239+
subClaim: "repo:example-org/example-repo:ref:refs/heads/main",
1240+
expected: "",
1241+
},
1242+
{
1243+
name: "empty sub claim",
1244+
subClaim: "",
1245+
expected: "",
1246+
},
1247+
{
1248+
name: "malformed sub claim",
1249+
subClaim: "invalid:format",
1250+
expected: "",
1251+
},
1252+
{
1253+
name: "job_workflow_ref at end without value",
1254+
subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:",
1255+
expected: "",
1256+
},
1257+
{
1258+
name: "multiple colons in workflow path",
1259+
subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main",
1260+
expected: "org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main",
1261+
},
1262+
}
1263+
1264+
for _, tt := range tests {
1265+
t.Run(tt.name, func(t *testing.T) {
1266+
result := extractJobWorkflowRef(tt.subClaim)
1267+
assert.Equal(t, tt.expected, result)
1268+
})
1269+
}
1270+
}
1271+
1272+
// TestExtractJobWorkflowRef_RealWorldExamples tests with actual GitHub OIDC token formats
1273+
func TestExtractJobWorkflowRef_RealWorldExamples(t *testing.T) {
1274+
tests := []struct {
1275+
name string
1276+
subClaim string
1277+
expected string
1278+
description string
1279+
}{
1280+
{
1281+
name: "GitHub Actions reusable workflow",
1282+
subClaim: "repo:gitpod-io/gitpod:environment:production:ref:refs/heads/main:job_workflow_ref:gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main",
1283+
expected: "gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main",
1284+
description: "Reusable workflow with environment claim",
1285+
},
1286+
{
1287+
name: "Pull request workflow",
1288+
subClaim: "repo:gitpod-io/leeway:ref:refs/pull/264/merge:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge",
1289+
expected: "gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge",
1290+
description: "Pull request merge ref",
1291+
},
1292+
{
1293+
name: "Tag-triggered workflow",
1294+
subClaim: "repo:org/repo:ref:refs/tags/v1.0.0:job_workflow_ref:org/repo/.github/workflows/release.yml@refs/tags/v1.0.0",
1295+
expected: "org/repo/.github/workflows/release.yml@refs/tags/v1.0.0",
1296+
description: "Tag reference",
1297+
},
1298+
}
1299+
1300+
for _, tt := range tests {
1301+
t.Run(tt.name, func(t *testing.T) {
1302+
result := extractJobWorkflowRef(tt.subClaim)
1303+
assert.Equal(t, tt.expected, result, tt.description)
1304+
})
1305+
}
1306+
}
1307+
1308+
// Helper function to create base64url encoded strings for JWT tokens
1309+
func base64EncodeForTest(s string) string {
1310+
return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(s)), "=")
1311+
}
1312+
1313+
// TestExtractBuilderIDFromOIDC tests the extraction of builder ID from OIDC tokens
1314+
func TestExtractBuilderIDFromOIDC(t *testing.T) {
1315+
tests := []struct {
1316+
name string
1317+
setupServer func() *httptest.Server
1318+
githubCtx *GitHubContext
1319+
expectError bool
1320+
expectedID string
1321+
errorMsg string
1322+
}{
1323+
{
1324+
name: "valid OIDC token with reusable workflow",
1325+
setupServer: func() *httptest.Server {
1326+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1327+
header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`)
1328+
payload := base64EncodeForTest(`{
1329+
"sub": "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1330+
"aud": "sigstore",
1331+
"iss": "https://token.actions.githubusercontent.com"
1332+
}`)
1333+
signature := base64EncodeForTest("fake-signature")
1334+
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
1335+
1336+
w.Header().Set("Content-Type", "application/json")
1337+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1338+
}))
1339+
},
1340+
githubCtx: &GitHubContext{
1341+
ServerURL: "https://github.com",
1342+
Repository: "example-org/example-repo",
1343+
WorkflowRef: "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main",
1344+
},
1345+
expectError: false,
1346+
expectedID: "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1347+
},
1348+
{
1349+
name: "valid OIDC token with direct workflow",
1350+
setupServer: func() *httptest.Server {
1351+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1352+
header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`)
1353+
payload := base64EncodeForTest(`{
1354+
"sub": "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1355+
"aud": "sigstore"
1356+
}`)
1357+
signature := base64EncodeForTest("fake-signature")
1358+
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
1359+
1360+
w.Header().Set("Content-Type", "application/json")
1361+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1362+
}))
1363+
},
1364+
githubCtx: &GitHubContext{
1365+
ServerURL: "https://github.com",
1366+
Repository: "gitpod-io/leeway",
1367+
WorkflowRef: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1368+
},
1369+
expectError: false,
1370+
expectedID: "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1371+
},
1372+
{
1373+
name: "invalid JWT format - only 2 parts",
1374+
setupServer: func() *httptest.Server {
1375+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1376+
token := "header.payload"
1377+
w.Header().Set("Content-Type", "application/json")
1378+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1379+
}))
1380+
},
1381+
githubCtx: &GitHubContext{
1382+
ServerURL: "https://github.com",
1383+
Repository: "org/repo",
1384+
},
1385+
expectError: true,
1386+
errorMsg: "invalid JWT token format",
1387+
},
1388+
{
1389+
name: "missing sub claim",
1390+
setupServer: func() *httptest.Server {
1391+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1392+
header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`)
1393+
payload := base64EncodeForTest(`{"aud": "sigstore"}`)
1394+
signature := base64EncodeForTest("fake-signature")
1395+
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
1396+
1397+
w.Header().Set("Content-Type", "application/json")
1398+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1399+
}))
1400+
},
1401+
githubCtx: &GitHubContext{
1402+
ServerURL: "https://github.com",
1403+
Repository: "org/repo",
1404+
},
1405+
expectError: true,
1406+
errorMsg: "sub claim not found",
1407+
},
1408+
{
1409+
name: "missing job_workflow_ref in sub claim",
1410+
setupServer: func() *httptest.Server {
1411+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1412+
header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`)
1413+
payload := base64EncodeForTest(`{
1414+
"sub": "repo:org/repo:ref:refs/heads/main",
1415+
"aud": "sigstore"
1416+
}`)
1417+
signature := base64EncodeForTest("fake-signature")
1418+
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
1419+
1420+
w.Header().Set("Content-Type", "application/json")
1421+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1422+
}))
1423+
},
1424+
githubCtx: &GitHubContext{
1425+
ServerURL: "https://github.com",
1426+
Repository: "org/repo",
1427+
},
1428+
expectError: true,
1429+
errorMsg: "job_workflow_ref not found",
1430+
},
1431+
{
1432+
name: "OIDC token fetch failure",
1433+
setupServer: func() *httptest.Server {
1434+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1435+
w.WriteHeader(http.StatusUnauthorized)
1436+
}))
1437+
},
1438+
githubCtx: &GitHubContext{
1439+
ServerURL: "https://github.com",
1440+
Repository: "org/repo",
1441+
},
1442+
expectError: true,
1443+
errorMsg: "failed to fetch OIDC token",
1444+
},
1445+
}
1446+
1447+
for _, tt := range tests {
1448+
t.Run(tt.name, func(t *testing.T) {
1449+
server := tt.setupServer()
1450+
defer server.Close()
1451+
1452+
oldRequestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
1453+
oldRequestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
1454+
defer func() {
1455+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", oldRequestURL)
1456+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", oldRequestToken)
1457+
}()
1458+
1459+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL)
1460+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token")
1461+
1462+
builderID, err := extractBuilderIDFromOIDC(context.Background(), tt.githubCtx)
1463+
1464+
if tt.expectError {
1465+
assert.Error(t, err)
1466+
if tt.errorMsg != "" {
1467+
assert.Contains(t, err.Error(), tt.errorMsg)
1468+
}
1469+
} else {
1470+
assert.NoError(t, err)
1471+
assert.Equal(t, tt.expectedID, builderID)
1472+
}
1473+
})
1474+
}
1475+
}
1476+
1477+
// TestBuilderIDMatchesCertificateIdentity is the critical regression test
1478+
// It verifies that the builder ID extracted from OIDC matches what Fulcio
1479+
// would use for the certificate identity, preventing verification failures
1480+
func TestBuilderIDMatchesCertificateIdentity(t *testing.T) {
1481+
tests := []struct {
1482+
name string
1483+
oidcSubClaim string
1484+
githubWorkflowRef string
1485+
expectedBuilderID string
1486+
shouldMatchWorkflowRef bool
1487+
description string
1488+
}{
1489+
{
1490+
name: "reusable workflow - builder ID must match OIDC not GITHUB_WORKFLOW_REF",
1491+
oidcSubClaim: "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1492+
githubWorkflowRef: "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main",
1493+
expectedBuilderID: "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1494+
shouldMatchWorkflowRef: false,
1495+
description: "For reusable workflows, certificate identity comes from OIDC sub claim (actual executing workflow), not GITHUB_WORKFLOW_REF (calling workflow)",
1496+
},
1497+
{
1498+
name: "direct workflow - builder ID matches both OIDC and GITHUB_WORKFLOW_REF",
1499+
oidcSubClaim: "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1500+
githubWorkflowRef: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1501+
expectedBuilderID: "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1502+
shouldMatchWorkflowRef: true,
1503+
description: "For direct workflows, OIDC and GITHUB_WORKFLOW_REF point to the same workflow",
1504+
},
1505+
{
1506+
name: "nested reusable workflow",
1507+
oidcSubClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/_internal-build.yml@refs/heads/feature",
1508+
githubWorkflowRef: "org/repo/.github/workflows/main-workflow.yml@refs/heads/main",
1509+
expectedBuilderID: "https://github.com/org/repo/.github/workflows/_internal-build.yml@refs/heads/feature",
1510+
shouldMatchWorkflowRef: false,
1511+
description: "Nested reusable workflows also use OIDC sub claim for certificate identity",
1512+
},
1513+
}
1514+
1515+
for _, tt := range tests {
1516+
t.Run(tt.name, func(t *testing.T) {
1517+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1518+
header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`)
1519+
payload := base64EncodeForTest(fmt.Sprintf(`{
1520+
"sub": "%s",
1521+
"aud": "sigstore",
1522+
"iss": "https://token.actions.githubusercontent.com"
1523+
}`, tt.oidcSubClaim))
1524+
signature := base64EncodeForTest("fake-signature")
1525+
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
1526+
1527+
w.Header().Set("Content-Type", "application/json")
1528+
json.NewEncoder(w).Encode(map[string]string{"value": token})
1529+
}))
1530+
defer server.Close()
1531+
1532+
oldRequestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
1533+
oldRequestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
1534+
defer func() {
1535+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", oldRequestURL)
1536+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", oldRequestToken)
1537+
}()
1538+
1539+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL)
1540+
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token")
1541+
1542+
githubCtx := &GitHubContext{
1543+
ServerURL: "https://github.com",
1544+
Repository: "example-org/example-repo",
1545+
WorkflowRef: tt.githubWorkflowRef,
1546+
}
1547+
1548+
builderID, err := extractBuilderIDFromOIDC(context.Background(), githubCtx)
1549+
require.NoError(t, err, tt.description)
1550+
1551+
assert.Equal(t, tt.expectedBuilderID, builderID, tt.description)
1552+
1553+
workflowRefBasedID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, tt.githubWorkflowRef)
1554+
if tt.shouldMatchWorkflowRef {
1555+
assert.Equal(t, workflowRefBasedID, builderID,
1556+
"For direct workflows, builder ID should match GITHUB_WORKFLOW_REF-based ID")
1557+
} else {
1558+
assert.NotEqual(t, workflowRefBasedID, builderID,
1559+
"For reusable workflows, builder ID must NOT match GITHUB_WORKFLOW_REF-based ID - this is the critical fix")
1560+
}
1561+
1562+
jobWorkflowRef := extractJobWorkflowRef(tt.oidcSubClaim)
1563+
require.NotEmpty(t, jobWorkflowRef, "job_workflow_ref should be extractable from sub claim")
1564+
1565+
expectedFromJobWorkflowRef := fmt.Sprintf("%s/%s", githubCtx.ServerURL, jobWorkflowRef)
1566+
assert.Equal(t, expectedFromJobWorkflowRef, builderID,
1567+
"Builder ID must be constructed from OIDC job_workflow_ref to match Fulcio certificate identity")
1568+
})
1569+
}
1570+
}

0 commit comments

Comments
 (0)