diff --git a/remediation/workflow/oidc/oidc.go b/remediation/workflow/oidc/oidc.go new file mode 100644 index 000000000..8be65cc58 --- /dev/null +++ b/remediation/workflow/oidc/oidc.go @@ -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 +} diff --git a/remediation/workflow/oidc/oidc_test.go b/remediation/workflow/oidc/oidc_test.go new file mode 100644 index 000000000..1410674ce --- /dev/null +++ b/remediation/workflow/oidc/oidc_test.go @@ -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) + } + }) + } +} diff --git a/testfiles/OIDC/input/multipleAWS.yml b/testfiles/OIDC/input/multipleAWS.yml new file mode 100644 index 000000000..9878adff7 --- /dev/null +++ b/testfiles/OIDC/input/multipleAWS.yml @@ -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 diff --git a/testfiles/OIDC/input/multipleAWSSteps.yml b/testfiles/OIDC/input/multipleAWSSteps.yml new file mode 100644 index 000000000..dc6372fcc --- /dev/null +++ b/testfiles/OIDC/input/multipleAWSSteps.yml @@ -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 diff --git a/testfiles/OIDC/input/withTopLevelAndJobLevel.yml b/testfiles/OIDC/input/withTopLevelAndJobLevel.yml new file mode 100644 index 000000000..c746712ac --- /dev/null +++ b/testfiles/OIDC/input/withTopLevelAndJobLevel.yml @@ -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 diff --git a/testfiles/OIDC/input/withoutAnyPerms.yml b/testfiles/OIDC/input/withoutAnyPerms.yml new file mode 100644 index 000000000..fc7c6ee73 --- /dev/null +++ b/testfiles/OIDC/input/withoutAnyPerms.yml @@ -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 diff --git a/testfiles/OIDC/input/withoutTopLevel.yml b/testfiles/OIDC/input/withoutTopLevel.yml new file mode 100644 index 000000000..c4993189c --- /dev/null +++ b/testfiles/OIDC/input/withoutTopLevel.yml @@ -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 diff --git a/testfiles/OIDC/output/multipleAWS.yml b/testfiles/OIDC/output/multipleAWS.yml new file mode 100644 index 000000000..48b03f859 --- /dev/null +++ b/testfiles/OIDC/output/multipleAWS.yml @@ -0,0 +1,40 @@ +name: Sample Workflow +on: + push: + branches: + - main + +jobs: + job1: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + job2: + permissions: + contents: read # for actions/checkout to fetch code + id-token: write # for aws-actions/configure-aws-credentials to get credentials from GitHub OIDC provider + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 + + job3: + permissions: + contents: read # for actions/checkout to fetch code + id-token: write # for aws-actions/configure-aws-credentials to get credentials from GitHub OIDC provider + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 diff --git a/testfiles/OIDC/output/multipleAWSSteps.yml b/testfiles/OIDC/output/multipleAWSSteps.yml new file mode 100644 index 000000000..33f663d21 --- /dev/null +++ b/testfiles/OIDC/output/multipleAWSSteps.yml @@ -0,0 +1,32 @@ +name: Sample Workflow +on: + push: + branches: + - main + +jobs: + job1: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + job2: + permissions: + contents: read # for actions/checkout to fetch code + id-token: write # for aws-actions/configure-aws-credentials to get credentials from GitHub OIDC provider + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 diff --git a/testfiles/OIDC/output/withTopLevelAndJobLevel.yml b/testfiles/OIDC/output/withTopLevelAndJobLevel.yml new file mode 100644 index 000000000..b18691965 --- /dev/null +++ b/testfiles/OIDC/output/withTopLevelAndJobLevel.yml @@ -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: + id-token: write + content: write + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 diff --git a/testfiles/OIDC/output/withoutAnyPerms.yml b/testfiles/OIDC/output/withoutAnyPerms.yml new file mode 100644 index 000000000..5dda4cd4d --- /dev/null +++ b/testfiles/OIDC/output/withoutAnyPerms.yml @@ -0,0 +1,26 @@ +name: Sample Workflow +on: + push: + branches: + - main + +jobs: + job1: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + job2: + permissions: + contents: read # for actions/checkout to fetch code + id-token: write # for aws-actions/configure-aws-credentials to get credentials from GitHub OIDC provider + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1 diff --git a/testfiles/OIDC/output/withoutTopLevel.yml b/testfiles/OIDC/output/withoutTopLevel.yml new file mode 100644 index 000000000..4022d7f27 --- /dev/null +++ b/testfiles/OIDC/output/withoutTopLevel.yml @@ -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: + id-token: write + content: write + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::{OICD_ID}:role/my-github-actions-role + aws-region: us-east-1