Skip to content

[UPDATE] Transform GitHub Actions Workflows to Use OIDC #2214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: int
Choose a base branch
from
Open
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
171 changes: 171 additions & 0 deletions remediation/workflow/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package oidc

import (
"fmt"
"strings"

metadata "github.com/step-security/secure-repo/remediation/workflow/metadata"
"github.com/step-security/secure-repo/remediation/workflow/permissions"
"gopkg.in/yaml.v3"
)

func UpdateActionsToUseOIDC(inputYaml string) (string, bool, error) {
updatedYaml, updated, err := updateActionsToUseOIDC(inputYaml)
if err != nil {
return "", false, fmt.Errorf("failed to update actions to use OIDC: %w", err)
}

if !updated {
return "", false, fmt.Errorf("inputYaml does not have OIDC actions")
}

secureWorkflowReponse, err := permissions.AddJobLevelPermissions(updatedYaml)
if err != nil {
return "", false, err
}

if !secureWorkflowReponse.HasErrors {
return secureWorkflowReponse.FinalOutput, true, nil
}

workflow := metadata.Workflow{}
err = yaml.Unmarshal([]byte(updatedYaml), &workflow)
if err != nil {
return "", false, err
}

IsContentRead := false
if len(workflow.Permissions.Scopes) == 1 {
if val, ok := workflow.Permissions.Scopes["contents"]; ok {
if val == "read" {
IsContentRead = true
}
}
}

for jobName, job := range workflow.Jobs {
if val, ok := job.Permissions.Scopes["id-token"]; ok {
if val == "write" {
continue
}
}
containsOIDCAction := false
for _, step := range job.Steps {
if strings.Contains(step.Uses, "aws-actions/configure-aws-credentials") {
containsOIDCAction = true
break
}
}
if containsOIDCAction && (job.Permissions.IsSet || IsContentRead) {
updatedYaml, err = addOIDCPermission(updatedYaml, jobName, job.Permissions.IsSet)
if err != nil {
return "", false, err
}
} else if containsOIDCAction && (!job.Permissions.IsSet && !IsContentRead) {
return "", false, err
}
}
return updatedYaml, true, nil
}

func addOIDCPermission(inputYaml, jobName string, isSet bool) (string, error) {
t := yaml.Node{}

err := yaml.Unmarshal([]byte(inputYaml), &t)
if err != nil {
return "", fmt.Errorf("unable to parse yaml %v", err)
}

jobNode := permissions.IterateNode(&t, jobName, "!!map", 0)
if isSet {
jobNode = permissions.IterateNode(&t, "permissions", "!!map", jobNode.Line)
}

if jobNode == nil {
return "", fmt.Errorf("jobName %s not found in the input yaml", jobName)
}

inputLines := strings.Split(inputYaml, "\n")
var output []string
for i := 0; i < jobNode.Line-1; i++ {
output = append(output, inputLines[i])
}

spaces := ""
for i := 0; i < jobNode.Column-1; i++ {
spaces += " "
}

if !isSet {
output = append(output, spaces+fmt.Sprintf("permissions:"))
spaces += " "
}
output = append(output, spaces+fmt.Sprintf("id-token: write"))

for i := jobNode.Line - 1; i < len(inputLines); i++ {
output = append(output, inputLines[i])
}

return strings.Join(output, "\n"), nil

}

func updateActionsToUseOIDC(inputYaml string) (string, bool, error) {
workflow := metadata.Workflow{}
finalOutput := inputYaml
updated := false

err := yaml.Unmarshal([]byte(inputYaml), &workflow)
if err != nil {
return "", false, fmt.Errorf("failed to unmarshal workflow: %w", err)
}

// Iterate over each job
for jobName, job := range workflow.Jobs {
// Iterate over each step in the job
for _, step := range job.Steps {
if strings.Contains(step.Uses, "aws-actions/configure-aws-credentials") {
finalOutput, err = updateAWSCredentialsAction(jobName, step, finalOutput)
if err != nil {
return "", false, fmt.Errorf("failed to update AWS credentials action: %w", err)
}
updated = true
}
}
}

return finalOutput, updated, nil
}

