Skip to content

Commit b8181bf

Browse files
authored
Support for Pipeline As Code Tekton (#174)
* parse pipeline as code tekton * untrusted checkout pipeline as code tekton * added injection detection for script steps
1 parent b7c3cae commit b8181bf

8 files changed

+275
-0
lines changed

models/package_insights.go

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type PackageInsights struct {
3131
GithubActionsMetadata []GithubActionsMetadata `json:"github_actions_metadata"`
3232
GitlabciConfigs []GitlabciConfig `json:"gitlabci_configs"`
3333
AzurePipelines []AzurePipeline `json:"azure_pipelines"`
34+
PipelineAsCodeTekton []PipelineAsCodeTekton `json:"pipeline_as_code_tekton"`
3435
}
3536

3637
func (p *PackageInsights) GetSourceGitRepoURI() string {

models/pipeline_as_code_tekton.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package models
2+
3+
import "gopkg.in/yaml.v3"
4+
5+
type PipelineAsCodeTekton struct {
6+
ApiVersion string `json:"api_version" yaml:"apiVersion"`
7+
Kind string `json:"kind"`
8+
Metadata struct {
9+
Name string `json:"name"`
10+
Annotations map[string]string `json:"annotations"`
11+
} `json:"metadata"`
12+
Spec PipelineRunSpec `json:"spec,omitempty" yaml:"spec"`
13+
14+
Path string `json:"path" yaml:"-"`
15+
}
16+
17+
type PipelineRunSpec struct {
18+
PipelineSpec *PipelineSpec `json:"pipeline_spec,omitempty" yaml:"pipelineSpec"`
19+
}
20+
21+
type PipelineSpec struct {
22+
Tasks []PipelineTask `json:"tasks,omitempty" yaml:"tasks"`
23+
}
24+
25+
type PipelineTask struct {
26+
Name string `json:"name,omitempty"`
27+
28+
TaskSpec *TaskSpec `json:"task_spec,omitempty" yaml:"taskSpec"`
29+
}
30+
31+
type TaskSpec struct {
32+
Steps []Step `json:"steps,omitempty"`
33+
}
34+
35+
type Step struct {
36+
Name string `json:"name"`
37+
Script string `json:"script,omitempty"`
38+
Lines map[string]int `json:"lines" yaml:"-"`
39+
}
40+
41+
func (o *Step) UnmarshalYAML(node *yaml.Node) error {
42+
type step Step
43+
var s step
44+
if err := node.Decode(&s); err != nil {
45+
return err
46+
}
47+
48+
if node.Kind == yaml.MappingNode {
49+
s.Lines = map[string]int{"start": node.Line}
50+
for i := 0; i < len(node.Content); i += 2 {
51+
key := node.Content[i].Value
52+
switch key {
53+
case "script":
54+
s.Lines[key] = node.Content[i+1].Line
55+
}
56+
}
57+
}
58+
59+
*o = Step(s)
60+
return nil
61+
}

opa/rego/rules/injection.rego

+28
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,31 @@ results contains poutine.finding(rule, pkg.purl, {
101101
exprs := azure_injections(step[attr])
102102
count(exprs) > 0
103103
}
104+
105+
patterns.pipeline_as_code_tekton contains "\\{\\{\\s*(body\\.pull_request\\.(title|user\\.email|body)|source_branch)\\s*\\}\\}"
106+
107+
pipeline_as_code_tekton_injections(str) = {expr |
108+
match := regex.find_n(patterns.pipeline_as_code_tekton[_], str, -1)[_]
109+
expr := regex.find_all_string_submatch_n("\\{\\{\\s*([^}]+?)\\s*\\}\\}", match, 1)[0][1]
110+
}
111+
112+
results contains poutine.finding(rule, pkg.purl, {
113+
"path": pipeline.path,
114+
"job": task.name,
115+
"step": step_idx,
116+
"line": step.lines["start"],
117+
"details": sprintf("Sources: %s", [concat(" ", exprs)]),
118+
}) if {
119+
pkg := input.packages[_]
120+
pipeline := pkg.pipeline_as_code_tekton[_]
121+
contains(pipeline.api_version, "tekton.dev")
122+
pipeline.kind == "PipelineRun"
123+
contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/on-event"], "pull_request")
124+
task := pipeline.spec.pipeline_spec.tasks[_]
125+
step := task.task_spec.steps[step_idx]
126+
127+
exprs := pipeline_as_code_tekton_injections(step.script)
128+
count(exprs) > 0
129+
}
130+
131+

opa/rego/rules/untrusted_checkout_exec.rego

+24
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ build_commands[cmd] = {
4444
"bundler": {"bundle install", "bundle exec "},
4545
"ant": {"^ant "},
4646
"mkdocs": {"mkdocs build"},
47+
"vale": {"vale "},
4748
}[cmd]
4849

4950
results contains poutine.finding(rule, pkg_purl, {
@@ -134,3 +135,26 @@ find_ado_checkout(stage) := xs if {
134135
s[step_attr] == "self"
135136
}
136137
}
138+
139+
# Pipeline As Code Tekton
140+
141+
results contains poutine.finding(rule, pkg.purl, {
142+
"path": pipeline.path,
143+
"job": task.name,
144+
"step": step_idx,
145+
"line": step.lines["script"],
146+
"details": sprintf("Detected usage of `%s`", [cmd]),
147+
}) if {
148+
pkg := input.packages[_]
149+
pipeline := pkg.pipeline_as_code_tekton[_]
150+
contains(pipeline.api_version, "tekton.dev")
151+
pipeline.kind == "PipelineRun"
152+
contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/on-event"], "pull_request")
153+
contains(pipeline.metadata.annotations["pipelinesascode.tekton.dev/task"], "git-clone")
154+
task := pipeline.spec.pipeline_spec.tasks[_]
155+
step := task.task_spec.steps[step_idx]
156+
regex.match(
157+
sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]),
158+
step.script,
159+
)
160+
}

scanner/inventory_test.go

+22
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,28 @@ func TestFindings(t *testing.T) {
397397
Details: "Detected usage of `npm`",
398398
},
399399
},
400+
{
401+
RuleId: "untrusted_checkout_exec",
402+
Purl: purl,
403+
Meta: opa.FindingMeta{
404+
Path: ".tekton/pipeline-as-code-tekton.yml",
405+
Line: 43,
406+
Job: "vale",
407+
Step: "0",
408+
Details: "Detected usage of `vale`",
409+
},
410+
},
411+
{
412+
RuleId: "injection",
413+
Purl: purl,
414+
Meta: opa.FindingMeta{
415+
Path: ".tekton/pipeline-as-code-tekton.yml",
416+
Line: 45,
417+
Job: "vale",
418+
Step: "1",
419+
Details: "Sources: body.pull_request.body",
420+
},
421+
},
400422
}
401423

