diff --git a/models/package_insights.go b/models/package_insights.go index 8592247..5d3e997 100644 --- a/models/package_insights.go +++ b/models/package_insights.go @@ -31,6 +31,7 @@ type PackageInsights struct { GithubActionsMetadata []GithubActionsMetadata `json:"github_actions_metadata"` GitlabciConfigs []GitlabciConfig `json:"gitlabci_configs"` AzurePipelines []AzurePipeline `json:"azure_pipelines"` + PipelineAsCodeTekton []PipelineAsCodeTekton `json:"pipeline_as_code_tekton"` } func (p *PackageInsights) GetSourceGitRepoURI() string { diff --git a/models/pipeline_as_code_tekton.go b/models/pipeline_as_code_tekton.go new file mode 100644 index 0000000..e58b837 --- /dev/null +++ b/models/pipeline_as_code_tekton.go @@ -0,0 +1,61 @@ +package models + +import "gopkg.in/yaml.v3" + +type PipelineAsCodeTekton struct { + ApiVersion string `json:"api_version" yaml:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + Spec PipelineRunSpec `json:"spec,omitempty" yaml:"spec"` + + Path string `json:"path" yaml:"-"` +} + +type PipelineRunSpec struct { + PipelineSpec *PipelineSpec `json:"pipeline_spec,omitempty" yaml:"pipelineSpec"` +} + +type PipelineSpec struct { + Tasks []PipelineTask `json:"tasks,omitempty" yaml:"tasks"` +} + +type PipelineTask struct { + Name string `json:"name,omitempty"` + + TaskSpec *TaskSpec `json:"task_spec,omitempty" yaml:"taskSpec"` +} + +type TaskSpec struct { + Steps []Step `json:"steps,omitempty"` +} + +type Step struct { + Name string `json:"name"` + Script string `json:"script,omitempty"` + Lines map[string]int `json:"lines" yaml:"-"` +} + +func (o *Step) UnmarshalYAML(node *yaml.Node) error { + type step Step + var s step + if err := node.Decode(&s); err != nil { + return err + } + + if node.Kind == yaml.MappingNode { + s.Lines = map[string]int{"start": node.Line} + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i].Value + switch key { + case "script": + s.Lines[key] = node.Content[i+1].Line + } + } + } + + *o = Step(s) + return nil +} diff --git a/opa/rego/rules/injection.rego b/opa/rego/rules/injection.rego index 3970c26..f108d7a 100644 --- a/opa/rego/rules/injection.rego +++ b/opa/rego/rules/injection.rego @@ -101,3 +101,31 @@ results contains poutine.finding(rule, pkg.purl, { exprs := azure_injections(step[attr]) count(exprs) > 0 } + +patterns.pipeline_as_code_tekton contains "\\{\\{\\s*(body\\.pull_request\\.(title|user\\.email|body)|source_branch)\\s*\\}\\}" + +pipeline_as_code_tekton_injections(str) = {expr | + match := regex.find_n(patterns.pipeline_as_code_tekton[_], str, -1)[_] + expr := regex.find_all_string_submatch_n("\\{\\{\\s*([^}]+?)\\s*\\}\\}", match, 1)[0][1] +} + +results contains poutine.finding(rule, pkg.purl, { + "path": pipeline.path, + "job": task.name, + "step": step_idx, + "line": step.lines["start"], + "details": sprintf("Sources: %s", [concat(" ", exprs)]), +}) if { + pkg := input.packages[_] + pipeline := pkg.pipeline_as_code_tekton[_] + contains(pipeline.api_version, "tekton.dev") + pipeline.kind == "PipelineRun" + contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/on-event"], "pull_request") + task := pipeline.spec.pipeline_spec.tasks[_] + step := task.task_spec.steps[step_idx] + + exprs := pipeline_as_code_tekton_injections(step.script) + count(exprs) > 0 +} + + diff --git a/opa/rego/rules/untrusted_checkout_exec.rego b/opa/rego/rules/untrusted_checkout_exec.rego index 373190a..1559dd9 100644 --- a/opa/rego/rules/untrusted_checkout_exec.rego +++ b/opa/rego/rules/untrusted_checkout_exec.rego @@ -44,6 +44,7 @@ build_commands[cmd] = { "bundler": {"bundle install", "bundle exec "}, "ant": {"^ant "}, "mkdocs": {"mkdocs build"}, + "vale": {"vale "}, }[cmd] results contains poutine.finding(rule, pkg_purl, { @@ -134,3 +135,26 @@ find_ado_checkout(stage) := xs if { s[step_attr] == "self" } } + +# Pipeline As Code Tekton + +results contains poutine.finding(rule, pkg.purl, { + "path": pipeline.path, + "job": task.name, + "step": step_idx, + "line": step.lines["script"], + "details": sprintf("Detected usage of `%s`", [cmd]), +}) if { + pkg := input.packages[_] + pipeline := pkg.pipeline_as_code_tekton[_] + contains(pipeline.api_version, "tekton.dev") + pipeline.kind == "PipelineRun" + contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/on-event"], "pull_request") + contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/task"], "git-clone") + task := pipeline.spec.pipeline_spec.tasks[_] + step := task.task_spec.steps[step_idx] + regex.match( + sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]), + step.script, + ) +} diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index a252730..3c56e16 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -397,6 +397,28 @@ func TestFindings(t *testing.T) { Details: "Detected usage of `npm`", }, }, + { + RuleId: "untrusted_checkout_exec", + Purl: purl, + Meta: opa.FindingMeta{ + Path: ".tekton/pipeline-as-code-tekton.yml", + Line: 43, + Job: "vale", + Step: "0", + Details: "Detected usage of `vale`", + }, + }, + { + RuleId: "injection", + Purl: purl, + Meta: opa.FindingMeta{ + Path: ".tekton/pipeline-as-code-tekton.yml", + Line: 45, + Job: "vale", + Step: "1", + Details: "Sources: body.pull_request.body", + }, + }, } assert.Equal(t, len(findings), len(results.Findings)) diff --git a/scanner/scanner.go b/scanner/scanner.go index 943b9e1..f8f1d8c 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -154,6 +154,30 @@ func parseGitlabCi(scanner *Scanner, filePath string, fileInfo fs.FileInfo) erro return nil } +func parsePipelineAsCodeTekton(scanner *Scanner, filePath string, fileInfo fs.FileInfo) error { + relPath, err := filepath.Rel(scanner.Path, filePath) + if err != nil { + return err + } + + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + pipelineAsCode := models.PipelineAsCodeTekton{} + err = yaml.Unmarshal(data, &pipelineAsCode) + if err != nil { + log.Debug().Err(err).Str("file", relPath).Msg("failed to unmarshal pipeline as code yaml file") + return nil + } + + pipelineAsCode.Path = relPath + scanner.Package.PipelineAsCodeTekton = append(scanner.Package.PipelineAsCodeTekton, pipelineAsCode) + + return nil +} + type Scanner struct { Path string Package *models.PackageInsights @@ -169,6 +193,7 @@ func NewScanner(path string) Scanner { ParseFuncs: map[*regexp.Regexp]parseFunc{ regexp.MustCompile(`(\b|/)action\.ya?ml$`): parseGithubActionsMetadata, regexp.MustCompile(`^\.github/workflows/[^/]+\.ya?ml$`): parseGithubWorkflows, + regexp.MustCompile(`^\.tekton/[^/]+\.ya?ml$`): parsePipelineAsCodeTekton, regexp.MustCompile(`\.?azure-pipelines(-.+)?\.ya?ml$`): parseAzurePipelines, regexp.MustCompile(`\.?gitlab-ci(-.+)?\.ya?ml$`): parseGitlabCi, }, diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 8a2dbb3..063d0b6 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "github.com/boostsecurityio/poutine/models" "github.com/boostsecurityio/poutine/opa" "github.com/stretchr/testify/assert" "testing" @@ -69,3 +70,53 @@ func TestRun(t *testing.T) { assert.Contains(t, s.Package.PackageDependencies, "pkg:docker/alpine%3Alatest") assert.Equal(t, 3, len(s.Package.GitlabciConfigs)) } + +func TestPipelineAsCodeTekton(t *testing.T) { + s := NewScanner("testdata") + o, _ := opa.NewOpa() + err := s.Run(context.TODO(), o) + assert.NoError(t, err) + + pipelines := s.Package.PipelineAsCodeTekton + + assert.Len(t, pipelines, 1) + expectedAnnotations := map[string]string{ + "pipelinesascode.tekton.dev/on-event": "[push, pull_request]", + "pipelinesascode.tekton.dev/on-target-branch": "[*]", + "pipelinesascode.tekton.dev/task": "[git-clone]", + } + expectedPipeline := models.PipelineAsCodeTekton{ + ApiVersion: "tekton.dev/v1beta1", + Kind: "PipelineRun", + Metadata: struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + }{ + Name: "linters", + Annotations: expectedAnnotations, + }, + Spec: models.PipelineRunSpec{ + PipelineSpec: &models.PipelineSpec{ + Tasks: []models.PipelineTask{ + { + Name: "fetchit", + }, + { + Name: "vale", + TaskSpec: &models.TaskSpec{ + Steps: []models.Step{ + { + Name: "vale-lint", + Script: "vale docs/content --minAlertLevel=error --output=line\n", + Lines: map[string]int{"script": 43, "start": 40}, + }, + }, + }, + }, + }, + }, + }, + } + assert.Equal(t, expectedPipeline.Metadata, pipelines[0].Metadata) + assert.Equal(t, expectedPipeline.Spec.PipelineSpec.Tasks[1].TaskSpec.Steps[0], pipelines[0].Spec.PipelineSpec.Tasks[1].TaskSpec.Steps[0]) +} diff --git a/scanner/testdata/.tekton/pipeline-as-code-tekton.yml b/scanner/testdata/.tekton/pipeline-as-code-tekton.yml new file mode 100644 index 0000000..783d39a --- /dev/null +++ b/scanner/testdata/.tekton/pipeline-as-code-tekton.yml @@ -0,0 +1,63 @@ +apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: linters + annotations: + pipelinesascode.tekton.dev/on-event: "[push, pull_request]" + pipelinesascode.tekton.dev/on-target-branch: "[*]" + pipelinesascode.tekton.dev/task: "[git-clone]" +spec: + params: + - name: repo_url + value: "{{repo_url}}" + - name: revision + value: "{{revision}}" + pipelineSpec: + params: + - name: repo_url + - name: revision + tasks: + - name: fetchit + displayName: "Fetch git repository" + params: + - name: url + value: $(params.repo_url) + - name: revision + value: $(params.revision) + taskRef: + name: git-clone + workspaces: + - name: output + workspace: source + - name: vale + displayName: "Spelling and Grammar" + runAfter: + - fetchit + taskSpec: + workspaces: + - name: source + steps: + - name: vale-lint + image: jdkato/vale + workingDir: $(workspaces.source.path) + script: | + vale docs/content --minAlertLevel=error --output=line + - name: injection + image: jdkato/vale + workingDir: $(workspaces.source.path) + script: | + binary {{body.pull_request.body}} + workspaces: + - name: source + workspace: source + workspaces: + - name: source + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi \ No newline at end of file