func updateAWSCredentialsAction(jobName string, step metadata.Step, inputYaml string) (string, error) {
t := yaml.Node{}

err := yaml.Unmarshal([]byte(inputYaml), &t)
if err != nil {
return "", fmt.Errorf("unable to parse yaml %v", err)
}

jobNode := permissions.IterateNode(&t, jobName, "!!map", 0)
jobNode = permissions.IterateNode(&t, "steps", "!!seq", jobNode.Line)

jobNode = permissions.IterateNode(&t, "aws-access-key-id", "!!str", jobNode.Line)
if jobNode == nil {
return "", fmt.Errorf("jobName %s not found in the input yaml", jobName)
}

inputLines := strings.Split(inputYaml, "\n")
var output []string
for i := 0; i < jobNode.Line-1; i++ {
output = append(output, inputLines[i])
}
for i := jobNode.Line; i < len(inputLines); i++ {
output = append(output, inputLines[i])
}

inputYaml = strings.Join(output, "\n")

inputYaml = strings.ReplaceAll(inputYaml, "aws-secret-access-key", "role-to-assume")
inputYaml = strings.ReplaceAll(inputYaml, step.With["aws-secret-access-key"], "arn:aws:iam::{OICD_ID}:role/my-github-actions-role")

return inputYaml, nil
}
53 changes: 53 additions & 0 deletions remediation/workflow/oidc/oidc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package oidc_test

import (
"io/ioutil"
"os"
"path/filepath"
"testing"

"github.com/step-security/secure-repo/remediation/workflow/oidc"
)

const inputDirectory = "../../../testfiles/OIDC/input"
const outputDirectory = "../../../testfiles/OIDC/output"

func TestUpdateActionsToUseOIDC(t *testing.T) {
inputFiles := []string{
"withTopLevelAndJobLevel.yml",
"withoutTopLevel.yml",
"withoutAnyPerms.yml",
"multipleAWS.yml",
"multipleAWSSteps.yml",
}
os.Setenv("KBFolder", "../../../knowledge-base/actions")

for _, inputFile := range inputFiles {
t.Run(inputFile, func(t *testing.T) {
inputPath := filepath.Join(inputDirectory, inputFile)
expectedOutputPath := filepath.Join(outputDirectory, inputFile)

inputYAML, err := ioutil.ReadFile(inputPath)
if err != nil {
t.Errorf("Failed to read input YAML file: %v", err)
return
}

expectedOutputYAML, err := ioutil.ReadFile(expectedOutputPath)
if err != nil {
t.Errorf("Failed to read expected output YAML file: %v", err)
return
}

updatedYAML, _, err := oidc.UpdateActionsToUseOIDC(string(inputYAML))
if err != nil {
t.Errorf("Failed to update actions to use OIDC: %v", err)
return
}

if updatedYAML != string(expectedOutputYAML) {
t.Errorf("Updated YAML does not match the expected output:\nExpected:\n%s\n\nActual:\n%s", string(expectedOutputYAML), updatedYAML)
}
})
}
}
36 changes: 36 additions & 0 deletions testfiles/OIDC/input/multipleAWS.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Sample Workflow
on:
push:
branches:
- main

jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

job2:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1

job3:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
31 changes: 31 additions & 0 deletions testfiles/OIDC/input/multipleAWSSteps.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Sample Workflow
on:
push:
branches:
- main

jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

job2:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
28 changes: 28 additions & 0 deletions testfiles/OIDC/input/withTopLevelAndJobLevel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Sample Workflow
on:
push:
branches:
- main

permissions:
id-token: read
contents: read

jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

job2:
runs-on: ubuntu-latest
permissions:
content: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
24 changes: 24 additions & 0 deletions testfiles/OIDC/input/withoutAnyPerms.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Sample Workflow
on:
push:
branches:
- main

jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

job2:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
24 changes: 24 additions & 0 deletions testfiles/OIDC/input/withoutTopLevel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Sample Workflow
on:
push:
branches:
- main

jobs:
job1:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

job2:
runs-on: ubuntu-latest
permissions:
content: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
Loading