diff --git a/README.md b/README.md index df1304e758..228b3506d4 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ Ratchet is a tool for improving the security of CI/CD workflows by automating the process of pinning and unpinning upstream versions. It's like Bundler, Cargo, Go modules, NPM, Pip, or Yarn, but for CI/CD workflows. Ratchet supports: +- Circle CI - GitHub Actions - Google Cloud Build + ## Problem statement Most CI/CD systems are one layer of indirection away from `curl | sudo bash`. @@ -52,6 +54,9 @@ image: 'ubuntu@sha256:47f14534bda344d9fe6ffd6effb95eefe579f4be0d508b7445cf77f61a # pin the input file ./ratchet pin workflow.yml +# pin a circleci file +./ratchet pin -parser circleci circleci.yml + # pin a cloudbuild file ./ratchet pin -parser cloudbuild cloudbuild.yml @@ -75,6 +80,9 @@ image: 'ubuntu@sha256:47f14534bda344d9fe6ffd6effb95eefe579f4be0d508b7445cf77f61a # update the input file ./ratchet update workflow.yml +# update a circleci file +./ratchet update -parser circleci circleci.yml + # update a cloudbuild file ./ratchet update -parser cloudbuild cloudbuild.yml diff --git a/command/update.go b/command/update.go index 9e813be54f..1cfd30d8a1 100644 --- a/command/update.go +++ b/command/update.go @@ -4,6 +4,8 @@ import ( "context" "flag" "fmt" + "os" + "strings" "github.com/sethvargo/ratchet/parser" "github.com/sethvargo/ratchet/resolver" @@ -37,6 +39,11 @@ func (c *UpdateCommand) Desc() string { func (c *UpdateCommand) Flags() *flag.FlagSet { f := c.PinCommand.Flags() + f.Usage = func() { + fmt.Fprintf(os.Stderr, "%s\n\n", strings.TrimSpace(updateCommandHelp)) + f.PrintDefaults() + } + return f } diff --git a/parser/circleci.go b/parser/circleci.go new file mode 100644 index 0000000000..f25c48d71b --- /dev/null +++ b/parser/circleci.go @@ -0,0 +1,75 @@ +package parser + +import ( + "fmt" + + "github.com/sethvargo/ratchet/resolver" + "gopkg.in/yaml.v3" +) + +type CircleCI struct{} + +// Parse pulls the CircleCI refs from the document. Unfortunately it does not +// process "orbs" because there is no documented API for resolving orbs to an +// absolute version. +func (C *CircleCI) Parse(m *yaml.Node) (*RefsList, error) { + var refs RefsList + + if m == nil { + return nil, nil + } + + if m.Kind != yaml.DocumentNode { + return nil, fmt.Errorf("expected document node, got %v", m.Kind) + } + + // Top-level object map + for _, docMap := range m.Content { + if docMap.Kind != yaml.MappingNode { + continue + } + + // jobs: and executors: keyword + for i, jobsMap := range docMap.Content { + if jobsMap.Value != "jobs" && jobsMap.Value != "executors" { + continue + } + + // Individual job names + jobs := docMap.Content[i+1] + if jobs.Kind != yaml.MappingNode { + continue + } + + for _, jobMap := range jobs.Content { + if jobMap.Kind != yaml.MappingNode { + continue + } + + for j, sub := range jobMap.Content { + // CI service container, should be resolved as a Docker reference. + // This is a map, so the container value is nested a bit deeper. + if sub.Value == "docker" { + servicesMap := jobMap.Content[j+1] + for _, subMap := range servicesMap.Content { + if subMap.Kind != yaml.MappingNode { + continue + } + + for k, property := range subMap.Content { + if property.Value == "image" { + image := subMap.Content[k+1] + ref := resolver.NormalizeContainerRef(image.Value) + refs.Add(ref, image) + break + } + } + } + } + } + } + } + } + + return &refs, nil +} diff --git a/parser/circleci_test.go b/parser/circleci_test.go new file mode 100644 index 0000000000..1f62e8c8eb --- /dev/null +++ b/parser/circleci_test.go @@ -0,0 +1,69 @@ +package parser + +import ( + "reflect" + "testing" +) + +func TestCircleCI_Parse(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + exp []string + }{ + { + name: "mostly_empty_file", + in: ` +executors: +`, + exp: []string{}, + }, + { + name: "executor", + in: ` +executors: + my-executor: + docker: + - image: 'docker://ubuntu:20.04' +`, + exp: []string{ + "container://ubuntu:20.04", + }, + }, + { + name: "job", + in: ` +jobs: + my-job: + docker: + - image: 'ubuntu:20.04' + - image: 'ubuntu:22.04' +`, + exp: []string{ + "container://ubuntu:20.04", + "container://ubuntu:22.04", + }, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + m := helperStringToYAML(t, tc.in) + + refs, err := new(CircleCI).Parse(m) + if err != nil { + t.Fatal(err) + } + + if got, want := refs.Refs(), tc.exp; !reflect.DeepEqual(got, want) { + t.Errorf("expected %q to be %q", got, want) + } + }) + } +} diff --git a/parser/parser.go b/parser/parser.go index 58906ace2d..fa613d851c 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -26,6 +26,7 @@ type Parser interface { var parserFactory = map[string]func() Parser{ "actions": func() Parser { return new(Actions) }, + "circleci": func() Parser { return new(CircleCI) }, "cloudbuild": func() Parser { return new(CloudBuild) }, } diff --git a/testdata/circleci.yml b/testdata/circleci.yml new file mode 100644 index 0000000000..d4cb4d9d58 --- /dev/null +++ b/testdata/circleci.yml @@ -0,0 +1,16 @@ +version: '2.1' + +orbs: + my-orb: 'circleci/hello-build@0.0.5' + my-other-orb: 'circleci/hello-build@0.0.3' + +executors: + my-executor: + docker: + - image: 'cimg/base:2022.05-22.04' + +jobs: + build: + docker: + - image: 'cimg/base:2022.05-22.04' + - image: 'ubuntu:20.04'