402424
assert.Equal(t, len(findings), len(results.Findings))

scanner/scanner.go

+25
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,30 @@ func parseGitlabCi(scanner *Scanner, filePath string, fileInfo fs.FileInfo) erro
154154
return nil
155155
}
156156

157+
func parsePipelineAsCodeTekton(scanner *Scanner, filePath string, fileInfo fs.FileInfo) error {
158+
relPath, err := filepath.Rel(scanner.Path, filePath)
159+
if err != nil {
160+
return err
161+
}
162+
163+
data, err := os.ReadFile(filePath)
164+
if err != nil {
165+
return err
166+
}
167+
168+
pipelineAsCode := models.PipelineAsCodeTekton{}
169+
err = yaml.Unmarshal(data, &pipelineAsCode)
170+
if err != nil {
171+
log.Debug().Err(err).Str("file", relPath).Msg("failed to unmarshal pipeline as code yaml file")
172+
return nil
173+
}
174+
175+
pipelineAsCode.Path = relPath
176+
scanner.Package.PipelineAsCodeTekton = append(scanner.Package.PipelineAsCodeTekton, pipelineAsCode)
177+
178+
return nil
179+
}
180+
157181
type Scanner struct {
158182
Path string
159183
Package *models.PackageInsights
@@ -169,6 +193,7 @@ func NewScanner(path string) Scanner {
169193
ParseFuncs: map[*regexp.Regexp]parseFunc{
170194
regexp.MustCompile(`(\b|/)action\.ya?ml$`): parseGithubActionsMetadata,
171195
regexp.MustCompile(`^\.github/workflows/[^/]+\.ya?ml$`): parseGithubWorkflows,
196+
regexp.MustCompile(`^\.tekton/[^/]+\.ya?ml$`): parsePipelineAsCodeTekton,
172197
regexp.MustCompile(`\.?azure-pipelines(-.+)?\.ya?ml$`): parseAzurePipelines,
173198
regexp.MustCompile(`\.?gitlab-ci(-.+)?\.ya?ml$`): parseGitlabCi,
174199
},

scanner/scanner_test.go

+51
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package scanner
22

33
import (
44
"context"
5+
"github.com/boostsecurityio/poutine/models"
56
"github.com/boostsecurityio/poutine/opa"
67
"github.com/stretchr/testify/assert"
78
"testing"
@@ -69,3 +70,53 @@ func TestRun(t *testing.T) {
6970
assert.Contains(t, s.Package.PackageDependencies, "pkg:docker/alpine%3Alatest")
7071
assert.Equal(t, 3, len(s.Package.GitlabciConfigs))
7172
}
73+
74+
func TestPipelineAsCodeTekton(t *testing.T) {
75+
s := NewScanner("testdata")
76+
o, _ := opa.NewOpa()
77+
err := s.Run(context.TODO(), o)
78+
assert.NoError(t, err)
79+
80+
pipelines := s.Package.PipelineAsCodeTekton
81+
82+
assert.Len(t, pipelines, 1)
83+
expectedAnnotations := map[string]string{
84+
"pipelinesascode.tekton.dev/on-event": "[push, pull_request]",
85+
"pipelinesascode.tekton.dev/on-target-branch": "[*]",
86+
"pipelinesascode.tekton.dev/task": "[git-clone]",
87+
}
88+
expectedPipeline := models.PipelineAsCodeTekton{
89+
ApiVersion: "tekton.dev/v1beta1",
90+
Kind: "PipelineRun",
91+
Metadata: struct {
92+
Name string `json:"name"`
93+
Annotations map[string]string `json:"annotations"`
94+
}{
95+
Name: "linters",
96+
Annotations: expectedAnnotations,
97+
},
98+
Spec: models.PipelineRunSpec{
99+
PipelineSpec: &models.PipelineSpec{
100+
Tasks: []models.PipelineTask{
101+
{
102+
Name: "fetchit",
103+
},
104+
{
105+
Name: "vale",
106+
TaskSpec: &models.TaskSpec{
107+
Steps: []models.Step{
108+
{
109+
Name: "vale-lint",
110+
Script: "vale docs/content --minAlertLevel=error --output=line\n",
111+
Lines: map[string]int{"script": 43, "start": 40},
112+
},
113+
},
114+
},
115+
},
116+
},
117+
},
118+
},
119+
}
120+
assert.Equal(t, expectedPipeline.Metadata, pipelines[0].Metadata)
121+
assert.Equal(t, expectedPipeline.Spec.PipelineSpec.Tasks[1].TaskSpec.Steps[0], pipelines[0].Spec.PipelineSpec.Tasks[1].TaskSpec.Steps[0])
122+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
apiVersion: tekton.dev/v1beta1
2+
kind: PipelineRun
3+
metadata:
4+
name: linters
5+
annotations:
6+
pipelinesascode.tekton.dev/on-event: "[push, pull_request]"
7+
pipelinesascode.tekton.dev/on-target-branch: "[*]"
8+
pipelinesascode.tekton.dev/task: "[git-clone]"
9+
spec:
10+
params:
11+
- name: repo_url
12+
value: "{{repo_url}}"
13+
- name: revision
14+
value: "{{revision}}"
15+
pipelineSpec:
16+
params:
17+
- name: repo_url
18+
- name: revision
19+
tasks:
20+
- name: fetchit
21+
displayName: "Fetch git repository"
22+
params:
23+
- name: url
24+
value: $(params.repo_url)
25+
- name: revision
26+
value: $(params.revision)
27+
taskRef:
28+
name: git-clone
29+
workspaces:
30+
- name: output
31+
workspace: source
32+
- name: vale
33+
displayName: "Spelling and Grammar"
34+
runAfter:
35+
- fetchit
36+
taskSpec:
37+
workspaces:
38+
- name: source
39+
steps:
40+
- name: vale-lint
41+
image: jdkato/vale
42+
workingDir: $(workspaces.source.path)
43+
script: |
44+
vale docs/content --minAlertLevel=error --output=line
45+
- name: injection
46+
image: jdkato/vale
47+
workingDir: $(workspaces.source.path)
48+
script: |
49+
binary {{body.pull_request.body}}
50+
workspaces:
51+
- name: source
52+
workspace: source
53+
workspaces:
54+
- name: source
55+
workspaces:
56+
- name: source
57+
volumeClaimTemplate:
58+
spec:
59+
accessModes:
60+
- ReadWriteOnce
61+
resources:
62+
requests:
63+
storage: 5Gi

0 commit comments

Comments
 (0)