├── imgs ├── logo.png ├── tines.png ├── dockle.png ├── usage_fail_light.png ├── usage_pass_light.png ├── cis-benchmark-comparison.png └── original-checkpoint-comparison.png ├── Dockerfile.releaser ├── pkg ├── scanner │ ├── testdata │ │ ├── Dockerfile.scratch │ │ ├── Dockerfile.base │ │ └── scratch.tar │ ├── scan_test.go │ └── scan.go ├── types │ ├── error.go │ ├── assessment.go │ ├── assessment_test.go │ ├── checkpoint.go │ └── image.go ├── color │ └── color.go ├── assessor │ ├── hosts │ │ └── hosts.go │ ├── contentTrust │ │ └── contentTrust.go │ ├── privilege │ │ └── suid.go │ ├── user │ │ └── user.go │ ├── group │ │ └── group.go │ ├── passwd │ │ └── passwd.go │ ├── manifest │ │ ├── testdata │ │ │ ├── add_with_arg_statement.json │ │ │ ├── root_default.json │ │ │ ├── multi_add.json │ │ │ ├── apt_update_upgrade.json │ │ │ ├── apk_cache.json │ │ │ └── nginx.json │ │ ├── manifest.go │ │ └── manifest_test.go │ ├── assessor.go │ ├── cache │ │ └── cache.go │ └── credential │ │ └── credential.go ├── report │ ├── writer.go │ ├── testdata │ │ ├── DKL-DI-0006.sarif │ │ └── CIS-DI-0010.sarif │ ├── list.go │ ├── sarif_test.go │ ├── json.go │ └── sarif.go ├── utils │ └── fetch.go ├── log │ └── logger.go ├── run.go └── app.go ├── .dockerignore ├── .dockleignore ├── .deepsource.toml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST.md │ ├── SUPPORT_QUESTION.md │ └── BUG_REPORT.md └── workflows │ ├── build-scan-on-push.yml │ └── releasebuild.yml ├── cmd └── dockle │ └── main.go ├── Dockerfile ├── config └── config.go ├── goreleaser.yaml ├── go.mod ├── CHECKPOINT.md ├── .vex └── dockle.openvex.json ├── LICENSE └── README.md /imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/logo.png -------------------------------------------------------------------------------- /imgs/tines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/tines.png -------------------------------------------------------------------------------- /imgs/dockle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/dockle.png -------------------------------------------------------------------------------- /Dockerfile.releaser: -------------------------------------------------------------------------------- 1 | FROM alpine:3.17.0_rc1 2 | COPY dockle /usr/bin/dockle 3 | ENTRYPOINT ["/usr/bin/dockle"] -------------------------------------------------------------------------------- /imgs/usage_fail_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/usage_fail_light.png -------------------------------------------------------------------------------- /imgs/usage_pass_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/usage_pass_light.png -------------------------------------------------------------------------------- /pkg/scanner/testdata/Dockerfile.scratch: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ADD Dockerfile.base /credentials.json 3 | USER scratch -------------------------------------------------------------------------------- /imgs/cis-benchmark-comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/cis-benchmark-comparison.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .git 3 | imgs 4 | .github 5 | .goreleaser.yaml 6 | .gitignore 7 | Dockerfile 8 | LICENSE 9 | README.md -------------------------------------------------------------------------------- /imgs/original-checkpoint-comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodwithtech/dockle/HEAD/imgs/original-checkpoint-comparison.png -------------------------------------------------------------------------------- /pkg/types/error.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrSetImageOrFile = errors.New("image name or image file must be specified") 7 | ) 8 | -------------------------------------------------------------------------------- /.dockleignore: -------------------------------------------------------------------------------- 1 | # for CI Azure/container-scan : use latest tag 2 | DKL-DI-0006 3 | # doesn't need HEALTHCHECK for onetime CLI tool 4 | CIS-DI-0006 5 | # dockle sometimes mounts and uses docker.sock 6 | CIS-DI-0001 -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = [ 4 | "**/*_test.go" 5 | ] 6 | 7 | [[analyzers]] 8 | name = "go" 9 | enabled = true 10 | 11 | [analyzers.meta] 12 | import_path = "github.com/goodwithtech/dockle" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | .idea -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | labels: enhancement 4 | about: I have a suggestion (and might want to implement myself)! 5 | --- 6 | 7 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Question 3 | labels: question 4 | about: If you have a question about Dockle. 5 | --- 6 | 7 | 11 | -------------------------------------------------------------------------------- /cmd/dockle/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/goodwithtech/dockle/pkg" 5 | "github.com/goodwithtech/dockle/pkg/log" 6 | l "log" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | app := pkg.NewApp() 12 | err := app.Run(os.Args) 13 | 14 | if err != nil { 15 | if log.Logger != nil { 16 | log.Fatal(err) 17 | } 18 | l.Fatal(err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/color/color.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import "fmt" 4 | 5 | // Foreground colors. 6 | const ( 7 | Red Color = iota + 31 8 | Green 9 | Yellow 10 | Blue 11 | Magenta 12 | Cyan 13 | ) 14 | 15 | // Color represents a text color. 16 | type Color uint8 17 | 18 | // Add adds the coloring to the given string. 19 | func (c Color) Add(s string) string { 20 | return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build-scan-on-push.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | jobs: 5 | build-and-scan: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - run: docker build . -t ${{ github.sha }} 10 | - uses: Azure/container-scan@v0 11 | with: 12 | image-name: ${{ github.sha }} 13 | severity-threshold: CRITICAL 14 | env: 15 | TRIVY_IGNORE_UNFIXED: true 16 | DOCKLE_HOST: "unix:///var/run/docker.sock" -------------------------------------------------------------------------------- /pkg/scanner/testdata/Dockerfile.base: -------------------------------------------------------------------------------- 1 | FROM debian:jessie-slim 2 | 3 | RUN apt-get update && apt-get install -y git 4 | RUN useradd nopasswd -p "" 5 | ADD credentials.json /app/credentials.json 6 | COPY suid.txt once-suid.txt gid.txt once-gid.txt /app/ 7 | RUN chmod u+s /app/suid.txt /app/once-suid.txt && chmod g+s /app/gid.txt /app/once-gid.txt 8 | RUN chmod u-s /app/once-suid.txt && chmod g-s /app/once-gid.txt && echo "once" >> /app/once-suid.txt 9 | ENV MYSQL_PASSWD password 10 | RUN rm /sbin/unix_chkpwd /usr/bin/* 11 | VOLUME /usr 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | labels: bug 4 | about: If something isn't working as expected. 5 | --- 6 | 7 | **Description** 8 | 9 | 12 | 13 | **What did you expect to happen?** 14 | 15 | 16 | **What happened instead?** 17 | 18 | 19 | **Output of run with `-debug`:** 20 | 21 | ``` 22 | (paste your output here) 23 | ``` 24 | 25 | **Output of `dockle -v`:** 26 | 27 | ``` 28 | (paste your output here) 29 | ``` 30 | 31 | **Additional details (base image name, container registry info...):** 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine AS builder 2 | ARG TARGET_DIR="github.com/goodwithtech/dockle" 3 | 4 | WORKDIR /go/src/${TARGET_DIR} 5 | RUN apk --no-cache add git 6 | COPY . . 7 | RUN CGO_ENABLED=0 go build -a -o /dockle ${PWD}/cmd/dockle 8 | 9 | FROM alpine:3.17 10 | COPY --from=builder /dockle /usr/local/bin/dockle 11 | RUN chmod +x /usr/local/bin/dockle 12 | RUN apk --no-cache add ca-certificates shadow 13 | 14 | # for use docker daemon via mounted /var/run/docker.sock 15 | #RUN addgroup -S docker && adduser -S -G docker dockle && usermod -aG root dockle 16 | #USER dockle 17 | 18 | ENTRYPOINT ["dockle"] 19 | -------------------------------------------------------------------------------- /pkg/assessor/hosts/hosts.go: -------------------------------------------------------------------------------- 1 | package hosts 2 | 3 | import ( 4 | "os" 5 | 6 | deckodertypes "github.com/goodwithtech/deckoder/types" 7 | 8 | "github.com/goodwithtech/dockle/pkg/log" 9 | "github.com/goodwithtech/dockle/pkg/types" 10 | ) 11 | 12 | type HostsAssessor struct{} 13 | 14 | func (a HostsAssessor) Assess(_ deckodertypes.FileMap) ([]*types.Assessment, error) { 15 | log.Logger.Debug("Start scan : /etc/hosts") 16 | 17 | assesses := []*types.Assessment{} 18 | // TODO : check hosts setting 19 | return assesses, nil 20 | } 21 | 22 | func (a HostsAssessor) RequiredFiles() []string { 23 | return []string{"etc/hosts"} 24 | } 25 | 26 | func (a HostsAssessor) RequiredExtensions() []string { 27 | return []string{} 28 | } 29 | 30 | func (a HostsAssessor) RequiredPermissions() []os.FileMode { 31 | return []os.FileMode{} 32 | } 33 | -------------------------------------------------------------------------------- /pkg/assessor/contentTrust/contentTrust.go: -------------------------------------------------------------------------------- 1 | package contentTrust 2 | 3 | import ( 4 | "os" 5 | 6 | deckodertypes "github.com/goodwithtech/deckoder/types" 7 | 8 | "github.com/goodwithtech/dockle/pkg/log" 9 | "github.com/goodwithtech/dockle/pkg/types" 10 | ) 11 | 12 | var HostEnvironmentFileName = "ENVIRONMENT variable on HOST OS" 13 | 14 | type ContentTrustAssessor struct{} 15 | 16 | func (a ContentTrustAssessor) Assess(_ deckodertypes.FileMap) ([]*types.Assessment, error) { 17 | log.Logger.Debug("Scan start : DOCKER_CONTENT_TRUST") 18 | 19 | if os.Getenv("DOCKER_CONTENT_TRUST") != "1" { 20 | return []*types.Assessment{ 21 | { 22 | Code: types.UseContentTrust, 23 | Filename: HostEnvironmentFileName, 24 | Desc: "export DOCKER_CONTENT_TRUST=1 before docker pull/build", 25 | }, 26 | }, nil 27 | } 28 | return nil, nil 29 | } 30 | 31 | func (a ContentTrustAssessor) RequiredFiles() []string { 32 | return []string{} 33 | } 34 | 35 | func (a ContentTrustAssessor) RequiredExtensions() []string { 36 | return []string{} 37 | } 38 | 39 | func (a ContentTrustAssessor) RequiredPermissions() []os.FileMode { 40 | return []os.FileMode{} 41 | } 42 | -------------------------------------------------------------------------------- /pkg/report/writer.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/goodwithtech/dockle/config" 7 | "github.com/goodwithtech/dockle/pkg/types" 8 | ) 9 | 10 | var AlertLabels = map[int]string{ 11 | types.InfoLevel: "INFO", 12 | types.WarnLevel: "WARN", 13 | types.FatalLevel: "FATAL", 14 | types.PassLevel: "PASS", 15 | types.SkipLevel: "SKIP", 16 | types.IgnoreLevel: "IGNORE", 17 | } 18 | var sarifAlertLabels = map[int]string{ 19 | types.InfoLevel: "note", 20 | types.WarnLevel: "warning", 21 | types.FatalLevel: "error", 22 | types.PassLevel: "none", 23 | types.SkipLevel: "none", 24 | types.IgnoreLevel: "none", 25 | } 26 | 27 | type Writer interface { 28 | Write(assessments types.AssessmentMap) (bool, error) 29 | } 30 | 31 | func getCodeOrder() []types.Assessment { 32 | ass := types.ByLevel{} 33 | for code, level := range types.DefaultLevelMap { 34 | if _, ok := config.Conf.IgnoreMap[code]; ok { 35 | ass = append(ass, types.Assessment{ 36 | Code: code, 37 | Level: types.IgnoreLevel, 38 | }) 39 | continue 40 | } 41 | ass = append(ass, types.Assessment{ 42 | Code: code, 43 | Level: level, 44 | }) 45 | } 46 | sort.Sort(ass) 47 | return ass 48 | } 49 | -------------------------------------------------------------------------------- /pkg/assessor/privilege/suid.go: -------------------------------------------------------------------------------- 1 | package privilege 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | deckodertypes "github.com/goodwithtech/deckoder/types" 8 | 9 | "github.com/goodwithtech/dockle/pkg/types" 10 | ) 11 | 12 | type PrivilegeAssessor struct{} 13 | 14 | func (a PrivilegeAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 15 | var assesses []*types.Assessment 16 | 17 | for filename, filedata := range fileMap { 18 | if filedata.FileMode&os.ModeSetuid != 0 { 19 | assesses = append( 20 | assesses, 21 | &types.Assessment{ 22 | Code: types.CheckSuidGuid, 23 | Filename: filename, 24 | Desc: fmt.Sprintf("setuid file: %s %s", filedata.FileMode, filename), 25 | }) 26 | } 27 | if filedata.FileMode&os.ModeSetgid != 0 { 28 | assesses = append( 29 | assesses, 30 | &types.Assessment{ 31 | Code: types.CheckSuidGuid, 32 | Filename: filename, 33 | Desc: fmt.Sprintf("setgid file: %s %s", filedata.FileMode, filename), 34 | }) 35 | } 36 | 37 | } 38 | return assesses, nil 39 | } 40 | 41 | func (a PrivilegeAssessor) RequiredFiles() []string { 42 | return []string{} 43 | } 44 | 45 | func (a PrivilegeAssessor) RequiredExtensions() []string { 46 | return []string{} 47 | } 48 | 49 | //const GidMode os.FileMode = 4000 50 | func (a PrivilegeAssessor) RequiredPermissions() []os.FileMode { 51 | return []os.FileMode{os.ModeSetgid, os.ModeSetuid} 52 | } 53 | -------------------------------------------------------------------------------- /pkg/types/assessment.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Assessment struct { 4 | Code string 5 | Level int 6 | Filename string 7 | Desc string 8 | } 9 | type AssessmentSlice []*Assessment 10 | type CodeInfo struct { 11 | Code string 12 | Level int 13 | Assessments AssessmentSlice 14 | } 15 | type AssessmentMap map[string]CodeInfo 16 | 17 | func CreateAssessmentMap(as AssessmentSlice, ignoreMap map[string]struct{}, debug bool) AssessmentMap { 18 | asMap := AssessmentMap{} 19 | for _, a := range as { 20 | level := a.Level 21 | if level == 0 { 22 | level = DefaultLevelMap[a.Code] 23 | } 24 | if _, ok := ignoreMap[a.Code]; ok { 25 | // ignore level only shows DEBUG mode 26 | if !debug { 27 | continue 28 | } 29 | level = IgnoreLevel 30 | } 31 | if _, ok := asMap[a.Code]; !ok { 32 | asMap[a.Code] = CodeInfo{ 33 | Code: a.Code, 34 | Level: level, 35 | Assessments: []*Assessment{a}, 36 | } 37 | } else { 38 | asMap[a.Code] = CodeInfo{ 39 | Code: a.Code, 40 | Level: level, 41 | Assessments: append(asMap[a.Code].Assessments, a), 42 | } 43 | } 44 | } 45 | return asMap 46 | } 47 | 48 | type ByLevel []Assessment 49 | 50 | func (a ByLevel) Len() int { return len(a) } 51 | func (a ByLevel) Less(i, j int) bool { 52 | if a[i].Level == a[j].Level { 53 | return a[i].Code < a[j].Code 54 | } 55 | return a[i].Level > a[j].Level 56 | } 57 | func (a ByLevel) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 58 | -------------------------------------------------------------------------------- /pkg/report/testdata/DKL-DI-0006.sarif: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.0", 3 | "$schema": "https://json.schemastore.org/sarif-2.1.0-rtm.5.json", 4 | "runs": [ 5 | { 6 | "tool": { 7 | "driver": { 8 | "informationUri": "https://github.com/goodwithtech/dockle", 9 | "name": "Dockle", 10 | "rules": [ 11 | { 12 | "id": "DKL-DI-0006", 13 | "name": "DKL-DI-0006", 14 | "shortDescription": { 15 | "text": "Avoid latest tag" 16 | }, 17 | "helpUri": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#DKL-DI-0006" 18 | } 19 | ] 20 | } 21 | }, 22 | "results": [ 23 | { 24 | "ruleId": "DKL-DI-0006", 25 | "ruleIndex": 0, 26 | "level": "warning", 27 | "message": { 28 | "text": "Avoid 'latest' tag" 29 | }, 30 | "locations": [ 31 | { 32 | "physicalLocation": { 33 | "artifactLocation": { 34 | "uri": "alpine:latest" 35 | } 36 | } 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /pkg/report/testdata/CIS-DI-0010.sarif: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.0", 3 | "$schema": "https://json.schemastore.org/sarif-2.1.0-rtm.5.json", 4 | "runs": [ 5 | { 6 | "tool": { 7 | "driver": { 8 | "informationUri": "https://github.com/goodwithtech/dockle", 9 | "name": "Dockle", 10 | "rules": [ 11 | { 12 | "id": "CIS-DI-0010", 13 | "name": "CIS-DI-0010", 14 | "shortDescription": { 15 | "text": "Do not store credential in environment variables/files" 16 | }, 17 | "helpUri": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0010" 18 | } 19 | ] 20 | } 21 | }, 22 | "results": [ 23 | { 24 | "ruleId": "CIS-DI-0010", 25 | "ruleIndex": 0, 26 | "level": "error", 27 | "message": { 28 | "text": "Suspicious filename found" 29 | }, 30 | "locations": [ 31 | { 32 | "physicalLocation": { 33 | "artifactLocation": { 34 | "uri": "/some/abs/path" 35 | } 36 | } 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /pkg/utils/fetch.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "time" 9 | 10 | "github.com/goodwithtech/dockle/pkg/log" 11 | ) 12 | 13 | var versionPattern = regexp.MustCompile(`v[0-9]+\.[0-9]+\.[0-9]+`) 14 | 15 | func fetchLocation(ctx context.Context, url string, cookie *http.Cookie) (*string, error) { 16 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 17 | if err != nil { 18 | return nil, fmt.Errorf("new request: %w", err) 19 | } 20 | req.AddCookie(cookie) 21 | resp, err := (&http.Client{ 22 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 23 | return http.ErrUseLastResponse 24 | }, 25 | Timeout: time.Second * 3, 26 | }).Do(req) 27 | if err != nil { 28 | return nil, fmt.Errorf("fetch: %w", err) 29 | } 30 | defer resp.Body.Close() 31 | if resp.StatusCode != 302 { 32 | return nil, fmt.Errorf("HTTP error code : %d, url : %s", resp.StatusCode, url) 33 | } 34 | location, err := resp.Location() 35 | if err != nil { 36 | return nil, err 37 | } 38 | locationString := location.String() 39 | return &locationString, nil 40 | } 41 | 42 | func FetchLatestVersion(ctx context.Context) (version string, err error) { 43 | log.Logger.Debug("Fetch latest version from github") 44 | body, err := fetchLocation( 45 | ctx, 46 | "https://github.com/goodwithtech/dockle/releases/latest", 47 | &http.Cookie{Name: "user_session", Value: "guard"}, 48 | ) 49 | if err != nil { 50 | return "", err 51 | } 52 | if versionMatched := versionPattern.FindString(*body); versionMatched != "" { 53 | return versionMatched, nil 54 | } 55 | return "", fmt.Errorf("not found version patterns parsing GH response: %s", *body) 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/releasebuild.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - 'master' 5 | tags: 6 | - 'v*' 7 | pull_request: 8 | 9 | jobs: 10 | build-and-release: 11 | runs-on: ubuntu-latest 12 | env: 13 | DOCKER_CLI_EXPERIMENTAL: "enabled" 14 | steps: 15 | - uses: actions/checkout@master 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: 'go.mod' 21 | - uses: actions/cache@v3.2.2 22 | with: 23 | path: ~/go/pkg/mod 24 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 25 | restore-keys: | 26 | ${{ runner.os }}-go- 27 | - run: | 28 | go test ./... 29 | env: 30 | CGO_ENABLED: 0 31 | - name: Login to docker.io registry 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | - name: Login to ghcr.io registry 37 | uses: docker/login-action@v1 38 | with: 39 | registry: ghcr.io 40 | username: goodwithtech 41 | password: ${{ secrets.GH_PAT }} 42 | - 43 | name: Run GoReleaser 44 | if: success() && startsWith(github.ref, 'refs/tags/v') 45 | uses: goreleaser/goreleaser-action@v2 46 | with: 47 | distribution: goreleaser 48 | version: "~> v2" 49 | args: release --clean 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 52 | - 53 | name: Clear 54 | if: always() && startsWith(github.ref, 'refs/tags/v') 55 | run: | 56 | rm -f ${HOME}/.docker/config.json -------------------------------------------------------------------------------- /pkg/assessor/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | deckodertypes "github.com/goodwithtech/deckoder/types" 11 | 12 | "github.com/goodwithtech/dockle/pkg/log" 13 | "github.com/goodwithtech/dockle/pkg/types" 14 | ) 15 | 16 | type UserAssessor struct{} 17 | 18 | func (a UserAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 19 | log.Logger.Debug("Start scan : /etc/passwd") 20 | 21 | var existFile bool 22 | assesses := []*types.Assessment{} 23 | for _, filename := range a.RequiredFiles() { 24 | file, ok := fileMap[filename] 25 | if !ok { 26 | continue 27 | } 28 | existFile = true 29 | scanner := bufio.NewScanner(bytes.NewBuffer(file.Body)) 30 | uidMap := map[string]struct{}{} 31 | for scanner.Scan() { 32 | line := scanner.Text() 33 | data := strings.Split(line, ":") 34 | uname := data[0] 35 | uid := data[2] 36 | 37 | // check duplicate UID 38 | if _, ok := uidMap[uid]; ok { 39 | assesses = append( 40 | assesses, 41 | &types.Assessment{ 42 | Code: types.AvoidDuplicateUserGroup, 43 | Filename: filename, 44 | Desc: fmt.Sprintf("duplicate UID %s : username %s", uid, uname), 45 | }) 46 | } 47 | uidMap[uid] = struct{}{} 48 | } 49 | } 50 | if !existFile { 51 | assesses = []*types.Assessment{{ 52 | Code: types.AvoidDuplicateUserGroup, 53 | Level: types.SkipLevel, 54 | Desc: fmt.Sprintf("failed to detect %s", strings.Join(a.RequiredFiles(), ",")), 55 | }} 56 | } 57 | 58 | return assesses, nil 59 | } 60 | 61 | func (a UserAssessor) RequiredFiles() []string { 62 | return []string{"etc/passwd"} 63 | } 64 | 65 | func (a UserAssessor) RequiredExtensions() []string { 66 | return []string{} 67 | } 68 | 69 | func (a UserAssessor) RequiredPermissions() []os.FileMode { 70 | return []os.FileMode{} 71 | } 72 | -------------------------------------------------------------------------------- /pkg/assessor/group/group.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | deckodertypes "github.com/goodwithtech/deckoder/types" 11 | 12 | "github.com/goodwithtech/dockle/pkg/log" 13 | 14 | "github.com/goodwithtech/dockle/pkg/types" 15 | ) 16 | 17 | type GroupAssessor struct{} 18 | 19 | func (a GroupAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 20 | log.Logger.Debug("Start scan : /etc/group") 21 | 22 | var existFile bool 23 | assesses := []*types.Assessment{} 24 | for _, filename := range a.RequiredFiles() { 25 | file, ok := fileMap[filename] 26 | if !ok { 27 | continue 28 | } 29 | existFile = true 30 | scanner := bufio.NewScanner(bytes.NewBuffer(file.Body)) 31 | gidMap := map[string]struct{}{} 32 | 33 | for scanner.Scan() { 34 | line := scanner.Text() 35 | data := strings.Split(line, ":") 36 | gname := data[0] 37 | gid := data[2] 38 | 39 | if _, ok := gidMap[gid]; ok { 40 | assesses = append( 41 | assesses, 42 | &types.Assessment{ 43 | Code: types.AvoidDuplicateUserGroup, 44 | Filename: filename, 45 | Desc: fmt.Sprintf("duplicate GroupID %s : username %s", gid, gname), 46 | }) 47 | } 48 | gidMap[gid] = struct{}{} 49 | } 50 | } 51 | if !existFile { 52 | assesses = []*types.Assessment{ 53 | { 54 | Code: types.AvoidDuplicateUserGroup, 55 | Level: types.SkipLevel, 56 | Desc: fmt.Sprintf("failed to detect %s", strings.Join(a.RequiredFiles(), ",")), 57 | }, 58 | } 59 | } 60 | 61 | return assesses, nil 62 | } 63 | 64 | func (a GroupAssessor) RequiredFiles() []string { 65 | return []string{"etc/group"} 66 | } 67 | 68 | func (a GroupAssessor) RequiredExtensions() []string { 69 | return []string{} 70 | } 71 | 72 | func (a GroupAssessor) RequiredPermissions() []os.FileMode { 73 | return []os.FileMode{} 74 | } 75 | -------------------------------------------------------------------------------- /pkg/log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "go.uber.org/zap" 6 | "go.uber.org/zap/zapcore" 7 | "os" 8 | ) 9 | 10 | var ( 11 | Logger *zap.SugaredLogger 12 | debugOption bool 13 | ) 14 | 15 | func InitLogger(debug, quiet bool) (err error) { 16 | debugOption = debug 17 | Logger, err = newLogger(debug, quiet) 18 | if err != nil { 19 | return fmt.Errorf("error in new logger: %w", err) 20 | } 21 | return nil 22 | } 23 | 24 | func newLogger(debug, quiet bool) (*zap.SugaredLogger, error) { 25 | level := zap.NewAtomicLevel() 26 | if debug { 27 | level.SetLevel(zapcore.DebugLevel) 28 | } else { 29 | level.SetLevel(zapcore.InfoLevel) 30 | } 31 | 32 | stdout := "stdout" 33 | stderr := "stderr" 34 | if quiet { 35 | if _, err := os.Create(os.DevNull); err != nil { 36 | return nil, err 37 | } 38 | stdout = os.DevNull 39 | stderr = os.DevNull 40 | } 41 | 42 | myConfig := zap.Config{ 43 | Level: level, 44 | Encoding: "console", 45 | Development: debug, 46 | DisableStacktrace: true, 47 | DisableCaller: true, 48 | EncoderConfig: zapcore.EncoderConfig{ 49 | TimeKey: "Time", 50 | LevelKey: "Level", 51 | NameKey: "Name", 52 | CallerKey: "Caller", 53 | MessageKey: "Msg", 54 | StacktraceKey: "St", 55 | EncodeLevel: zapcore.CapitalColorLevelEncoder, 56 | EncodeTime: zapcore.ISO8601TimeEncoder, 57 | EncodeDuration: zapcore.StringDurationEncoder, 58 | EncodeCaller: zapcore.ShortCallerEncoder, 59 | }, 60 | OutputPaths: []string{stdout}, 61 | ErrorOutputPaths: []string{stderr}, 62 | } 63 | logger, err := myConfig.Build() 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to build zap config: %w", err) 66 | } 67 | 68 | return logger.Sugar(), nil 69 | } 70 | 71 | func Fatal(err error) { 72 | if debugOption { 73 | Logger.Fatalf("%+v", err) 74 | } 75 | Logger.Fatal(err) 76 | } 77 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | 8 | "github.com/goodwithtech/dockle/pkg/types" 9 | 10 | "github.com/goodwithtech/dockle/pkg/log" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | const ( 15 | dockleIgnore = ".dockleignore" 16 | ) 17 | 18 | var exitLevelMap = map[string]int{ 19 | "info": types.InfoLevel, 20 | "INFO": types.InfoLevel, 21 | "warn": types.WarnLevel, 22 | "WARN": types.WarnLevel, 23 | "fatal": types.FatalLevel, 24 | "FATAL": types.FatalLevel, 25 | } 26 | 27 | type Config struct { 28 | IgnoreMap map[string]struct{} 29 | ExitCode int 30 | ExitLevel int 31 | } 32 | 33 | var Conf Config 34 | 35 | func CreateFromCli(c *cli.Context) { 36 | ignoreRules := c.StringSlice("ignore") 37 | Conf = Config{ 38 | IgnoreMap: getIgnoreCheckpointMap(ignoreRules), 39 | ExitCode: c.Int("exit-code"), 40 | ExitLevel: getExitLevel(c.String("exit-level")), 41 | } 42 | } 43 | 44 | func getExitLevel(param string) (exitLevel int) { 45 | exitLevel, ok := exitLevelMap[param] 46 | if !ok { 47 | return types.WarnLevel 48 | } 49 | return exitLevel 50 | } 51 | 52 | func getIgnoreCheckpointMap(ignoreRules []string) map[string]struct{} { 53 | ignoreCheckpointMap := map[string]struct{}{} 54 | // from cli command 55 | for _, rule := range ignoreRules { 56 | ignoreCheckpointMap[rule] = struct{}{} 57 | } 58 | 59 | // from ignore file 60 | f, err := os.Open(dockleIgnore) 61 | if err != nil { 62 | log.Logger.Debug("There is no .dockleignore file") 63 | // dockle must work even if there isn't ignore file 64 | return ignoreCheckpointMap 65 | } 66 | scanner := bufio.NewScanner(f) 67 | for scanner.Scan() { 68 | line := scanner.Text() 69 | line = strings.TrimSpace(line) 70 | if strings.HasPrefix(line, "#") || line == "" { 71 | continue 72 | } 73 | log.Logger.Debugf("Add new ignore code: %s", line) 74 | ignoreCheckpointMap[line] = struct{}{} 75 | } 76 | return ignoreCheckpointMap 77 | } 78 | -------------------------------------------------------------------------------- /pkg/assessor/passwd/passwd.go: -------------------------------------------------------------------------------- 1 | package passwd 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | deckodertypes "github.com/goodwithtech/deckoder/types" 11 | 12 | "github.com/goodwithtech/dockle/pkg/log" 13 | 14 | "github.com/goodwithtech/dockle/pkg/types" 15 | ) 16 | 17 | type PasswdAssessor struct{} 18 | 19 | func (a PasswdAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 20 | log.Logger.Debug("Start scan : password files") 21 | 22 | var existFile bool 23 | assesses := []*types.Assessment{} 24 | for _, filename := range a.RequiredFiles() { 25 | file, ok := fileMap[filename] 26 | if !ok { 27 | continue 28 | } 29 | existFile = true 30 | scanner := bufio.NewScanner(bytes.NewBuffer(file.Body)) 31 | for scanner.Scan() { 32 | line := scanner.Text() 33 | if len(line) == 0 || line[0] == '#' { 34 | continue 35 | } 36 | passData := strings.Split(line, ":") 37 | if len(passData) < 2 { 38 | log.Logger.Debug("The password format may be invalid.", line) 39 | } else if passData[1] == "" { 40 | assesses = append( 41 | assesses, 42 | &types.Assessment{ 43 | Code: types.AvoidEmptyPassword, 44 | Filename: filename, 45 | Desc: fmt.Sprintf("No password user found! username : %s", passData[0]), 46 | }) 47 | } 48 | } 49 | } 50 | if !existFile { 51 | assesses = []*types.Assessment{ 52 | { 53 | Code: types.AvoidEmptyPassword, 54 | Level: types.SkipLevel, 55 | Desc: fmt.Sprintf("failed to detect %s", strings.Join(a.RequiredFiles(), ",")), 56 | }, 57 | } 58 | } 59 | return assesses, nil 60 | } 61 | 62 | func (a PasswdAssessor) RequiredFiles() []string { 63 | return []string{"etc/shadow", "etc/master.passwd"} 64 | } 65 | 66 | func (a PasswdAssessor) RequiredExtensions() []string { 67 | return []string{} 68 | } 69 | 70 | func (a PasswdAssessor) RequiredPermissions() []os.FileMode { 71 | return []os.FileMode{} 72 | } 73 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/add_with_arg_statement.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "arm64", 3 | "config": { 4 | "Env": [ 5 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 6 | ], 7 | "Cmd": [ 8 | "/bin/bash" 9 | ], 10 | "Labels": { 11 | "org.opencontainers.image.ref.name": "ubuntu", 12 | "org.opencontainers.image.version": "22.04" 13 | }, 14 | "OnBuild": null 15 | }, 16 | "created": "2023-07-06T14:45:51.166292881Z", 17 | "history": [ 18 | { 19 | "created": "2023-06-28T08:42:48.423710828Z", 20 | "created_by": "/bin/sh -c #(nop) ARG RELEASE", 21 | "empty_layer": true 22 | }, 23 | { 24 | "created": "2023-06-28T08:42:48.499325674Z", 25 | "created_by": "/bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH", 26 | "empty_layer": true 27 | }, 28 | { 29 | "created": "2023-06-28T08:42:48.566770422Z", 30 | "created_by": "/bin/sh -c #(nop) LABEL org.opencontainers.image.ref.name=ubuntu", 31 | "empty_layer": true 32 | }, 33 | { 34 | "created": "2023-06-28T08:42:48.653381453Z", 35 | "created_by": "/bin/sh -c #(nop) LABEL org.opencontainers.image.version=22.04", 36 | "empty_layer": true 37 | }, 38 | { 39 | "created": "2023-06-28T08:42:50.199969775Z", 40 | "created_by": "/bin/sh -c #(nop) ADD file:262490f82459c14632f5c9a6d6a5cf6c07b4f307e8fd380fa764662cda46e88f in / " 41 | }, 42 | { 43 | "created": "2023-06-28T08:42:50.42500211Z", 44 | "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]", 45 | "empty_layer": true 46 | }, 47 | { 48 | "created": "2023-07-06T14:45:51.166292881Z", 49 | "created_by": "RUN /bin/sh -c echo \"hello\" # buildkit", 50 | "comment": "buildkit.dockerfile.v0" 51 | } 52 | ], 53 | "os": "linux", 54 | "rootfs": { 55 | "type": "layers", 56 | "diff_ids": [ 57 | "sha256:c5ca84f245d30117a9a2720cb4297cedf3642816471d4d699f4d77e39e13a39c", 58 | "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" 59 | ] 60 | }, 61 | "variant": "v8" 62 | } 63 | -------------------------------------------------------------------------------- /pkg/report/list.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/goodwithtech/dockle/config" 8 | 9 | "github.com/goodwithtech/dockle/pkg/color" 10 | "github.com/goodwithtech/dockle/pkg/types" 11 | ) 12 | 13 | const ( 14 | LISTMARK = "*" 15 | COLON = ":" 16 | SPACE = " " 17 | TAB = " " 18 | NEWLINE = "\n" 19 | ) 20 | 21 | var AlertLevelColors = map[int]color.Color{ 22 | types.InfoLevel: color.Magenta, 23 | types.WarnLevel: color.Yellow, 24 | types.FatalLevel: color.Red, 25 | types.PassLevel: color.Green, 26 | types.SkipLevel: color.Blue, 27 | types.IgnoreLevel: color.Blue, 28 | } 29 | 30 | type ListWriter struct { 31 | Output io.Writer 32 | NoColor bool 33 | } 34 | 35 | func (lw ListWriter) Write(assessMap types.AssessmentMap) (abend bool, err error) { 36 | codeOrderLevel := getCodeOrder() 37 | for _, ass := range codeOrderLevel { 38 | if _, ok := assessMap[ass.Code]; !ok { 39 | continue 40 | } 41 | assess := assessMap[ass.Code] 42 | lw.showTargetResult(assess.Code, assess.Level, assess.Assessments) 43 | if assess.Level >= config.Conf.ExitLevel { 44 | abend = true 45 | } 46 | } 47 | return abend, nil 48 | } 49 | 50 | func (lw ListWriter) showTargetResult(code string, level int, assessments []*types.Assessment) { 51 | lw.showTitleLine(code, level) 52 | if level > types.IgnoreLevel { 53 | for _, assessment := range assessments { 54 | lw.showDescription(assessment) 55 | } 56 | } 57 | } 58 | 59 | func (lw ListWriter) showTitleLine(code string, level int) { 60 | if lw.NoColor { 61 | fmt.Fprint(lw.Output, AlertLabels[level], TAB, "-", SPACE, code, COLON, SPACE, types.TitleMap[code], NEWLINE) 62 | return 63 | } 64 | cyan := color.Cyan 65 | fmt.Fprint(lw.Output, colorizeAlert(level), TAB, "-", SPACE, cyan.Add(code), COLON, SPACE, types.TitleMap[code], NEWLINE) 66 | } 67 | 68 | func (lw ListWriter) showDescription(assessment *types.Assessment) { 69 | fmt.Fprint(lw.Output, TAB, LISTMARK, SPACE, assessment.Desc, NEWLINE) 70 | } 71 | 72 | func colorizeAlert(alertLevel int) string { 73 | return AlertLevelColors[alertLevel].Add(AlertLabels[alertLevel]) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/root_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "amd64", 3 | "config": { 4 | "Hostname": "", 5 | "Domainname": "", 6 | "User": "", 7 | "AttachStdin": false, 8 | "AttachStdout": false, 9 | "AttachStderr": false, 10 | "Tty": false, 11 | "OpenStdin": false, 12 | "StdinOnce": false, 13 | "Env": [ 14 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 15 | ], 16 | "Cmd": [ 17 | "/bin/sh" 18 | ], 19 | "ArgsEscaped": true, 20 | "Image": "sha256:3e4931917957608ce24efef27c24e999b6c4ecc112d4ad11e6c4bb9ec6bfb2ee", 21 | "Volumes": null, 22 | "WorkingDir": "", 23 | "Entrypoint": null, 24 | "OnBuild": null, 25 | "Labels": null 26 | }, 27 | "container": "17551f3be084475a267672aa096b8613660da1e59d2d7e897006386e45d3d2de", 28 | "container_config": { 29 | "Hostname": "17551f3be084", 30 | "Domainname": "", 31 | "User": "", 32 | "AttachStdin": false, 33 | "AttachStdout": false, 34 | "AttachStderr": false, 35 | "Tty": false, 36 | "OpenStdin": false, 37 | "StdinOnce": false, 38 | "Env": [ 39 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 40 | ], 41 | "Cmd": [ 42 | "/bin/sh", 43 | "-c", 44 | "#(nop) ", 45 | "CMD [\"/bin/sh\"]" 46 | ], 47 | "ArgsEscaped": true, 48 | "Image": "sha256:3e4931917957608ce24efef27c24e999b6c4ecc112d4ad11e6c4bb9ec6bfb2ee", 49 | "Volumes": null, 50 | "WorkingDir": "", 51 | "Entrypoint": null, 52 | "OnBuild": null, 53 | "Labels": {} 54 | }, 55 | "created": "2019-04-09T23:20:18.391201481Z", 56 | "docker_version": "18.06.1-ce", 57 | "history": [ 58 | { 59 | "created": "2019-04-09T23:20:18.226548168Z", 60 | "created_by": "/bin/sh -c #(nop) ADD file:2e3a37883f56a4a278bec2931fc9f91fb9ebdaa9047540fe8fde419b84a1701b in / " 61 | }, 62 | { 63 | "created": "2019-04-09T23:20:18.391201481Z", 64 | "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", 65 | "empty_layer": true 66 | } 67 | ], 68 | "os": "linux", 69 | "rootfs": { 70 | "type": "layers", 71 | "diff_ids": [ 72 | "sha256:a464c54f93a9e88fc1d33df1e0e39cca427d60145a360962e8f19a1dbf900da9" 73 | ] 74 | } 75 | } -------------------------------------------------------------------------------- /pkg/report/sarif_test.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | 11 | "github.com/goodwithtech/dockle/pkg/types" 12 | ) 13 | 14 | func TestSarifWriter_Write(t *testing.T) { 15 | tests := []struct { 16 | description string 17 | assessments types.AssessmentSlice 18 | sarif string 19 | }{ 20 | { 21 | description: "Should include location when URI", 22 | assessments: types.AssessmentSlice{ 23 | { 24 | Code: "DKL-DI-0006", 25 | Filename: "alpine:latest", 26 | Desc: "Avoid 'latest' tag", 27 | }, 28 | }, 29 | sarif: "./testdata/DKL-DI-0006.sarif", 30 | }, 31 | { 32 | description: "Should include location when file path", 33 | assessments: types.AssessmentSlice{ 34 | { 35 | Code: "CIS-DI-0010", 36 | Filename: "/some/abs/path", 37 | Desc: "Suspicious filename found", 38 | }, 39 | }, 40 | sarif: "./testdata/CIS-DI-0010.sarif", 41 | }, 42 | } 43 | for _, tt := range tests { 44 | t.Run(tt.description, func(t *testing.T) { 45 | // Generate the assessment map 46 | am := types.CreateAssessmentMap( 47 | tt.assessments, 48 | map[string]struct{}{}, 49 | false, 50 | ) 51 | 52 | // Write the serif report to a buffer 53 | output := &bytes.Buffer{} 54 | writer := &SarifWriter{Output: output} 55 | _, err := writer.Write(am) 56 | if err != nil { 57 | t.Errorf("Write error: %v", err) 58 | } 59 | 60 | // parse that JSON into a map for easy comparison 61 | var actual map[string]interface{} 62 | err = json.NewDecoder(output).Decode(&actual) 63 | if err != nil { 64 | t.Errorf("Decode error: %v", err) 65 | } 66 | 67 | expected := loadSarifFixture(t, tt.sarif) 68 | if diff := cmp.Diff(expected, actual); diff != "" { 69 | t.Errorf("diff: %v", diff) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func loadSarifFixture(t testing.TB, path string) map[string]interface{} { 76 | data, err := os.ReadFile(path) 77 | if err != nil { 78 | t.Errorf("Fixture read error: %v", err) 79 | } 80 | 81 | var sarif map[string]interface{} 82 | err = json.Unmarshal(data, &sarif) 83 | if err != nil { 84 | t.Errorf("Fixture decode error: %v", err) 85 | } 86 | 87 | return sarif 88 | } 89 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/multi_add.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "arm64", 3 | "config": { 4 | "Env": [ 5 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 6 | ], 7 | "Cmd": [ 8 | "/bin/bash" 9 | ], 10 | "Labels": { 11 | "org.opencontainers.image.ref.name": "ubuntu", 12 | "org.opencontainers.image.version": "22.04" 13 | }, 14 | "OnBuild": null 15 | }, 16 | "created": "2023-07-06T14:45:51.166292881Z", 17 | "history": [ 18 | { 19 | "created": "2023-06-28T08:42:48.423710828Z", 20 | "created_by": "/bin/sh -c #(nop) ARG RELEASE", 21 | "empty_layer": true 22 | }, 23 | { 24 | "created": "2023-06-28T08:42:48.499325674Z", 25 | "created_by": "/bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH", 26 | "empty_layer": true 27 | }, 28 | { 29 | "created": "2023-06-28T08:42:48.566770422Z", 30 | "created_by": "/bin/sh -c #(nop) LABEL org.opencontainers.image.ref.name=ubuntu", 31 | "empty_layer": true 32 | }, 33 | { 34 | "created": "2023-06-28T08:42:48.653381453Z", 35 | "created_by": "/bin/sh -c #(nop) LABEL org.opencontainers.image.version=22.04", 36 | "empty_layer": true 37 | }, 38 | { 39 | "created": "2023-06-28T08:42:50.199969775Z", 40 | "created_by": "/bin/sh -c #(nop) ADD file:262490f82459c14632f5c9a6d6a5cf6c07b4f307e8fd380fa764662cda46e88f in / " 41 | }, 42 | { 43 | "created": "2023-06-28T08:42:50.42500211Z", 44 | "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]", 45 | "empty_layer": true 46 | }, 47 | { 48 | "created": "2023-07-06T14:45:51.166292881Z", 49 | "created_by": "RUN /bin/sh -c echo \"hello\" # buildkit", 50 | "comment": "buildkit.dockerfile.v0" 51 | }, 52 | { 53 | "created": "2023-06-28T08:42:50.199969775Z", 54 | "created_by": "/bin/sh -c #(nop) ADD file:262490f82459c14632f5c9a6d6a5cf6c07b4f307e8fd380fa764662cda46e88f in / " 55 | }, 56 | { 57 | "created": "2023-06-28T08:42:50.199969775Z", 58 | "created_by": "/bin/sh -c #(nop) ADD file:262490f82459c14632f5c9a6d6a5cf6c07b4f307e8fd380fa764662cda46e88f in / " 59 | } 60 | ], 61 | "os": "linux", 62 | "rootfs": { 63 | "type": "layers", 64 | "diff_ids": [ 65 | "sha256:c5ca84f245d30117a9a2720cb4297cedf3642816471d4d699f4d77e39e13a39c", 66 | "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" 67 | ] 68 | }, 69 | "variant": "v8" 70 | } 71 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/apt_update_upgrade.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "arm64", 3 | "config": { 4 | "Hostname": "", 5 | "Domainname": "", 6 | "User": "", 7 | "AttachStdin": false, 8 | "AttachStdout": false, 9 | "AttachStderr": false, 10 | "Tty": false, 11 | "OpenStdin": false, 12 | "StdinOnce": false, 13 | "Env": [ 14 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 15 | ], 16 | "Cmd": [ 17 | "bash" 18 | ], 19 | "Image": "sha256:37fe96a532d30f99df9d33b2e5568f7340eb412679250731e1446cf429a6eb9e", 20 | "Volumes": null, 21 | "WorkingDir": "", 22 | "Entrypoint": null, 23 | "OnBuild": null, 24 | "Labels": null 25 | }, 26 | "container": "2f74aee84e66a15a4fd8938e8e77913dcb84e23fa401bb09fef1f6754199a036", 27 | "container_config": { 28 | "Hostname": "", 29 | "Domainname": "", 30 | "User": "", 31 | "AttachStdin": false, 32 | "AttachStdout": false, 33 | "AttachStderr": false, 34 | "Tty": false, 35 | "OpenStdin": false, 36 | "StdinOnce": false, 37 | "Env": [ 38 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 39 | ], 40 | "Cmd": [ 41 | "/bin/sh", 42 | "-c", 43 | "apt-get update -y && apt-get upgrade -y && echo \"hello\"" 44 | ], 45 | "Image": "sha256:37fe96a532d30f99df9d33b2e5568f7340eb412679250731e1446cf429a6eb9e", 46 | "Volumes": null, 47 | "WorkingDir": "", 48 | "Entrypoint": null, 49 | "OnBuild": null, 50 | "Labels": null 51 | }, 52 | "created": "2021-08-31T01:39:59.963242465Z", 53 | "docker_version": "20.10.8", 54 | "history": [ 55 | { 56 | "created": "2021-08-17T01:45:48.451416155Z", 57 | "created_by": "/bin/sh -c #(nop) ADD file:1e52a0aa8f37622b3d0d73bddae98dd854cdd0b001fffe704eb833b2659413ec in / " 58 | }, 59 | { 60 | "created": "2021-08-17T01:45:49.041321963Z", 61 | "created_by": "/bin/sh -c #(nop) CMD [\"bash\"]", 62 | "empty_layer": true 63 | }, 64 | { 65 | "created": "2021-08-31T01:39:59.963242465Z", 66 | "created_by": "/bin/sh -c apt-get update -y && apt-get upgrade -y && echo \"hello\"" 67 | } 68 | ], 69 | "os": "linux", 70 | "rootfs": { 71 | "type": "layers", 72 | "diff_ids": [ 73 | "sha256:9d93ee5b513b12ff60d69072e4ff0dbdca71652c489b9750a96a85bec726a17e", 74 | "sha256:a50611151d42ef5da2ce47cc17e366b8214a937ee875573b99e11ab678c54cd2" 75 | ] 76 | }, 77 | "variant": "v8" 78 | } 79 | -------------------------------------------------------------------------------- /pkg/assessor/assessor.go: -------------------------------------------------------------------------------- 1 | package assessor 2 | 3 | import ( 4 | "os" 5 | 6 | deckodertypes "github.com/goodwithtech/deckoder/types" 7 | 8 | "github.com/goodwithtech/dockle/pkg/assessor/cache" 9 | "github.com/goodwithtech/dockle/pkg/assessor/privilege" 10 | 11 | "github.com/goodwithtech/dockle/pkg/assessor/contentTrust" 12 | "github.com/goodwithtech/dockle/pkg/assessor/credential" 13 | "github.com/goodwithtech/dockle/pkg/assessor/hosts" 14 | 15 | "github.com/goodwithtech/dockle/pkg/assessor/group" 16 | "github.com/goodwithtech/dockle/pkg/assessor/manifest" 17 | "github.com/goodwithtech/dockle/pkg/assessor/passwd" 18 | "github.com/goodwithtech/dockle/pkg/assessor/user" 19 | 20 | "github.com/goodwithtech/dockle/pkg/log" 21 | "github.com/goodwithtech/dockle/pkg/types" 22 | ) 23 | 24 | var assessors []Assessor 25 | 26 | type Assessor interface { 27 | Assess(deckodertypes.FileMap) ([]*types.Assessment, error) 28 | RequiredFiles() []string 29 | RequiredExtensions() []string 30 | RequiredPermissions() []os.FileMode 31 | } 32 | 33 | func init() { 34 | RegisterAssessor(passwd.PasswdAssessor{}) 35 | RegisterAssessor(privilege.PrivilegeAssessor{}) 36 | RegisterAssessor(user.UserAssessor{}) 37 | RegisterAssessor(group.GroupAssessor{}) 38 | RegisterAssessor(hosts.HostsAssessor{}) 39 | RegisterAssessor(credential.CredentialAssessor{}) 40 | RegisterAssessor(manifest.ManifestAssessor{}) 41 | RegisterAssessor(contentTrust.ContentTrustAssessor{}) 42 | RegisterAssessor(cache.CacheAssessor{}) 43 | } 44 | 45 | func GetAssessments(files deckodertypes.FileMap) (assessments []*types.Assessment) { 46 | for _, assessor := range assessors { 47 | results, err := assessor.Assess(files) 48 | if err != nil { 49 | log.Logger.Error(err) 50 | } 51 | assessments = append(assessments, results...) 52 | } 53 | return assessments 54 | } 55 | 56 | func RegisterAssessor(a Assessor) { 57 | assessors = append(assessors, a) 58 | } 59 | 60 | func LoadRequiredFiles() (filenames []string) { 61 | for _, assessor := range assessors { 62 | filenames = append(filenames, assessor.RequiredFiles()...) 63 | } 64 | return filenames 65 | } 66 | 67 | func LoadRequiredExtensions() (extensions []string) { 68 | for _, assessor := range assessors { 69 | extensions = append(extensions, assessor.RequiredExtensions()...) 70 | } 71 | return extensions 72 | } 73 | 74 | func LoadRequiredPermissions() (permissions []os.FileMode) { 75 | for _, assessor := range assessors { 76 | permissions = append(permissions, assessor.RequiredPermissions()...) 77 | } 78 | return permissions 79 | } 80 | -------------------------------------------------------------------------------- /pkg/report/json.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/goodwithtech/dockle/config" 9 | 10 | "github.com/goodwithtech/dockle/pkg/types" 11 | ) 12 | 13 | type JsonWriter struct { 14 | ImageName string 15 | Output io.Writer 16 | } 17 | 18 | type JsonOutputFormat struct { 19 | ImageName string `json:"image,omitempty"` 20 | Summary JsonSummary `json:"summary"` 21 | Details []*JsonDetail `json:"details"` 22 | } 23 | type JsonSummary struct { 24 | Fatal int `json:"fatal"` 25 | Warn int `json:"warn"` 26 | Info int `json:"info"` 27 | Skip int `json:"skip"` 28 | Pass int `json:"pass"` 29 | } 30 | type JsonDetail struct { 31 | Code string `json:"code"` 32 | Title string `json:"title"` 33 | Level string `json:"level"` 34 | Alerts []string `json:"alerts"` 35 | } 36 | 37 | func (jw JsonWriter) Write(assessMap types.AssessmentMap) (abend bool, err error) { 38 | jsonSummary := JsonSummary{} 39 | jsonDetails := []*JsonDetail{} 40 | codeOrderLevel := getCodeOrder() 41 | for _, ass := range codeOrderLevel { 42 | if _, ok := assessMap[ass.Code]; !ok { 43 | jsonSummary.Pass++ 44 | continue 45 | } 46 | assess := assessMap[ass.Code] 47 | detail := jsonDetail(assess.Code, assess.Level, assess.Assessments) 48 | if detail != nil { 49 | jsonDetails = append(jsonDetails, detail) 50 | } 51 | 52 | // increment summary 53 | switch assess.Level { 54 | case types.FatalLevel: 55 | jsonSummary.Fatal++ 56 | case types.WarnLevel: 57 | jsonSummary.Warn++ 58 | case types.InfoLevel: 59 | jsonSummary.Info++ 60 | case types.SkipLevel: 61 | jsonSummary.Skip++ 62 | } 63 | if assess.Level >= config.Conf.ExitLevel { 64 | abend = true 65 | } 66 | } 67 | result := JsonOutputFormat{ 68 | ImageName: jw.ImageName, 69 | Summary: jsonSummary, 70 | Details: jsonDetails, 71 | } 72 | output, err := json.MarshalIndent(result, "", " ") 73 | if err != nil { 74 | return false, fmt.Errorf("failed to marshal json: %w", err) 75 | } 76 | 77 | if _, err = fmt.Fprint(jw.Output, string(output)); err != nil { 78 | return false, fmt.Errorf("failed to write json: %w", err) 79 | } 80 | return abend, nil 81 | } 82 | func jsonDetail(code string, level int, assessments []*types.Assessment) (jsonInfo *JsonDetail) { 83 | if len(assessments) == 0 { 84 | return nil 85 | } 86 | alerts := []string{} 87 | for _, assessment := range assessments { 88 | alerts = append(alerts, assessment.Desc) 89 | } 90 | jsonInfo = &JsonDetail{ 91 | Code: code, 92 | Title: types.TitleMap[code], 93 | Level: AlertLabels[level], 94 | Alerts: alerts, 95 | } 96 | return jsonInfo 97 | } 98 | -------------------------------------------------------------------------------- /pkg/assessor/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | deckodertypes "github.com/goodwithtech/deckoder/types" 10 | "github.com/goodwithtech/deckoder/utils" 11 | 12 | "github.com/goodwithtech/dockle/pkg/log" 13 | "github.com/goodwithtech/dockle/pkg/types" 14 | ) 15 | 16 | var ( 17 | reqFiles = []string{"Dockerfile", "docker-compose.yml", ".vimrc", ".DS_Store"} 18 | // Directory ends "/" separator 19 | reqDirs = []string{".cache/", ".aws/", ".azure/", ".gcp/", ".git/", ".vscode/", ".idea/", ".npm/"} 20 | uncontrollableDirs = []string{"node_modules/", "vendor/"} 21 | detectedDir = map[string]struct{}{} 22 | ) 23 | 24 | type CacheAssessor struct{} 25 | 26 | func (a CacheAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 27 | log.Logger.Debug("Start scan : cache files") 28 | assesses := []*types.Assessment{} 29 | for filename := range fileMap { 30 | fileBase := filepath.Base(filename) 31 | dirName := filepath.Dir(filename) 32 | dirBase := filepath.Base(dirName) 33 | 34 | // match Directory 35 | if utils.StringInSlice(dirBase+"/", reqDirs) || utils.StringInSlice(dirName+"/", reqDirs) { 36 | if _, ok := detectedDir[dirName]; ok { 37 | continue 38 | } 39 | detectedDir[dirName] = struct{}{} 40 | 41 | // Skip uncontrollable dependency directory e.g) npm : node_modules, php: composer 42 | if inIgnoreDir(filename) { 43 | continue 44 | } 45 | 46 | assesses = append( 47 | assesses, 48 | &types.Assessment{ 49 | Code: types.InfoDeletableFiles, 50 | Filename: dirName, 51 | Desc: fmt.Sprintf("Suspicious directory : %s ", dirName), 52 | }) 53 | 54 | } 55 | 56 | // match File 57 | if utils.StringInSlice(filename, reqFiles) || utils.StringInSlice(fileBase, reqFiles) { 58 | assesses = append( 59 | assesses, 60 | &types.Assessment{ 61 | Code: types.InfoDeletableFiles, 62 | Filename: filename, 63 | Desc: fmt.Sprintf("unnecessary file : %s ", filename), 64 | }) 65 | } 66 | } 67 | return assesses, nil 68 | } 69 | 70 | // check and register uncontrollable directory e.g) npm : node_modules, php: composer 71 | func inIgnoreDir(filename string) bool { 72 | for _, ignoreDir := range uncontrollableDirs { 73 | if strings.Contains(filename, ignoreDir) { 74 | return true 75 | } 76 | } 77 | return false 78 | } 79 | 80 | func (a CacheAssessor) RequiredFiles() []string { 81 | return append(reqFiles, reqDirs...) 82 | } 83 | 84 | func (a CacheAssessor) RequiredExtensions() []string { 85 | return []string{} 86 | } 87 | 88 | func (a CacheAssessor) RequiredPermissions() []os.FileMode { 89 | return []os.FileMode{} 90 | } 91 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/apk_cache.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "amd64", 3 | "config": { 4 | "Hostname": "", 5 | "Domainname": "", 6 | "User": "", 7 | "AttachStdin": false, 8 | "AttachStdout": false, 9 | "AttachStderr": false, 10 | "Tty": false, 11 | "OpenStdin": false, 12 | "StdinOnce": false, 13 | "Env": [ 14 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 15 | ], 16 | "Cmd": [ 17 | "/bin/sh" 18 | ], 19 | "ArgsEscaped": true, 20 | "Image": "sha256:d4a30ffdd84e73f03d4fa4277310814aae4892ad21c36d70c3eac80999c4a643", 21 | "Volumes": null, 22 | "WorkingDir": "", 23 | "Entrypoint": null, 24 | "OnBuild": null, 25 | "Labels": null 26 | }, 27 | "container_config": { 28 | "Hostname": "", 29 | "Domainname": "", 30 | "User": "", 31 | "AttachStdin": false, 32 | "AttachStdout": false, 33 | "AttachStderr": false, 34 | "Tty": false, 35 | "OpenStdin": false, 36 | "StdinOnce": false, 37 | "Env": [ 38 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 39 | ], 40 | "Cmd": [ 41 | "/bin/sh", 42 | "-c", 43 | "#(nop) ADD file:cf1415d8f82ed259f8e9cd1d44120662a8e5fea652935195313426c3f5712a5c in /app/credential.json " 44 | ], 45 | "ArgsEscaped": true, 46 | "Image": "sha256:d4a30ffdd84e73f03d4fa4277310814aae4892ad21c36d70c3eac80999c4a643", 47 | "Volumes": null, 48 | "WorkingDir": "", 49 | "Entrypoint": null, 50 | "OnBuild": null, 51 | "Labels": null 52 | }, 53 | "created": "2019-06-03T18:21:23.2554118Z", 54 | "docker_version": "18.09.2", 55 | "history": [ 56 | { 57 | "created": "2019-04-09T23:20:18.226548168Z", 58 | "created_by": "/bin/sh -c #(nop) ADD file:2e3a37883f56a4a278bec2931fc9f91fb9ebdaa9047540fe8fde419b84a1701b in / " 59 | }, 60 | { 61 | "created": "2019-04-09T23:20:18.391201481Z", 62 | "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", 63 | "empty_layer": true 64 | }, 65 | { 66 | "created": "2019-06-03T18:21:08.6996176Z", 67 | "created_by": "/bin/sh -c apk add wget curl" 68 | }, 69 | { 70 | "created": "2019-06-03T18:21:23.2554118Z", 71 | "created_by": "/bin/sh -c #(nop) ADD file:cf1415d8f82ed259f8e9cd1d44120662a8e5fea652935195313426c3f5712a5c in /app/credential.json" 72 | } 73 | ], 74 | "os": "linux", 75 | "rootfs": { 76 | "type": "layers", 77 | "diff_ids": [ 78 | "sha256:a464c54f93a9e88fc1d33df1e0e39cca427d60145a360962e8f19a1dbf900da9", 79 | "sha256:52db8dfadb3ce319c37fe669372c25b69228c0561133469d3696cda77536ce20", 80 | "sha256:d09f913df06f43f42a341b0c5263fe2b1e95d6f253784d8aea71c775df1e7e39" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/report/sarif.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/owenrumney/go-sarif/v2/sarif" 9 | 10 | "github.com/goodwithtech/dockle/config" 11 | "github.com/goodwithtech/dockle/pkg/types" 12 | ) 13 | 14 | type SarifWriter struct { 15 | Output io.Writer 16 | } 17 | 18 | type sarifResult struct { 19 | ruleID string 20 | ruleDescription string 21 | link string 22 | description string 23 | severity string 24 | locations []string 25 | } 26 | 27 | func (sw SarifWriter) Write(assessMap types.AssessmentMap) (abend bool, err error) { 28 | var rules []*sarifResult 29 | codeOrderLevel := getCodeOrder() 30 | for _, ass := range codeOrderLevel { 31 | if _, ok := assessMap[ass.Code]; !ok { 32 | continue 33 | } 34 | assess := assessMap[ass.Code] 35 | detail := sarifDetail(assess.Code, assess.Level, assess.Assessments) 36 | if detail != nil { 37 | rules = append(rules, detail) 38 | } 39 | if assess.Level >= config.Conf.ExitLevel { 40 | abend = true 41 | } 42 | } 43 | 44 | report, err := sarif.New(sarif.Version210) 45 | if err != nil { 46 | return false, err 47 | } 48 | run := sarif.NewRunWithInformationURI("Dockle", "https://github.com/goodwithtech/dockle") 49 | report.AddRun(run) 50 | for _, r := range rules { 51 | result := sarif.NewRuleResult(r.ruleID). 52 | WithLevel(strings.ToLower(r.severity)). 53 | WithMessage(sarif.NewTextMessage(r.description)) 54 | 55 | for _, uri := range r.locations { 56 | result.AddLocation( 57 | sarif.NewLocation().WithPhysicalLocation( 58 | sarif.NewPhysicalLocation().WithArtifactLocation( 59 | sarif.NewArtifactLocation().WithUri( 60 | uri, 61 | ), 62 | ), 63 | ), 64 | ) 65 | } 66 | 67 | run.AddRule(r.ruleID). 68 | WithName(r.ruleID). 69 | WithDescription(r.ruleDescription). 70 | WithHelpURI(r.link) 71 | run.AddResult(result) 72 | } 73 | if err := report.PrettyWrite(sw.Output); err != nil { 74 | return false, fmt.Errorf("failed to write sarif: %w", err) 75 | } 76 | return abend, nil 77 | } 78 | 79 | func sarifDetail(code string, level int, assessments []*types.Assessment) (jsonInfo *sarifResult) { 80 | if len(assessments) == 0 { 81 | return nil 82 | } 83 | alerts := []string{} 84 | locations := []string{} 85 | for _, assessment := range assessments { 86 | alerts = append(alerts, assessment.Desc) 87 | locations = append(locations, assessment.Filename) 88 | } 89 | return &sarifResult{ 90 | ruleID: code, 91 | severity: sarifAlertLabels[level], 92 | ruleDescription: types.TitleMap[code], 93 | link: fmt.Sprintf("https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#%s", code), 94 | description: strings.Join(alerts, ", "), 95 | locations: locations, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/types/assessment_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/google/go-cmp/cmp/cmpopts" 8 | ) 9 | 10 | func TestCreateAssessmentMap(t *testing.T) { 11 | testcases := map[string]struct { 12 | as AssessmentSlice 13 | ig map[string]struct{} 14 | debug bool 15 | expected AssessmentMap 16 | }{ 17 | "OK": { 18 | as: AssessmentSlice{ 19 | {Code: "a", Filename: "a"}, 20 | {Code: "b", Filename: "b"}, 21 | {Code: "a", Filename: "c"}, 22 | {Code: "a", Filename: "b"}, 23 | }, 24 | ig: map[string]struct{}{}, 25 | expected: map[string]CodeInfo{ 26 | "a": { 27 | Code: "a", 28 | Level: 0, 29 | Assessments: []*Assessment{ 30 | {Code: "a", Filename: "a"}, 31 | {Code: "a", Filename: "c"}, 32 | {Code: "a", Filename: "b"}, 33 | }, 34 | }, 35 | "b": { 36 | Code: "b", 37 | Level: 0, 38 | Assessments: []*Assessment{ 39 | {Code: "b", Filename: "b"}, 40 | }, 41 | }, 42 | }, 43 | }, 44 | "IgnoreB": { 45 | as: AssessmentSlice{ 46 | {Code: "a", Filename: "a"}, 47 | {Code: "b", Filename: "b"}, 48 | {Code: "a", Filename: "c"}, 49 | {Code: "a", Filename: "b"}, 50 | }, 51 | ig: map[string]struct{}{"b": {}}, 52 | expected: map[string]CodeInfo{ 53 | "a": { 54 | Code: "a", 55 | Level: 0, 56 | Assessments: []*Assessment{ 57 | {Code: "a", Filename: "a"}, 58 | {Code: "a", Filename: "c"}, 59 | {Code: "a", Filename: "b"}, 60 | }, 61 | }, 62 | }, 63 | }, 64 | "IgnoreBwithDebug": { 65 | as: AssessmentSlice{ 66 | {Code: "a", Filename: "a"}, 67 | {Code: "b", Filename: "b"}, 68 | {Code: "a", Filename: "c"}, 69 | {Code: "a", Filename: "b"}, 70 | }, 71 | ig: map[string]struct{}{"b": {}}, 72 | debug: true, 73 | expected: map[string]CodeInfo{ 74 | "a": { 75 | Code: "a", 76 | Level: 0, 77 | Assessments: []*Assessment{ 78 | {Code: "a", Filename: "a"}, 79 | {Code: "a", Filename: "c"}, 80 | {Code: "a", Filename: "b"}, 81 | }, 82 | }, 83 | "b": { 84 | Code: "b", 85 | Level: IgnoreLevel, 86 | Assessments: []*Assessment{ 87 | {Code: "b", Filename: "b"}, 88 | }, 89 | }, 90 | }, 91 | }, 92 | } 93 | 94 | for name, v := range testcases { 95 | actual := CreateAssessmentMap(v.as, v.ig, v.debug) 96 | cmpopts := []cmp.Option{ 97 | cmpopts.SortSlices(func(x, y Assessment) bool { 98 | if x.Code == y.Code { 99 | return x.Filename < y.Filename 100 | } 101 | return x.Code < y.Code 102 | }), 103 | } 104 | if diff := cmp.Diff(actual, v.expected, cmpopts...); diff != "" { 105 | t.Errorf("%s : diff %v", name, diff) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /pkg/types/checkpoint.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | // CIS-DI 5 | AvoidRootDefault = "CIS-DI-0001" 6 | UseContentTrust = "CIS-DI-0005" 7 | AddHealthcheck = "CIS-DI-0006" 8 | UseAptGetUpdateNoCache = "CIS-DI-0007" 9 | CheckSuidGuid = "CIS-DI-0008" 10 | UseCOPY = "CIS-DI-0009" 11 | AvoidCredential = "CIS-DI-0010" 12 | 13 | // DG-DI 14 | AvoidSudo = "DKL-DI-0001" 15 | AvoidSensitiveDirectoryMounting = "DKL-DI-0002" 16 | AvoidDistUpgrade = "DKL-DI-0003" 17 | UseApkAddNoCache = "DKL-DI-0004" 18 | MinimizeAptGet = "DKL-DI-0005" 19 | AvoidLatestTag = "DKL-DI-0006" 20 | 21 | // DG-LI 22 | AvoidEmptyPassword = "DKL-LI-0001" 23 | AvoidDuplicateUserGroup = "DKL-LI-0002" 24 | InfoDeletableFiles = "DKL-LI-0003" 25 | ) 26 | 27 | const ( 28 | PassLevel int = iota + 1 29 | IgnoreLevel 30 | SkipLevel 31 | InfoLevel 32 | WarnLevel 33 | FatalLevel 34 | ) 35 | 36 | // DefaultLevelMap save risk level each checkpoints 37 | var DefaultLevelMap = map[string]int{ 38 | AvoidRootDefault: WarnLevel, 39 | UseContentTrust: InfoLevel, 40 | AddHealthcheck: InfoLevel, 41 | UseAptGetUpdateNoCache: FatalLevel, 42 | CheckSuidGuid: InfoLevel, 43 | UseCOPY: FatalLevel, 44 | AvoidCredential: FatalLevel, 45 | 46 | AvoidSudo: FatalLevel, 47 | AvoidSensitiveDirectoryMounting: FatalLevel, 48 | AvoidDistUpgrade: WarnLevel, 49 | UseApkAddNoCache: FatalLevel, 50 | MinimizeAptGet: FatalLevel, 51 | AvoidLatestTag: WarnLevel, 52 | 53 | AvoidEmptyPassword: FatalLevel, 54 | AvoidDuplicateUserGroup: FatalLevel, 55 | InfoDeletableFiles: InfoLevel, 56 | } 57 | 58 | // TitleMap save title each checkpoints 59 | var TitleMap = map[string]string{ 60 | AvoidRootDefault: "Create a user for the container", 61 | UseContentTrust: "Enable Content trust for Docker", 62 | AddHealthcheck: "Add HEALTHCHECK instruction to the container image", 63 | UseAptGetUpdateNoCache: "Do not use update instructions alone in the Dockerfile", 64 | CheckSuidGuid: "Confirm safety of setuid/setgid files", 65 | UseCOPY: "Use COPY instead of ADD in Dockerfile", 66 | AvoidCredential: "Do not store credential in environment variables/files", 67 | AvoidSudo: "Avoid sudo command", 68 | AvoidSensitiveDirectoryMounting: "Avoid sensitive directory mounting", 69 | AvoidDistUpgrade: `Avoid "apt-get dist-upgrade"`, 70 | UseApkAddNoCache: `Use "apk add" with --no-cache`, 71 | MinimizeAptGet: `Clear apt-get caches`, 72 | AvoidLatestTag: "Avoid latest tag", 73 | AvoidEmptyPassword: "Avoid empty password", 74 | AvoidDuplicateUserGroup: "Be unique UID/GROUP", 75 | InfoDeletableFiles: "Only put necessary files", 76 | } 77 | -------------------------------------------------------------------------------- /pkg/scanner/scan_test.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | 11 | deckodertypes "github.com/goodwithtech/deckoder/types" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | 15 | "github.com/goodwithtech/dockle/pkg/assessor/contentTrust" 16 | "github.com/goodwithtech/dockle/pkg/assessor/manifest" 17 | "github.com/goodwithtech/dockle/pkg/log" 18 | "github.com/goodwithtech/dockle/pkg/types" 19 | ) 20 | 21 | func TestScanImage(t *testing.T) { 22 | log.InitLogger(false, false) 23 | testcases := map[string]struct { 24 | imageName string 25 | fileName string 26 | option deckodertypes.DockerOption 27 | wantErr error 28 | expected []*types.Assessment 29 | }{ 30 | "Dockerfile.base": { 31 | // TODO : too large to use github / fileName: "base.tar", 32 | // testdata/Dockerfile.base 33 | imageName: "goodwithtech/dockle-test:base-test", 34 | option: deckodertypes.DockerOption{Timeout: time.Minute}, 35 | expected: []*types.Assessment{ 36 | {Code: types.AvoidEmptyPassword, Filename: "etc/shadow"}, 37 | {Code: types.AvoidRootDefault, Filename: manifest.ConfigFileName}, 38 | {Code: types.AvoidCredential, Filename: "app/credentials.json"}, 39 | {Code: types.CheckSuidGuid, Filename: "app/gid.txt"}, 40 | {Code: types.CheckSuidGuid, Filename: "app/suid.txt"}, 41 | {Code: types.CheckSuidGuid, Filename: "bin/mount"}, 42 | {Code: types.CheckSuidGuid, Filename: "bin/su"}, 43 | {Code: types.CheckSuidGuid, Filename: "bin/umount"}, 44 | {Code: types.CheckSuidGuid, Filename: "usr/lib/openssh/ssh-keysign"}, 45 | {Code: types.UseCOPY, Filename: manifest.ConfigFileName}, 46 | {Code: types.AddHealthcheck, Filename: manifest.ConfigFileName}, 47 | {Code: types.MinimizeAptGet, Filename: manifest.ConfigFileName}, 48 | {Code: types.AvoidCredential, Filename: manifest.ConfigFileName}, 49 | {Code: types.UseContentTrust, Filename: contentTrust.HostEnvironmentFileName}, 50 | }, 51 | }, 52 | "Dockerfile.scratch": { 53 | fileName: "./testdata/scratch.tar", 54 | expected: []*types.Assessment{ 55 | {Code: types.AvoidCredential, Filename: "credentials.json"}, 56 | {Code: types.AddHealthcheck, Filename: manifest.ConfigFileName}, 57 | {Code: types.UseContentTrust, Filename: contentTrust.HostEnvironmentFileName}, 58 | {Code: types.AvoidEmptyPassword, Level: types.SkipLevel}, 59 | {Code: types.AvoidDuplicateUserGroup, Level: types.SkipLevel}, 60 | {Code: types.AvoidDuplicateUserGroup, Level: types.SkipLevel}, 61 | }, 62 | }, 63 | "emptyArg": { 64 | wantErr: types.ErrSetImageOrFile, 65 | }, 66 | } 67 | for name, v := range testcases { 68 | ctx := context.Background() 69 | assesses, err := ScanImage(ctx, v.imageName, v.fileName, v.option) 70 | if !errors.Is(v.wantErr, err) { 71 | t.Errorf("%s: error got %v, want %v", name, err, v.wantErr) 72 | } 73 | 74 | cmpopts := []cmp.Option{ 75 | cmpopts.SortSlices(func(x, y *types.Assessment) bool { 76 | if x.Code == y.Code { 77 | return x.Filename < y.Filename 78 | } 79 | return x.Code < y.Code 80 | }), 81 | cmpopts.IgnoreFields(types.Assessment{}, "Desc"), 82 | } 83 | if diff := cmp.Diff(assesses, v.expected, cmpopts...); diff != "" { 84 | t.Errorf("%s : tasks diff %v", name, diff) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/assessor/credential/credential.go: -------------------------------------------------------------------------------- 1 | package credential 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "unicode/utf8" 8 | 9 | deckodertypes "github.com/goodwithtech/deckoder/types" 10 | 11 | "github.com/goodwithtech/dockle/pkg/log" 12 | 13 | "github.com/goodwithtech/dockle/pkg/types" 14 | ) 15 | 16 | var ( 17 | suspiciousFiles []string 18 | suspiciousFileExtensions []string 19 | ) 20 | 21 | type CredentialAssessor struct{} 22 | 23 | func AddSensitiveFiles(files []string) { 24 | suspiciousFiles = append(suspiciousFiles, files...) 25 | } 26 | 27 | func AddSensitiveFileExtensions(fileExtensions []string) { 28 | suspiciousFileExtensions = append(suspiciousFileExtensions, fileExtensions...) 29 | } 30 | 31 | func (a CredentialAssessor) Assess(fileMap deckodertypes.FileMap) ([]*types.Assessment, error) { 32 | log.Logger.Debug("Start scan : credential files") 33 | assesses := []*types.Assessment{} 34 | fmap := makeMaps(a.RequiredFiles()) 35 | fexts := makeMaps(a.RequiredExtensions()) 36 | for filename := range fileMap { 37 | basename := filepath.Base(filename) 38 | // check exist target files 39 | if _, ok := fmap[basename]; ok { 40 | assesses = append( 41 | assesses, 42 | &types.Assessment{ 43 | Code: types.AvoidCredential, 44 | Filename: filename, 45 | Desc: fmt.Sprintf("Suspicious filename found : %s (You can suppress it with \"-af %s\")", filename, basename), 46 | }) 47 | } else if _, ok := fexts[filepath.Ext(basename)]; ok { 48 | assesses = append( 49 | assesses, 50 | &types.Assessment{ 51 | Code: types.AvoidCredential, 52 | Filename: filename, 53 | Desc: fmt.Sprintf("Suspicious file extension found : %s (You can suppress it with \"-ae %s\")", filename, trimFirstRune(filepath.Ext(basename))), 54 | }) 55 | } 56 | } 57 | return assesses, nil 58 | } 59 | 60 | func trimFirstRune(s string) string { 61 | _, i := utf8.DecodeRuneInString(s) 62 | return s[i:] 63 | } 64 | 65 | func makeMaps(keys []string) map[string]struct{} { 66 | maps := make(map[string]struct{}) 67 | for i := 0; i < len(keys); i++ { 68 | maps[keys[i]] = struct{}{} 69 | } 70 | return maps 71 | } 72 | 73 | func (a CredentialAssessor) RequiredFiles() []string { 74 | return append([]string{ 75 | "credentials.json", 76 | "credential.json", 77 | // TODO: Only check .docker/config.json 78 | // "config.json", 79 | "credentials", 80 | "credential", 81 | "password.txt", 82 | "id_rsa", 83 | "id_dsa", 84 | "id_ecdsa", 85 | "id_ed25519", 86 | "secret_token.rb", 87 | "carrierwave.rb", 88 | "omniauth.rb", 89 | "settings.py", 90 | "database.yml", 91 | "credentials.xml", 92 | ".env", 93 | }, suspiciousFiles...) 94 | } 95 | 96 | func (a CredentialAssessor) RequiredExtensions() []string { 97 | return append([]string{ 98 | // reference: https://github.com/eth0izzle/shhgit/blob/master/config.yaml 99 | // TODO: potential sensitive data but they have many false-positives. 100 | // Dockle need to analyze each file. 101 | //".pem", 102 | //".key", 103 | //".p12", 104 | //".pkcs12", 105 | //".pfx", 106 | //".asc", 107 | 108 | ".secret", 109 | ".ovpn", 110 | ".private_key", 111 | ".cscfg", 112 | ".rdp", 113 | ".mdf", 114 | ".sdf", 115 | ".bek", 116 | ".tpm", 117 | ".fve", 118 | ".jks", 119 | ".psafe3", 120 | ".agilekeychain", 121 | ".keychain", 122 | ".pcap", 123 | ".gnucache", 124 | }, suspiciousFileExtensions...) 125 | } 126 | 127 | func (a CredentialAssessor) RequiredPermissions() []os.FileMode { 128 | return []os.FileMode{} 129 | } 130 | -------------------------------------------------------------------------------- /pkg/scanner/scan.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "archive/tar" 5 | "context" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/goodwithtech/deckoder/analyzer" 10 | "github.com/goodwithtech/deckoder/extractor" 11 | "github.com/goodwithtech/deckoder/extractor/docker" 12 | deckodertypes "github.com/goodwithtech/deckoder/types" 13 | 14 | "github.com/goodwithtech/dockle/pkg/types" 15 | 16 | "github.com/goodwithtech/dockle/pkg/assessor" 17 | ) 18 | 19 | var ( 20 | acceptanceFiles = map[string]struct{}{} 21 | acceptanceExtensions = map[string]struct{}{} 22 | ) 23 | 24 | func AddAcceptanceFiles(keys []string) { 25 | for _, key := range keys { 26 | acceptanceFiles[key] = struct{}{} 27 | } 28 | } 29 | 30 | func AddAcceptanceExtensions(keys []string) { 31 | for _, key := range keys { 32 | // file extension must start with . 33 | acceptanceExtensions["."+key] = struct{}{} 34 | } 35 | } 36 | 37 | func ScanImage(ctx context.Context, imageName, filePath string, dockerOption deckodertypes.DockerOption) (assessments []*types.Assessment, err error) { 38 | var ext extractor.Extractor 39 | var cleanup func() 40 | if imageName != "" { 41 | ext, cleanup, err = docker.NewDockerExtractor(ctx, imageName, dockerOption) 42 | if err != nil { 43 | return nil, err 44 | } 45 | } else if filePath != "" { 46 | ext, cleanup, err = docker.NewDockerArchiveExtractor(ctx, filePath, dockerOption) 47 | if err != nil { 48 | return nil, err 49 | } 50 | } else { 51 | return nil, types.ErrSetImageOrFile 52 | } 53 | defer cleanup() 54 | ac := analyzer.New(ext) 55 | var files deckodertypes.FileMap 56 | filterFunc := createPathPermissionFilterFunc(assessor.LoadRequiredFiles(), assessor.LoadRequiredExtensions(), assessor.LoadRequiredPermissions()) 57 | if files, err = ac.Analyze(ctx, filterFunc); err != nil { 58 | return nil, err 59 | } 60 | 61 | assessments = assessor.GetAssessments(files) 62 | return assessments, nil 63 | } 64 | 65 | func createPathPermissionFilterFunc(filenames, extensions []string, permissions []os.FileMode) deckodertypes.FilterFunc { 66 | requiredDirNames := map[string]struct{}{} 67 | requiredFileNames := map[string]struct{}{} 68 | requiredExts := map[string]struct{}{} 69 | for _, filename := range filenames { 70 | if filename[len(filename)-1] == '/' { 71 | // if filename end "/", it is directory and requiredDirNames removes last "/" 72 | requiredDirNames[filepath.Clean(filename)] = struct{}{} 73 | } else { 74 | requiredFileNames[filename] = struct{}{} 75 | } 76 | } 77 | for _, extension := range extensions { 78 | requiredExts[extension] = struct{}{} 79 | } 80 | 81 | return func(h *tar.Header) (bool, error) { 82 | filePath := filepath.Clean(h.Name) 83 | fileName := filepath.Base(filePath) 84 | // Skip check if acceptance files 85 | if _, ok := acceptanceExtensions[filepath.Ext(fileName)]; ok { 86 | return false, nil 87 | } 88 | if _, ok := acceptanceFiles[filePath]; ok { 89 | return false, nil 90 | } 91 | if _, ok := acceptanceFiles[fileName]; ok { 92 | return false, nil 93 | } 94 | 95 | // Check with file names 96 | if _, ok := requiredFileNames[filePath]; ok { 97 | return true, nil 98 | } 99 | if _, ok := requiredFileNames[fileName]; ok { 100 | return true, nil 101 | } 102 | 103 | // Check with file extensions 104 | if _, ok := requiredExts[filepath.Ext(fileName)]; ok { 105 | return true, nil 106 | } 107 | 108 | // Check with file directory name 109 | fileDir := filepath.Dir(filePath) 110 | if _, ok := requiredDirNames[fileDir]; ok { 111 | return true, nil 112 | } 113 | fileDirBase := filepath.Base(fileDir) 114 | if _, ok := requiredDirNames[fileDirBase]; ok { 115 | return true, nil 116 | } 117 | 118 | fi := h.FileInfo() 119 | fileMode := fi.Mode() 120 | for _, p := range permissions { 121 | if fileMode&p != 0 { 122 | return true, nil 123 | } 124 | } 125 | return false, nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: dockle 3 | builds: 4 | - main: ./cmd/dockle/ 5 | binary: dockle 6 | ldflags: 7 | - -s -w 8 | - "-extldflags '-static'" 9 | - -X github.com/goodwithtech/dockle/pkg.version={{.Version}} 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - darwin 14 | - linux 15 | goarch: 16 | - amd64 17 | - 386 18 | - arm 19 | - arm64 20 | - loong64 21 | goarm: 22 | - 7 23 | 24 | nfpms: 25 | - 26 | formats: 27 | - apk 28 | - deb 29 | - rpm 30 | vendor: "goodwithtech" 31 | homepage: "https://github.com/goodwithtech" 32 | maintainer: "Tomoya Amachi " 33 | description: "A Security and Dockerfile checker for Containers" 34 | license: "AGPL" 35 | file_name_template: >- 36 | {{ .ProjectName }}_{{ .Version }}_ 37 | {{- if eq .Os "darwin" }}macOS 38 | {{- else if eq .Os "openbsd" }}OpenBSD 39 | {{- else if eq .Os "netbsd" }}NetBSD 40 | {{- else if eq .Os "freebsd" }}FreeBSD 41 | {{- else if eq .Os "dragonfly" }}DragonFlyBSD 42 | {{- else}}{{- title .Os }}{{ end }}- 43 | {{- if eq .Arch "amd64" }}64bit 44 | {{- else if eq .Arch "arm" }}ARM 45 | {{- else if eq .Arch "arm64" }}ARM64 46 | {{- else }}{{ .Arch }}{{ end }} 47 | 48 | archives: 49 | - 50 | format: tar.gz 51 | format_overrides: 52 | - goos: windows 53 | format: zip 54 | name_template: >- 55 | {{ .ProjectName }}_{{ .Version }}_ 56 | {{- if eq .Os "darwin" }}macOS 57 | {{- else if eq .Os "linux" }}Linux 58 | {{- else if eq .Os "openbsd" }}OpenBSD 59 | {{- else if eq .Os "netbsd" }}NetBSD 60 | {{- else if eq .Os "freebsd" }}FreeBSD 61 | {{- else if eq .Os "dragonfly" }}DragonFlyBSD 62 | {{- else}}{{- .Os }}{{ end }}- 63 | {{- if eq .Arch "amd64" }}64bit 64 | {{- else if eq .Arch "arm" }}ARM 65 | {{- else if eq .Arch "arm64" }}ARM64 66 | {{- else if eq .Arch "loong64" }}LOONG64 67 | {{- else }}{{ .Arch }}{{ end }} 68 | files: 69 | - README.md 70 | - LICENSE 71 | 72 | brews: 73 | - 74 | repository: 75 | owner: goodwithtech 76 | name: homebrew-r 77 | homepage: "https://github.com/goodwithtech/dockle" 78 | description: "Simple security auditing, helping build the Best Docker Images" 79 | test: | 80 | system "#{bin}/program --version" 81 | 82 | dockers: 83 | - image_templates: 84 | - 'goodwithtech/dockle:{{ .Tag }}-amd64' 85 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-amd64' 86 | dockerfile: Dockerfile.releaser 87 | build_flag_templates: 88 | - "--pull" 89 | - "--label=org.opencontainers.image.created={{.Date}}" 90 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 91 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 92 | - "--label=org.opencontainers.image.version={{.Version}}" 93 | - "--label=org.opencontainers.image.source={{.GitURL}}" 94 | - "--platform=linux/amd64" 95 | - image_templates: 96 | - 'goodwithtech/dockle:{{ .Tag }}-arm64' 97 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-arm64' 98 | dockerfile: Dockerfile.releaser 99 | build_flag_templates: 100 | - "--pull" 101 | - "--label=org.opencontainers.image.created={{.Date}}" 102 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 103 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 104 | - "--label=org.opencontainers.image.version={{.Version}}" 105 | - "--label=org.opencontainers.image.source={{.GitURL}}" 106 | - "--platform=linux/arm64" 107 | goarch: arm64 108 | docker_manifests: 109 | - name_template: 'goodwithtech/dockle:{{ .Tag }}' 110 | image_templates: 111 | - 'goodwithtech/dockle:{{ .Tag }}-amd64' 112 | - 'goodwithtech/dockle:{{ .Tag }}-arm64' 113 | - name_template: 'ghcr.io/goodwithtech/dockle:{{ .Tag }}' 114 | image_templates: 115 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-amd64' 116 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-arm64' 117 | - name_template: 'goodwithtech/dockle:latest' 118 | image_templates: 119 | - 'goodwithtech/dockle:{{ .Tag }}-amd64' 120 | - 'goodwithtech/dockle:{{ .Tag }}-arm64' 121 | - name_template: 'ghcr.io/goodwithtech/dockle:latest' 122 | image_templates: 123 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-amd64' 124 | - 'ghcr.io/goodwithtech/dockle:{{ .Tag }}-arm64' 125 | -------------------------------------------------------------------------------- /pkg/types/image.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // TODO: better to use docker/docker/image.Image. 4 | // if use docker/docker/image.Image, it's occur dependency errors. 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/docker/go-connections/nat" 10 | ) 11 | 12 | // Image stores the image configuration 13 | type Image struct { 14 | V1Image 15 | History []History `json:"history,omitempty"` 16 | OSVersion string `json:"os.version,omitempty"` 17 | OSFeatures []string `json:"os.features,omitempty"` 18 | } 19 | 20 | // V1Image stores the V1 image configuration. 21 | type V1Image struct { 22 | ID string `json:"id,omitempty"` 23 | Parent string `json:"parent,omitempty"` 24 | Comment string `json:"comment,omitempty"` 25 | Created time.Time `json:"created"` 26 | Container string `json:"container,omitempty"` 27 | ContainerConfig Config `json:"container_config,omitempty"` 28 | DockerVersion string `json:"docker_version,omitempty"` 29 | Author string `json:"author,omitempty"` 30 | Config Config `json:"config,omitempty"` 31 | Architecture string `json:"architecture,omitempty"` 32 | OS string `json:"os,omitempty"` 33 | Size int64 `json:",omitempty"` 34 | } 35 | 36 | type Config struct { 37 | Hostname string // Hostname 38 | Domainname string // Domainname 39 | User string // User that will run the command(s) inside the container, also support user:group 40 | AttachStdin bool // Attach the standard input, makes possible user interaction 41 | AttachStdout bool // Attach the standard output 42 | AttachStderr bool // Attach the standard error 43 | ExposedPorts nat.PortSet `json:",omitempty"` // List of exposed ports 44 | Tty bool // Attach standard streams to a tty, including stdin if it is not closed. 45 | OpenStdin bool // Open stdin 46 | StdinOnce bool // If true, close stdin after the 1 attached client disconnects. 47 | Env []string // List of environment variable to set in the container 48 | Cmd []string // Command to run when starting the container 49 | Healthcheck *HealthConfig `json:",omitempty"` // Healthcheck describes how to check the container is healthy 50 | ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (Windows specific) 51 | Image string // Name of the image as it was passed by the operator (e.g. could be symbolic) 52 | Volumes map[string]struct{} // List of volumes (mounts) used for the container 53 | WorkingDir string // Current directory (PWD) in the command will be launched 54 | Entrypoint []string // Entrypoint to run when starting the container 55 | NetworkDisabled bool `json:",omitempty"` // Is network disabled 56 | MacAddress string `json:",omitempty"` // Mac Address of the container 57 | OnBuild []string // ONBUILD metadata that were defined on the image Dockerfile 58 | Labels map[string]string // List of labels set to this container 59 | StopSignal string `json:",omitempty"` // Signal to stop a container 60 | StopTimeout *int `json:",omitempty"` // Timeout (in seconds) to stop a container 61 | Shell []string `json:",omitempty"` // Shell for shell-form of RUN, CMD, ENTRYPOINT 62 | } 63 | 64 | // HealthConfig holds configuration settings for the HEALTHCHECK feature. 65 | type HealthConfig struct { 66 | Test []string `json:",omitempty"` 67 | Interval time.Duration `json:",omitempty"` // Interval is the time to wait between checks. 68 | Timeout time.Duration `json:",omitempty"` // Timeout is the time to wait before considering the check to have hung. 69 | StartPeriod time.Duration `json:",omitempty"` // The start period for the container to initialize before the retries starts to count down. 70 | Retries int `json:",omitempty"` 71 | } 72 | 73 | // History stores build commands that were used to create an image 74 | type History struct { 75 | Created time.Time `json:"created"` 76 | Author string `json:"author,omitempty"` 77 | CreatedBy string `json:"created_by,omitempty"` 78 | Comment string `json:"comment,omitempty"` 79 | EmptyLayer bool `json:"empty_layer,omitempty"` 80 | } 81 | -------------------------------------------------------------------------------- /pkg/run.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | l "log" 8 | "os" 9 | "strings" 10 | 11 | "github.com/goodwithtech/dockle/pkg/assessor/credential" 12 | "github.com/goodwithtech/dockle/pkg/assessor/manifest" 13 | 14 | "github.com/containers/image/v5/transports/alltransports" 15 | deckodertypes "github.com/goodwithtech/deckoder/types" 16 | 17 | "github.com/goodwithtech/dockle/config" 18 | "github.com/goodwithtech/dockle/pkg/utils" 19 | 20 | "github.com/goodwithtech/dockle/pkg/report" 21 | 22 | "github.com/goodwithtech/dockle/pkg/scanner" 23 | 24 | "github.com/urfave/cli" 25 | 26 | "github.com/goodwithtech/dockle/pkg/log" 27 | "github.com/goodwithtech/dockle/pkg/types" 28 | ) 29 | 30 | func Run(c *cli.Context) (err error) { 31 | ctx, cancel := context.WithTimeout(context.Background(), c.Duration("timeout")) 32 | defer cancel() 33 | debug := c.Bool("debug") 34 | quiet := c.Bool("quiet") 35 | if err = log.InitLogger(debug, quiet); err != nil { 36 | l.Fatal(err) 37 | } 38 | 39 | config.CreateFromCli(c) 40 | 41 | cliVersion := "v" + c.App.Version 42 | if c.Bool("version-check") { 43 | latestVersion, err := utils.FetchLatestVersion(ctx) 44 | // check latest version 45 | if err != nil { 46 | log.Logger.Infof("Failed to check latest version. %s", err) 47 | } else if cliVersion != latestVersion && c.App.Version != "dev" { 48 | log.Logger.Warnf("A new version %s is now available! You have %s.", latestVersion, cliVersion) 49 | } 50 | } else { 51 | log.Logger.Debug("Skipped update confirmation") 52 | } 53 | 54 | args := c.Args() 55 | filePath := c.String("input") 56 | if filePath == "" && len(args) == 0 { 57 | log.Logger.Info(`"dockle" requires at least 1 argument or --input option.`) 58 | cli.ShowAppHelpAndExit(c, 1) 59 | return 60 | } 61 | // set docker option 62 | dockerOption := deckodertypes.DockerOption{ 63 | Timeout: c.Duration("timeout"), 64 | UserName: c.String("username"), 65 | Password: c.String("password"), 66 | InsecureSkipTLSVerify: c.Bool("insecure"), 67 | DockerDaemonHost: getDockerSockPath(c), 68 | DockerDaemonCertPath: c.String("cert-path"), 69 | SkipPing: true, 70 | } 71 | var imageName string 72 | if filePath == "" { 73 | imageName = args[0] 74 | } 75 | 76 | var useLatestTag bool 77 | // Check whether 'latest' tag is used 78 | if imageName != "" { 79 | if useLatestTag, err = useLatest(imageName); err != nil { 80 | return fmt.Errorf("invalid image: %w", err) 81 | } 82 | } 83 | manifest.AddSensitiveWords(c.StringSlice("sensitive-word")) 84 | manifest.AddAcceptanceKeys(c.StringSlice("accept-key")) 85 | credential.AddSensitiveFiles(c.StringSlice("sensitive-file")) 86 | scanner.AddAcceptanceFiles(c.StringSlice("accept-file")) 87 | credential.AddSensitiveFileExtensions(c.StringSlice("sensitive-file-extension")) 88 | scanner.AddAcceptanceExtensions(c.StringSlice("accept-file-extension")) 89 | log.Logger.Debug("Start assessments...") 90 | assessments, err := scanner.ScanImage(ctx, imageName, filePath, dockerOption) 91 | if err != nil { 92 | if errors.Is(err, context.DeadlineExceeded) { 93 | return fmt.Errorf("Pull it with \"docker pull %s\" or \"dockle --timeout 600s\" to increase the timeout\n%w", imageName, err) 94 | } 95 | return err 96 | } 97 | if useLatestTag { 98 | assessments = append(assessments, &types.Assessment{ 99 | Code: types.AvoidLatestTag, 100 | Filename: imageName, 101 | Desc: "Avoid 'latest' tag", 102 | }) 103 | } 104 | 105 | log.Logger.Debug("End assessments...") 106 | 107 | assessmentMap := types.CreateAssessmentMap(assessments, config.Conf.IgnoreMap, debug) 108 | // Store ignore checkpoint code 109 | o := c.String("output") 110 | output := os.Stdout 111 | if o != "" { 112 | if output, err = os.Create(o); err != nil { 113 | return fmt.Errorf("failed to create an output file: %w", err) 114 | } 115 | } 116 | 117 | var writer report.Writer 118 | switch format := c.String("format"); format { 119 | case "json": 120 | writer = &report.JsonWriter{Output: output, ImageName: imageName} 121 | case "sarif": 122 | writer = &report.SarifWriter{Output: output} 123 | default: 124 | writer = &report.ListWriter{Output: output, NoColor: c.Bool("no-color")} 125 | } 126 | 127 | abend, err := writer.Write(assessmentMap) 128 | if err != nil { 129 | return fmt.Errorf("failed to write results: %w", err) 130 | } 131 | 132 | if config.Conf.ExitCode != 0 && abend { 133 | os.Exit(config.Conf.ExitCode) 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func getDockerSockPath(c *cli.Context) string { 140 | if c.String("host") != "" { 141 | return c.String("host") 142 | } 143 | xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") 144 | if c.Bool("use-xdg") && xdgRuntimeDir != "" { 145 | return fmt.Sprintf("unix://%s/docker.sock", xdgRuntimeDir) 146 | } 147 | return "unix:///var/run/docker.sock" 148 | } 149 | 150 | func useLatest(imageName string) (bool, error) { 151 | ref, err := alltransports.ParseImageName("docker://" + imageName) 152 | if err != nil { 153 | return false, err 154 | 155 | } 156 | return strings.HasSuffix(ref.DockerReference().String(), ":latest"), nil 157 | } 158 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/goodwithtech/dockle 2 | 3 | go 1.22.10 4 | 5 | require ( 6 | github.com/containers/image/v5 v5.33.0 7 | github.com/d4l3k/messagediff v1.2.2-0.20180726183240-b9e99b2f9263 8 | github.com/docker/go-connections v0.5.0 9 | github.com/goodwithtech/deckoder v0.0.6 10 | github.com/google/go-cmp v0.6.0 11 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 12 | github.com/owenrumney/go-sarif/v2 v2.0.17 13 | github.com/urfave/cli v1.22.15 14 | go.uber.org/zap v1.17.0 15 | ) 16 | 17 | require ( 18 | cloud.google.com/go/auth v0.10.0 // indirect 19 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 20 | dario.cat/mergo v1.0.1 // indirect 21 | github.com/BurntSushi/toml v1.4.0 // indirect 22 | github.com/GoogleCloudPlatform/docker-credential-gcr/v2 v2.1.26 // indirect 23 | github.com/Microsoft/go-winio v0.6.2 // indirect 24 | github.com/Microsoft/hcsshim v0.12.9 // indirect 25 | github.com/aws/aws-sdk-go v1.55.5 // indirect 26 | github.com/containerd/cgroups/v3 v3.0.3 // indirect 27 | github.com/containerd/errdefs v0.3.0 // indirect 28 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 29 | github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect 30 | github.com/containerd/typeurl/v2 v2.2.0 // indirect 31 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect 32 | github.com/containers/ocicrypt v1.2.0 // indirect 33 | github.com/containers/storage v1.56.0 // indirect 34 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 35 | github.com/cyphar/filepath-securejoin v0.3.4 // indirect 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 37 | github.com/distribution/reference v0.6.0 // indirect 38 | github.com/docker/distribution v2.8.3+incompatible // indirect 39 | github.com/docker/docker v27.3.1+incompatible // indirect 40 | github.com/docker/docker-credential-helpers v0.8.2 // indirect 41 | github.com/docker/go-units v0.5.0 // indirect 42 | github.com/felixge/httpsnoop v1.0.4 // indirect 43 | github.com/go-logr/logr v1.4.2 // indirect 44 | github.com/go-logr/stdr v1.2.2 // indirect 45 | github.com/gogo/protobuf v1.3.2 // indirect 46 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 47 | github.com/google/go-containerregistry v0.20.2 // indirect 48 | github.com/google/go-intervals v0.0.2 // indirect 49 | github.com/google/uuid v1.6.0 // indirect 50 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 51 | github.com/gorilla/mux v1.8.1 // indirect 52 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect 53 | github.com/hashicorp/errwrap v1.1.0 // indirect 54 | github.com/hashicorp/go-multierror v1.1.1 // indirect 55 | github.com/jmespath/go-jmespath v0.4.0 // indirect 56 | github.com/json-iterator/go v1.1.12 // indirect 57 | github.com/klauspost/compress v1.17.11 // indirect 58 | github.com/klauspost/pgzip v1.2.6 // indirect 59 | github.com/knqyf263/nested v0.0.1 // indirect 60 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 61 | github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect 62 | github.com/moby/docker-image-spec v1.3.1 // indirect 63 | github.com/moby/sys/capability v0.3.0 // indirect 64 | github.com/moby/sys/mountinfo v0.7.2 // indirect 65 | github.com/moby/sys/user v0.3.0 // indirect 66 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 67 | github.com/modern-go/reflect2 v1.0.2 // indirect 68 | github.com/opencontainers/go-digest v1.0.0 // indirect 69 | github.com/opencontainers/image-spec v1.1.0 // indirect 70 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 71 | github.com/opencontainers/selinux v1.11.1 // indirect 72 | github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f // indirect 73 | github.com/pkg/errors v0.9.1 // indirect 74 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 75 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 76 | github.com/sirupsen/logrus v1.9.3 // indirect 77 | github.com/stretchr/objx v0.5.2 // indirect 78 | github.com/stretchr/testify v1.10.0 // indirect 79 | github.com/sylabs/sif/v2 v2.19.1 // indirect 80 | github.com/tchap/go-patricia/v2 v2.3.1 // indirect 81 | github.com/toqueteos/webbrowser v1.2.0 // indirect 82 | github.com/ulikunitz/xz v0.5.12 // indirect 83 | github.com/vbatts/tar-split v0.11.6 // indirect 84 | go.opencensus.io v0.24.0 // indirect 85 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 86 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 87 | go.opentelemetry.io/otel v1.33.0 // indirect 88 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 89 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 90 | go.uber.org/atomic v1.7.0 // indirect 91 | go.uber.org/multierr v1.6.0 // indirect 92 | golang.org/x/crypto v0.29.0 // indirect 93 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 94 | golang.org/x/oauth2 v0.23.0 // indirect 95 | golang.org/x/sync v0.9.0 // indirect 96 | golang.org/x/sys v0.28.0 // indirect 97 | golang.org/x/text v0.20.0 // indirect 98 | golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect 99 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 100 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect 101 | google.golang.org/grpc v1.68.0 // indirect 102 | google.golang.org/protobuf v1.35.2 // indirect 103 | gopkg.in/yaml.v3 v3.0.1 // indirect 104 | ) 105 | -------------------------------------------------------------------------------- /pkg/app.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urfave/cli" 7 | ) 8 | 9 | var ( 10 | version = "dev" 11 | ) 12 | 13 | /* 14 | NewApp Factory for Dockle CLI creation. 15 | An Enabler for programmatic usage of Dockle 16 | */ 17 | func NewApp() *cli.App { 18 | cli.AppHelpTemplate = `NAME: 19 | {{.Name}}{{if .Usage}} - {{.Usage}}{{end}} 20 | USAGE: 21 | {{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}} 22 | VERSION: 23 | {{.Version}}{{end}}{{end}}{{if .Description}} 24 | DESCRIPTION: 25 | {{.Description}}{{end}}{{if len .Authors}} 26 | AUTHOR{{with $length := len .Authors}}{{if ne 1 $length}}S{{end}}{{end}}: 27 | {{range $index, $author := .Authors}}{{if $index}} 28 | {{end}}{{$author}}{{end}}{{end}}{{if .VisibleCommands}} 29 | OPTIONS: 30 | {{range $index, $option := .VisibleFlags}}{{if $index}} 31 | {{end}}{{$option}}{{end}}{{end}} 32 | ` 33 | app := cli.NewApp() 34 | 35 | var dockerSockPath string 36 | app.Name = "dockle" 37 | app.Version = version 38 | app.ArgsUsage = "image_name" 39 | 40 | app.Usage = "Container Image Linter for Security, Helping build the Best-Practice Docker Image, Easy to start" 41 | 42 | app.Flags = []cli.Flag{ 43 | cli.StringFlag{ 44 | Name: "input", 45 | Usage: "input file path instead of image name", 46 | }, 47 | cli.StringSliceFlag{ 48 | Name: "ignore, i", 49 | EnvVar: "DOCKLE_IGNORES", 50 | Usage: "checkpoints to ignore. You can use .dockleignore too.", 51 | }, 52 | cli.StringSliceFlag{ 53 | Name: "accept-key, ak", 54 | EnvVar: "DOCKLE_ACCEPT_KEYS", 55 | Usage: "For CIS-DI-0010. You can add acceptable keywords. e.g) -ak GPG_KEY -ak KEYCLOAK", 56 | }, 57 | cli.StringSliceFlag{ 58 | Name: "sensitive-word, sw", 59 | EnvVar: "DOCKLE_REJECT_KEYS", 60 | Usage: "For CIS-DI-0010. You can add sensitive keywords to look for. e.g) -ak api_password -sw keys", 61 | }, 62 | cli.StringSliceFlag{ 63 | Name: "accept-file, af", 64 | EnvVar: "DOCKLE_ACCEPT_FILES", 65 | Usage: "For CIS-DI-0010. You can add acceptable file names. e.g) -af id_rsa -af config.json", 66 | }, 67 | cli.StringSliceFlag{ 68 | Name: "sensitive-file, sf", 69 | EnvVar: "DOCKLE_REJECT_FILES", 70 | Usage: "For CIS-DI-0010. You can add sensitive files to look for. e.g) -sf .git", 71 | }, 72 | cli.StringSliceFlag{ 73 | Name: "accept-file-extension, ae", 74 | EnvVar: "DOCKLE_ACCEPT_FILE_EXTENSIONS", 75 | Usage: "For CIS-DI-0010. You can add acceptable file extensions. e.g) -ae pem -ae log", 76 | }, 77 | cli.StringSliceFlag{ 78 | Name: "sensitive-file-extension, se", 79 | EnvVar: "DOCKLE_REJECT_FILE_EXTENSIONS", 80 | Usage: "For CIS-DI-0010. You can add sensitive files to look for. e.g) -se .pfx", 81 | }, 82 | cli.StringFlag{ 83 | Name: "format, f", 84 | Value: "", 85 | EnvVar: "DOCKLE_OUTPUT_FORMAT", 86 | Usage: "output format (list, json, sarif)", 87 | }, 88 | cli.StringFlag{ 89 | Name: "output, o", 90 | EnvVar: "DOCKLE_OUTPUT_FILE", 91 | Usage: "output file name", 92 | }, 93 | cli.IntFlag{ 94 | Name: "exit-code, c", 95 | Value: 0, 96 | EnvVar: "DOCKLE_EXIT_CODE", 97 | Usage: "exit code when alert were found", 98 | }, 99 | cli.StringFlag{ 100 | Name: "exit-level, l", 101 | Value: "WARN", 102 | EnvVar: "DOCKLE_EXIT_LEVEL", 103 | Usage: "change ABEND level when use exit-code=1", 104 | }, 105 | cli.BoolFlag{ 106 | Name: "debug, d", 107 | EnvVar: "DOCKLE_DEBUG", 108 | Usage: "debug mode", 109 | }, 110 | cli.BoolFlag{ 111 | Name: "quiet, q", 112 | EnvVar: "DOCKLE_QUIET", 113 | Usage: "suppress log output", 114 | }, 115 | cli.BoolFlag{ 116 | Name: "no-color", 117 | EnvVar: "NO_COLOR", 118 | Usage: "disabling color output", 119 | }, 120 | cli.BoolFlag{ 121 | Name: "version-check", 122 | EnvVar: "DOCKLE_VERSION_CHECK", 123 | Usage: "show an update notification", 124 | }, 125 | 126 | // Registry flag 127 | cli.DurationFlag{ 128 | Name: "timeout, t", 129 | Value: time.Second * 90, 130 | EnvVar: "DOCKLE_TIMEOUT", 131 | Usage: "docker timeout. e.g) 5s, 5m...", 132 | }, 133 | cli.BoolFlag{ 134 | Name: "use-xdg, x", 135 | EnvVar: "USE_XDG", 136 | Usage: "Docker daemon host file XDG_RUNTIME_DIR", 137 | }, 138 | cli.StringFlag{ 139 | Name: "host", 140 | EnvVar: "DOCKLE_HOST", 141 | Usage: "docker daemon host", 142 | Value: dockerSockPath, 143 | }, 144 | cli.StringFlag{ 145 | Name: "authurl", 146 | EnvVar: "DOCKLE_AUTH_URL", 147 | Usage: "registry authenticate url", 148 | }, 149 | cli.StringFlag{ 150 | Name: "username", 151 | EnvVar: "DOCKLE_USERNAME", 152 | Usage: "registry login username", 153 | }, 154 | cli.StringFlag{ 155 | Name: "password", 156 | EnvVar: "DOCKLE_PASSWORD", 157 | Usage: "registry login password. Using --password via CLI is insecure.", 158 | }, 159 | cli.BoolFlag{ 160 | Name: "insecure", 161 | EnvVar: "DOCKLE_INSECURE", 162 | Usage: "registry connect insecure", 163 | }, 164 | cli.BoolTFlag{ 165 | Name: "nonssl", 166 | EnvVar: "DOCKLE_NON_SSL", 167 | Usage: "registry connect without ssl", 168 | }, 169 | cli.StringFlag{ 170 | Name: "cert-path", 171 | EnvVar: "DOCKLE_CERT_PATH", 172 | Usage: "docker daemon certificate path", 173 | Value: dockerSockPath, 174 | }, 175 | cli.StringFlag{ 176 | Name: "cache-dir", 177 | Usage: "cache directory", 178 | }, 179 | } 180 | 181 | app.Action = Run 182 | return app 183 | } 184 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/testdata/nginx.json: -------------------------------------------------------------------------------- 1 | {"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NGINX_VERSION=1.17.0","NJS_VERSION=0.3.2","PKG_RELEASE=1~stretch"],"Cmd":["nginx","-g","daemon off;"],"ArgsEscaped":true,"Image":"sha256:b4c546a22099dc546c525e66dd7bbf40bc183195a0559d418b04057cbe363792","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{"maintainer":"NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"},"StopSignal":"SIGTERM"},"container":"6e81d902bf0b5631099c0789d2573a348a26649a73391e67fa4b14545f6de200","container_config":{"Hostname":"6e81d902bf0b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NGINX_VERSION=1.17.0","NJS_VERSION=0.3.2","PKG_RELEASE=1~stretch"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"nginx\" \"-g\" \"daemon off;\"]"],"ArgsEscaped":true,"Image":"sha256:b4c546a22099dc546c525e66dd7bbf40bc183195a0559d418b04057cbe363792","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{"maintainer":"NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"},"StopSignal":"SIGTERM"},"created":"2019-06-04T22:29:15.22271163Z","docker_version":"18.06.1-ce","history":[{"created":"2019-05-08T00:33:32.152758355Z","created_by":"/bin/sh -c #(nop) ADD file:fcb9328ea4c1156709f3d04c3d9a5f3667e77fb36a4a83390ae2495555fc0238 in / "},{"created":"2019-05-08T00:33:32.718284983Z","created_by":"/bin/sh -c #(nop) CMD [\"bash\"]","empty_layer":true},{"created":"2019-05-08T03:01:16.010671568Z","created_by":"/bin/sh -c #(nop) LABEL maintainer=NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e","empty_layer":true},{"created":"2019-06-04T22:28:23.14261441Z","created_by":"/bin/sh -c #(nop) ENV NGINX_VERSION=1.17.0","empty_layer":true},{"created":"2019-06-04T22:28:23.428536076Z","created_by":"/bin/sh -c #(nop) ENV NJS_VERSION=0.3.2","empty_layer":true},{"created":"2019-06-04T22:28:23.694007905Z","created_by":"/bin/sh -c #(nop) ENV PKG_RELEASE=1~stretch","empty_layer":true},{"created":"2019-06-04T22:29:12.673598323Z","created_by":"/bin/sh -c set -x \u0026\u0026 addgroup --system --gid 101 nginx \u0026\u0026 adduser --system --disabled-login --ingroup nginx --no-create-home --home /nonexistent --gecos \"nginx user\" --shell /bin/false --uid 101 nginx \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install --no-install-recommends --no-install-suggests -y gnupg1 apt-transport-https ca-certificates \t\u0026\u0026 \tNGINX_GPGKEY=573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62; \tfound=''; \tfor server in \t\tha.pool.sks-keyservers.net \t\thkp://keyserver.ubuntu.com:80 \t\thkp://p80.pool.sks-keyservers.net:80 \t\tpgp.mit.edu \t; do \t\techo \"Fetching GPG key $NGINX_GPGKEY from $server\"; \t\tapt-key adv --keyserver \"$server\" --keyserver-options timeout=10 --recv-keys \"$NGINX_GPGKEY\" \u0026\u0026 found=yes \u0026\u0026 break; \tdone; \ttest -z \"$found\" \u0026\u0026 echo \u003e\u00262 \"error: failed to fetch GPG key $NGINX_GPGKEY\" \u0026\u0026 exit 1; \tapt-get remove --purge --auto-remove -y gnupg1 \u0026\u0026 rm -rf /var/lib/apt/lists/* \t\u0026\u0026 dpkgArch=\"$(dpkg --print-architecture)\" \t\u0026\u0026 nginxPackages=\" \t\tnginx=${NGINX_VERSION}-${PKG_RELEASE} \t\tnginx-module-xslt=${NGINX_VERSION}-${PKG_RELEASE} \t\tnginx-module-geoip=${NGINX_VERSION}-${PKG_RELEASE} \t\tnginx-module-image-filter=${NGINX_VERSION}-${PKG_RELEASE} \t\tnginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-${PKG_RELEASE} \t\" \t\u0026\u0026 case \"$dpkgArch\" in \t\tamd64|i386) \t\t\techo \"deb https://nginx.org/packages/mainline/debian/ stretch nginx\" \u003e\u003e /etc/apt/sources.list.d/nginx.list \t\t\t\u0026\u0026 apt-get update \t\t\t;; \t\t*) \t\t\techo \"deb-src https://nginx.org/packages/mainline/debian/ stretch nginx\" \u003e\u003e /etc/apt/sources.list.d/nginx.list \t\t\t\t\t\t\u0026\u0026 tempDir=\"$(mktemp -d)\" \t\t\t\u0026\u0026 chmod 777 \"$tempDir\" \t\t\t\t\t\t\u0026\u0026 savedAptMark=\"$(apt-mark showmanual)\" \t\t\t\t\t\t\u0026\u0026 apt-get update \t\t\t\u0026\u0026 apt-get build-dep -y $nginxPackages \t\t\t\u0026\u0026 ( \t\t\t\tcd \"$tempDir\" \t\t\t\t\u0026\u0026 DEB_BUILD_OPTIONS=\"nocheck parallel=$(nproc)\" \t\t\t\t\tapt-get source --compile $nginxPackages \t\t\t) \t\t\t\t\t\t\u0026\u0026 apt-mark showmanual | xargs apt-mark auto \u003e /dev/null \t\t\t\u0026\u0026 { [ -z \"$savedAptMark\" ] || apt-mark manual $savedAptMark; } \t\t\t\t\t\t\u0026\u0026 ls -lAFh \"$tempDir\" \t\t\t\u0026\u0026 ( cd \"$tempDir\" \u0026\u0026 dpkg-scanpackages . \u003e Packages ) \t\t\t\u0026\u0026 grep '^Package: ' \"$tempDir/Packages\" \t\t\t\u0026\u0026 echo \"deb [ trusted=yes ] file://$tempDir ./\" \u003e /etc/apt/sources.list.d/temp.list \t\t\t\u0026\u0026 apt-get -o Acquire::GzipIndexes=false update \t\t\t;; \tesac \t\t\u0026\u0026 apt-get install --no-install-recommends --no-install-suggests -y \t\t\t\t\t\t$nginxPackages \t\t\t\t\t\tgettext-base \t\u0026\u0026 apt-get remove --purge --auto-remove -y apt-transport-https ca-certificates \u0026\u0026 rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list \t\t\u0026\u0026 if [ -n \"$tempDir\" ]; then \t\tapt-get purge -y --auto-remove \t\t\u0026\u0026 rm -rf \"$tempDir\" /etc/apt/sources.list.d/temp.list; \tfi"},{"created":"2019-06-04T22:29:14.35618609Z","created_by":"/bin/sh -c ln -sf /dev/stdout /var/log/nginx/access.log \t\u0026\u0026 ln -sf /dev/stderr /var/log/nginx/error.log"},{"created":"2019-06-04T22:29:14.591650162Z","created_by":"/bin/sh -c #(nop) EXPOSE 80","empty_layer":true},{"created":"2019-06-04T22:29:14.860799242Z","created_by":"/bin/sh -c #(nop) STOPSIGNAL SIGTERM","empty_layer":true},{"created":"2019-06-04T22:29:15.22271163Z","created_by":"/bin/sh -c #(nop) CMD [\"nginx\" \"-g\" \"daemon off;\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:6270adb5794c6987109e54af00ab456977c5d5cc6f1bc52c1ce58d32ec0f15f4","sha256:22c458a3ff08c2249c2362281035476427dd84ae6c4d12a14e1f79e7ae325eba","sha256:ea06a73e56fce0a3293829c54bedc2f77d88c4d5951ff1acd59ee1507ad2ed7f"]}} -------------------------------------------------------------------------------- /CHECKPOINT.md: -------------------------------------------------------------------------------- 1 | 2 | # Checkpoint Details 3 | 4 | ## Docker Image Checkpoints 5 | 6 | These checkpoints referred to [CIS Docker 1.13.0 Benchmark v1.0.0](https://www.cisecurity.org/benchmark/docker/). 7 | 8 | ### CIS-DI-0001 9 | **Create a user for the container** 10 | 11 | > Create a non-root user for the container in the Dockerfile for the container image. 12 | > 13 | > It is a good practice to run the container as a non-root user, if possible. 14 | 15 | ``` 16 | # Dockerfile 17 | RUN useradd -d /home/dockle -m -s /bin/bash dockle 18 | USER dockle 19 | 20 | or 21 | 22 | RUN addgroup -S dockle && adduser -S -G dockle dockle 23 | USER dockle 24 | 25 | ``` 26 | 27 | ### CIS-DI-0002 28 | **Use trusted base images for containers** 29 | 30 | Dockle checks [Content Trust](https://docs.docker.com/engine/security/trust/content_trust/). 31 | 32 | ### CIS-DI-0003 33 | **Do not install unnecessary packages in the container** 34 | 35 | Not supported. 36 | 37 | ### CIS-DI-0004 38 | **Scan and rebuild the images to include security patches** 39 | 40 | Not supported. 41 | Please check with [Trivy](https://github.com/knqyf263/trivy). 42 | 43 | ### CIS-DI-0005 44 | **Enable Content trust for Docker** 45 | 46 | > Content trust is disabled by default. You should enable it. 47 | 48 | ```bash 49 | $ export DOCKER_CONTENT_TRUST=1 50 | ``` 51 | 52 | - https://docs.docker.com/engine/security/trust/content_trust/#about-docker-content-trust-dct 53 | 54 | > Docker Content Trust (DCT) provides the ability to use digital signatures for data sent to and received from remote Docker registries.
55 | > Engine Signature Verification prevents the following: 56 | > 57 | > - `$ docker container run` of an unsigned image. 58 | > - `$ docker pull` of an unsigned image. 59 | > - `$ docker build` where the FROM image is not signed or is not scratch. 60 | 61 | ### CIS-DI-0006 62 | **Add `HEALTHCHECK` instruction to the container image** 63 | 64 | > Add `HEALTHCHECK` instruction in your docker container images to perform the health check on running containers.
65 | > Based on the reported health status, the docker engine could then exit non-working containers and instantiate new ones. 66 | 67 | ``` 68 | # Dockerfile 69 | HEALTHCHECK --interval=5m --timeout=3s \ 70 | CMD curl -f http://localhost/ || exit 1 71 | ``` 72 | 73 | ### CIS-DI-0007 74 | **Do not use `update` instructions alone in the Dockerfile** 75 | 76 | > Do not use `update` instructions such as `apt-get update` alone or in a single line in the Dockerfile.
77 | > Adding the `update` instructions in a single line on the Dockerfile will cache the update layer. 78 | 79 | ```bash 80 | RUN apt-get update && apt-get install -y package-a 81 | ``` 82 | 83 | ### CIS-DI-0008 84 | **Confirm safety of `setuid` and `setgid` files** 85 | 86 | > Removing `setuid` and `setgid` permissions in the images would prevent privilege escalation attacks in the containers.
87 | > `setuid` and `setgid` permissions could be used for elevating privileges. 88 | 89 | ```bash 90 | chmod u-s setuid-file 91 | chmod g-s setgid-file 92 | ``` 93 | 94 | ### CIS-DI-0009 95 | **Use `COPY` instead of `ADD` in Dockerfile** 96 | 97 | > Use `COPY` instruction instead of `ADD` instruction in the Dockerfile.
98 | > `ADD` instruction introduces risks such as adding malicious files from URLs without scanning and unpacking procedure vulnerabilities. 99 | 100 | ``` 101 | # Dockerfile 102 | ADD test.json /app/test.json 103 | ↓ 104 | COPY test.json /app/test.json 105 | ``` 106 | 107 | ### CIS-DI-0010 108 | **Do not store secrets in Dockerfiles** 109 | 110 | > Do not store any secrets in Dockerfiles.
111 | > the secrets within these Dockerfiles could be easily exposed and potentially be exploited. 112 | 113 | `Dockle` checks ENVIRONMENT variables and credential files. 114 | 115 | ### CIS-DI-0011 116 | **Install verified packages only** 117 | 118 | Not supported. 119 | It's better to use [Trivy](https://github.com/knqyf263/trivy). 120 | 121 | ## Dockle Checkpoints for Docker 122 | 123 | These checkpoints referred to [Docker Best Practice](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) and so on. 124 | 125 | ### DKL-DI-0001 126 | **Avoid `sudo` command** 127 | 128 | - https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#user 129 | 130 | > Avoid installing or using sudo as it has unpredictable TTY and signal-forwarding behavior that can cause problems. 131 | 132 | ### DKL-DI-0002 133 | **Avoid sensitive directory mounting** 134 | 135 | A volume mount makes weak points. This depends on mounting volumes. 136 | 137 | Currently, `Dockle` checks following directories: 138 | 139 | - `/dev`, `/proc`, `/sys` 140 | 141 | `dockle` only checks `VOLUME` statements, since we can't check `docker run -v /lib:/lib ...`. 142 | 143 | 144 | ### DKL-DI-0003 145 | **Avoid `apt-get dist-upgrade`** 146 | 147 | https://github.com/docker/docker.github.io/pull/12571 148 | 149 | ~~https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#apt-get~~ 150 | ~~Avoid `RUN apt-get upgrade` and `dist-upgrade`, as many of the “essential” packages from the parent images cannot upgrade inside an unprivileged container.~~ 151 | 152 | ### DKL-DI-0004 153 | **Use `apk add` with `--no-cache`** 154 | 155 | - https://github.com/gliderlabs/docker-alpine/blob/master/docs/usage.md#disabling-cache 156 | 157 | > As of Alpine Linux 3.3 there exists a new `--no-cache` option for `apk`. It allows users to install packages with an index that is updated and used on-the-fly and not cached locally:
158 | > ...
159 | > This avoids the need to use `--update` and remove `/var/cache/apk/*` when done installing packages. 160 | 161 | ### DKL-DI-0005 162 | **Clear `apt-get` caches** 163 | 164 | Use `apt-get clean && rm -rf /var/lib/apt/lists/*` after `apt-get install`. 165 | 166 | - https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#apt-get 167 | 168 | > In addition, when you clean up the `apt cache` by removing `/var/lib/apt/lists` it reduces the image size, since the apt cache is not stored in a layer. Since the `RUN` statement starts with `apt-get update`, the package cache is always refreshed prior to `apt-get install`. 169 | 170 | ### DKL-DI-0006 171 | **Avoid `latest` tag** 172 | 173 | - https://vsupalov.com/docker-latest-tag/ 174 | 175 | > Docker images tagged with `:latest` have caused many people a lot of trouble. 176 | 177 | ## Dockle Checkpoints for Linux 178 | 179 | These checkpoints referred to [Linux Best Practices](https://www.cyberciti.biz/tips/linux-security.html) and so on. 180 | 181 | ### DKL-LI-0001 182 | **Avoid empty password** 183 | 184 | - https://blog.aquasec.com/cve-2019-5021-alpine-docker-image-vulnerability 185 | 186 | > CVE-2019-5021: Alpine Docker Image "null root password" Vulnerability 187 | 188 | ### DKL-LI-0002 189 | **Be unique UID/GROUPs** 190 | 191 | - http://www.linfo.org/uid.html 192 | 193 | > Contrary to popular belief, it is not necessary that each entry in the UID field be unique. However, non-unique UIDs can cause security problems, and thus UIDs should be kept unique across the entire organization. 194 | 195 | ### DKL-LI-0003 196 | **Only put necessary files** 197 | 198 | Check `.cache`, `.git` and so on directories. 199 | -------------------------------------------------------------------------------- /.vex/dockle.openvex.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://openvex.dev/ns/v0.2.0", 3 | "@id": "goodwithtech/dockle:e3f1396fca8b873f997c9fd51e1db455bdc501a8", 4 | "author": "Tomoya AMACHI", 5 | "timestamp": "2024-08-20T15:40:25.683571Z", 6 | "version": 1, 7 | "tooling": "https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck", 8 | "statements": [ 9 | { 10 | "vulnerability": { 11 | "@id": "https://pkg.go.dev/vuln/GO-2022-0646", 12 | "name": "GO-2022-0646", 13 | "description": "Use of risky cryptographic algorithm in github.com/aws/aws-sdk-go", 14 | "aliases": [ 15 | "CVE-2020-8911", 16 | "CVE-2020-8912", 17 | "GHSA-7f33-f4f5-xwgw", 18 | "GHSA-f5pg-7wfw-84q9" 19 | ] 20 | }, 21 | "products": [ 22 | { 23 | "@id": "Unknown Product" 24 | } 25 | ], 26 | "status": "not_affected", 27 | "justification": "vulnerable_code_not_present", 28 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 29 | }, 30 | { 31 | "vulnerability": { 32 | "@id": "https://pkg.go.dev/vuln/GO-2024-2512", 33 | "name": "GO-2024-2512", 34 | "description": "Classic builder cache poisoning in github.com/docker/docker", 35 | "aliases": [ 36 | "CVE-2024-24557", 37 | "GHSA-xw73-rw38-6vjc" 38 | ] 39 | }, 40 | "products": [ 41 | { 42 | "@id": "Unknown Product" 43 | } 44 | ], 45 | "status": "not_affected", 46 | "justification": "vulnerable_code_not_present", 47 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 48 | }, 49 | { 50 | "vulnerability": { 51 | "@id": "https://pkg.go.dev/vuln/GO-2024-2598", 52 | "name": "GO-2024-2598", 53 | "description": "Verify panics on certificates with an unknown public key algorithm in crypto/x509", 54 | "aliases": [ 55 | "CVE-2024-24783" 56 | ] 57 | }, 58 | "products": [ 59 | { 60 | "@id": "Unknown Product" 61 | } 62 | ], 63 | "status": "under_investigation" 64 | }, 65 | { 66 | "vulnerability": { 67 | "@id": "https://pkg.go.dev/vuln/GO-2024-2599", 68 | "name": "GO-2024-2599", 69 | "description": "Memory exhaustion in multipart form parsing in net/textproto and net/http", 70 | "aliases": [ 71 | "CVE-2023-45290" 72 | ] 73 | }, 74 | "products": [ 75 | { 76 | "@id": "Unknown Product" 77 | } 78 | ], 79 | "status": "under_investigation" 80 | }, 81 | { 82 | "vulnerability": { 83 | "@id": "https://pkg.go.dev/vuln/GO-2024-2600", 84 | "name": "GO-2024-2600", 85 | "description": "Incorrect forwarding of sensitive headers and cookies on HTTP redirect in net/http", 86 | "aliases": [ 87 | "CVE-2023-45289" 88 | ] 89 | }, 90 | "products": [ 91 | { 92 | "@id": "Unknown Product" 93 | } 94 | ], 95 | "status": "under_investigation" 96 | }, 97 | { 98 | "vulnerability": { 99 | "@id": "https://pkg.go.dev/vuln/GO-2024-2609", 100 | "name": "GO-2024-2609", 101 | "description": "Comments in display names are incorrectly handled in net/mail", 102 | "aliases": [ 103 | "CVE-2024-24784" 104 | ] 105 | }, 106 | "products": [ 107 | { 108 | "@id": "Unknown Product" 109 | } 110 | ], 111 | "status": "not_affected", 112 | "justification": "vulnerable_code_not_present", 113 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 114 | }, 115 | { 116 | "vulnerability": { 117 | "@id": "https://pkg.go.dev/vuln/GO-2024-2610", 118 | "name": "GO-2024-2610", 119 | "description": "Errors returned from JSON marshaling may break template escaping in html/template", 120 | "aliases": [ 121 | "CVE-2024-24785" 122 | ] 123 | }, 124 | "products": [ 125 | { 126 | "@id": "Unknown Product" 127 | } 128 | ], 129 | "status": "not_affected", 130 | "justification": "vulnerable_code_not_present", 131 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 132 | }, 133 | { 134 | "vulnerability": { 135 | "@id": "https://pkg.go.dev/vuln/GO-2024-2687", 136 | "name": "GO-2024-2687", 137 | "description": "HTTP/2 CONTINUATION flood in net/http", 138 | "aliases": [ 139 | "CVE-2023-45288", 140 | "GHSA-4v7x-pqxf-cx7m" 141 | ] 142 | }, 143 | "products": [ 144 | { 145 | "@id": "Unknown Product" 146 | } 147 | ], 148 | "status": "under_investigation" 149 | }, 150 | { 151 | "vulnerability": { 152 | "@id": "https://pkg.go.dev/vuln/GO-2024-2842", 153 | "name": "GO-2024-2842", 154 | "description": "Unexpected authenticated registry accesses in github.com/containers/image/v5", 155 | "aliases": [ 156 | "CVE-2024-3727", 157 | "GHSA-6wvf-f2vw-3425" 158 | ] 159 | }, 160 | "products": [ 161 | { 162 | "@id": "Unknown Product" 163 | } 164 | ], 165 | "status": "under_investigation" 166 | }, 167 | { 168 | "vulnerability": { 169 | "@id": "https://pkg.go.dev/vuln/GO-2024-2887", 170 | "name": "GO-2024-2887", 171 | "description": "Unexpected behavior from Is methods for IPv4-mapped IPv6 addresses in net/netip", 172 | "aliases": [ 173 | "CVE-2024-24790" 174 | ] 175 | }, 176 | "products": [ 177 | { 178 | "@id": "Unknown Product" 179 | } 180 | ], 181 | "status": "under_investigation" 182 | }, 183 | { 184 | "vulnerability": { 185 | "@id": "https://pkg.go.dev/vuln/GO-2024-2888", 186 | "name": "GO-2024-2888", 187 | "description": "Mishandling of corrupt central directory record in archive/zip", 188 | "aliases": [ 189 | "CVE-2024-24789" 190 | ] 191 | }, 192 | "products": [ 193 | { 194 | "@id": "Unknown Product" 195 | } 196 | ], 197 | "status": "not_affected", 198 | "justification": "vulnerable_code_not_present", 199 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 200 | }, 201 | { 202 | "vulnerability": { 203 | "@id": "https://pkg.go.dev/vuln/GO-2024-2963", 204 | "name": "GO-2024-2963", 205 | "description": "Denial of service due to improper 100-continue handling in net/http", 206 | "aliases": [ 207 | "CVE-2024-24791" 208 | ] 209 | }, 210 | "products": [ 211 | { 212 | "@id": "Unknown Product" 213 | } 214 | ], 215 | "status": "under_investigation" 216 | }, 217 | { 218 | "vulnerability": { 219 | "@id": "https://pkg.go.dev/vuln/GO-2024-3005", 220 | "name": "GO-2024-3005", 221 | "description": "Moby authz zero length regression in github.com/moby/moby", 222 | "aliases": [ 223 | "CVE-2024-41110" 224 | ] 225 | }, 226 | "products": [ 227 | { 228 | "@id": "Unknown Product" 229 | } 230 | ], 231 | "status": "not_affected", 232 | "justification": "vulnerable_code_not_present", 233 | "impact_statement": "Govulncheck determined that the vulnerable code isn't called" 234 | } 235 | ] 236 | } 237 | -------------------------------------------------------------------------------- /pkg/scanner/testdata/scratch.tar: -------------------------------------------------------------------------------- 1 | 26ba9f75a61dae80eace28eb9e17de6122395d6c4c1a8ac369005a9e3d4f9a36.json0100644000000000000000000000270613572101727023067 0ustar00rootroot00000000000000{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"scratch","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":null,"Image":"sha256:2822410717b6e02473529fb5c959b41acf90d0383c2ee753fa7b3c0f6952c25c","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"8c33aaa9bf02dbe2a080669feaa78df05bc988387e38f1cb00e08ca5789017e3","container_config":{"Hostname":"8c33aaa9bf02","Domainname":"","User":"scratch","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","USER scratch"],"Image":"sha256:2822410717b6e02473529fb5c959b41acf90d0383c2ee753fa7b3c0f6952c25c","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2019-12-05T04:13:11.2112069Z","docker_version":"19.03.5","history":[{"created":"2019-12-05T03:45:45.0150982Z","created_by":"/bin/sh -c #(nop) ADD file:5ac30f70224b79d5e0e6e9eedbdbf9888094f8fd5e00cb7e4f74b451f0f8529b in /credentials.json "},{"created":"2019-12-05T04:13:11.2112069Z","created_by":"/bin/sh -c #(nop) USER scratch","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:ef27a2eaa2bd07e4544ff17e6019e24f5b01a57df572e5d68f0d0ce47847b712"]}}8d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd/0040755000000000000000000000000013572101727022306 5ustar00rootroot000000000000008d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd/VERSION0100644000000000000000000000000313572101727023344 0ustar00rootroot000000000000001.08d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd/json0100644000000000000000000000216713572101727023205 0ustar00rootroot00000000000000{"id":"8d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd","created":"2019-12-05T04:13:11.2112069Z","container":"8c33aaa9bf02dbe2a080669feaa78df05bc988387e38f1cb00e08ca5789017e3","container_config":{"Hostname":"8c33aaa9bf02","Domainname":"","User":"scratch","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","USER scratch"],"Image":"sha256:2822410717b6e02473529fb5c959b41acf90d0383c2ee753fa7b3c0f6952c25c","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"docker_version":"19.03.5","config":{"Hostname":"","Domainname":"","User":"scratch","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":null,"Image":"sha256:2822410717b6e02473529fb5c959b41acf90d0383c2ee753fa7b3c0f6952c25c","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"architecture":"amd64","os":"linux"}8d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd/layer.tar0100644000000000000000000000400013572101727024121 0ustar00rootroot00000000000000credentials.json0100644000000000000000000000071513557501271014256 0ustar00rootroot00000000000000FROM debian:jessie-slim 2 | 3 | RUN apt-get update && apt-get install -y git 4 | RUN useradd nopasswd -p "" 5 | ADD credentials.json /app/credentials.json 6 | COPY suid.txt once-suid.txt gid.txt once-gid.txt /app/ 7 | RUN chmod u+s /app/suid.txt /app/once-suid.txt && chmod g+s /app/gid.txt /app/once-gid.txt 8 | RUN chmod u-s /app/once-suid.txt && chmod g-s /app/once-gid.txt && echo "once" >> /app/once-suid.txt 9 | ENV MYSQL_PASSWD password 10 | RUN rm /sbin/unix_chkpwd /usr/bin/* 11 | VOLUME /usr 12 | manifest.json0100644000000000000000000000032000000000000013512 0ustar00rootroot00000000000000[{"Config":"26ba9f75a61dae80eace28eb9e17de6122395d6c4c1a8ac369005a9e3d4f9a36.json","RepoTags":["scratchtest2:latest"],"Layers":["8d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd/layer.tar"]}] 13 | repositories0100644000000000000000000000013700000000000013471 0ustar00rootroot00000000000000{"scratchtest2":{"latest":"8d007a79497651fdc4ea69609df8dfa288b4876de4de9dd9ae002ad64b9db0cd"}} 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/manifest.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | deckodertypes "github.com/goodwithtech/deckoder/types" 14 | 15 | "github.com/google/shlex" 16 | 17 | "github.com/goodwithtech/dockle/pkg/log" 18 | 19 | "github.com/goodwithtech/dockle/pkg/types" 20 | ) 21 | 22 | type ManifestAssessor struct{} 23 | 24 | var ConfigFileName = "metadata" 25 | var ( 26 | sensitiveDirs = map[string]struct{}{"/sys": {}, "/dev": {}, "/proc": {}} 27 | suspiciousEnvKey = []string{"PASS", "PASSWD", "PASSWORD", "SECRET", "KEY", "ACCESS", "TOKEN"} 28 | acceptanceEnvKey = map[string]struct{}{"GPG_KEY": {}, "GPG_KEYS": {}} 29 | suspiciousCompiler *regexp.Regexp 30 | ) 31 | 32 | func (a ManifestAssessor) Assess(fileMap deckodertypes.FileMap) (assesses []*types.Assessment, err error) { 33 | log.Logger.Debug("Scan start : config file") 34 | file, ok := fileMap["/config"] 35 | if !ok { 36 | return nil, errors.New("config json file doesn't exist") 37 | } 38 | 39 | var d types.Image 40 | 41 | err = json.Unmarshal(file.Body, &d) 42 | if err != nil { 43 | return nil, errors.New("Fail to parse docker config file.") 44 | } 45 | 46 | return checkAssessments(d) 47 | } 48 | 49 | func AddSensitiveWords(words []string) { 50 | suspiciousEnvKey = append(suspiciousEnvKey, words...) 51 | } 52 | 53 | func AddAcceptanceKeys(keys []string) { 54 | for _, key := range keys { 55 | acceptanceEnvKey[key] = struct{}{} 56 | } 57 | } 58 | 59 | func compileSensitivePatterns() error { 60 | pat := fmt.Sprintf(`.*(?i)%s.*`, strings.Join(suspiciousEnvKey, "|")) 61 | r, err := regexp.Compile(pat) 62 | if err != nil { 63 | return fmt.Errorf("compile suspicious key: %w", err) 64 | } 65 | suspiciousCompiler = r 66 | return nil 67 | } 68 | 69 | type AssessmentWithColumns struct { 70 | types.Assessment 71 | HistoryIndex int 72 | } 73 | 74 | func checkAssessments(img types.Image) (assesses []*types.Assessment, err error) { 75 | if err := compileSensitivePatterns(); err != nil { 76 | return nil, err 77 | } 78 | 79 | //assessesCh := make(chan []*types.Assessment) 80 | assessesCh := make(chan []*AssessmentWithColumns) 81 | for index, cmd := range img.History { 82 | go func(index int, cmd types.History) { 83 | assessesCh <- assessHistory(index, cmd) 84 | }(index, cmd) 85 | } 86 | 87 | timeout := time.After(10 * time.Second) 88 | assessWithColumns := []*AssessmentWithColumns{} 89 | for i := 0; i < len(img.History); i++ { 90 | select { 91 | case results := <-assessesCh: 92 | assessWithColumns = append(assessWithColumns, results...) 93 | case <-timeout: 94 | return nil, errors.New("timeout: manifest assessor") 95 | } 96 | } 97 | 98 | sliceIndexShouldDelete := 0 99 | historyIndexOfSmallestAddStatement := -1 100 | assesses = make([]*types.Assessment, len(assessWithColumns)) 101 | for idx, assess := range assessWithColumns { 102 | assesses[idx] = &assess.Assessment 103 | if assess.Code == types.UseCOPY { 104 | if historyIndexOfSmallestAddStatement == -1 { 105 | historyIndexOfSmallestAddStatement = assess.HistoryIndex 106 | sliceIndexShouldDelete = idx 107 | } else if assess.HistoryIndex < historyIndexOfSmallestAddStatement { 108 | historyIndexOfSmallestAddStatement = assess.HistoryIndex 109 | sliceIndexShouldDelete = idx 110 | } 111 | } 112 | } 113 | 114 | // first ADD statement should not contain assessments 115 | if historyIndexOfSmallestAddStatement != -1 { 116 | assesses[sliceIndexShouldDelete] = assesses[len(assesses)-1] 117 | assesses = assesses[:len(assesses)-1] 118 | // fmt.Println("first ADD statement should not contain assessments", sliceIndexShouldDelete) 119 | } 120 | 121 | if img.Config.User == "" || img.Config.User == "root" { 122 | assesses = append(assesses, &types.Assessment{ 123 | Code: types.AvoidRootDefault, 124 | Filename: ConfigFileName, 125 | Desc: "Last user should not be root", 126 | }) 127 | } 128 | 129 | if img.Config.Healthcheck == nil { 130 | assesses = append(assesses, &types.Assessment{ 131 | Code: types.AddHealthcheck, 132 | Filename: ConfigFileName, 133 | Desc: "not found HEALTHCHECK statement", 134 | }) 135 | } 136 | 137 | for volume := range img.Config.Volumes { 138 | if _, ok := sensitiveDirs[volume]; ok { 139 | assesses = append(assesses, &types.Assessment{ 140 | Code: types.AvoidSensitiveDirectoryMounting, 141 | Filename: ConfigFileName, 142 | Desc: fmt.Sprintf("Avoid mounting sensitive dirs : %s", volume), 143 | }) 144 | } 145 | } 146 | return assesses, nil 147 | } 148 | 149 | func splitByCommands(line string) map[int][]string { 150 | commands := strings.Split(line, "&&") 151 | 152 | tokens := map[int][]string{} 153 | for index, command := range commands { 154 | splitted := strings.Split(command, " ") 155 | cmds := []string{} 156 | for _, cmd := range splitted { 157 | trimmed := strings.TrimSpace(cmd) 158 | if trimmed != "" { 159 | cmds = append(cmds, trimmed) 160 | } 161 | 162 | } 163 | tokens[index] = cmds 164 | } 165 | return tokens 166 | } 167 | 168 | func assessHistory(index int, cmd types.History) []*AssessmentWithColumns { 169 | var assesses []*AssessmentWithColumns 170 | cmdSlices := splitByCommands(cmd.CreatedBy) 171 | 172 | found, varName, varVal := sensitiveVars(cmd.CreatedBy) 173 | if found { 174 | assesses = append(assesses, &AssessmentWithColumns{ 175 | Assessment: types.Assessment{ 176 | Code: types.AvoidCredential, 177 | Filename: ConfigFileName, 178 | Desc: fmt.Sprintf("Suspicious ENV key found : %s on %s (You can suppress it with --accept-key)", varName, strings.ReplaceAll(cmd.CreatedBy, varVal, "*******")), 179 | }, 180 | HistoryIndex: index, 181 | }) 182 | } 183 | if reducableApkAdd(cmdSlices) { 184 | assesses = append(assesses, &AssessmentWithColumns{ 185 | Assessment: types.Assessment{ 186 | Code: types.UseApkAddNoCache, 187 | Filename: ConfigFileName, 188 | Desc: fmt.Sprintf("Use --no-cache option if use 'apk add': %s", cmd.CreatedBy), 189 | }, 190 | HistoryIndex: index, 191 | }) 192 | } 193 | 194 | if reducableAptGetInstall(cmdSlices) { 195 | assesses = append(assesses, &AssessmentWithColumns{ 196 | Assessment: types.Assessment{ 197 | Code: types.MinimizeAptGet, 198 | Filename: ConfigFileName, 199 | Desc: fmt.Sprintf("Use 'rm -rf /var/lib/apt/lists' after 'apt-get install|update' : %s", cmd.CreatedBy), 200 | }, 201 | HistoryIndex: index, 202 | }) 203 | } 204 | 205 | if reducableAptGetUpdate(cmdSlices) { 206 | assesses = append(assesses, &AssessmentWithColumns{ 207 | Assessment: types.Assessment{ 208 | Code: types.UseAptGetUpdateNoCache, 209 | Filename: ConfigFileName, 210 | Desc: fmt.Sprintf("Always combine 'apt-get update' with 'apt-get install|upgrade' : %s", cmd.CreatedBy), 211 | }, 212 | HistoryIndex: index, 213 | }) 214 | } 215 | 216 | // TODO: Allow the first ADD statement 217 | if useADDstatement(cmdSlices) { 218 | assesses = append(assesses, &AssessmentWithColumns{ 219 | Assessment: types.Assessment{ 220 | Code: types.UseCOPY, 221 | Filename: ConfigFileName, 222 | Desc: fmt.Sprintf("Use COPY : %s", cmd.CreatedBy), 223 | }, 224 | HistoryIndex: index, 225 | }) 226 | } 227 | 228 | if useDistUpgrade(cmdSlices) { 229 | assesses = append(assesses, &AssessmentWithColumns{ 230 | Assessment: types.Assessment{ 231 | Code: types.AvoidDistUpgrade, 232 | Filename: ConfigFileName, 233 | Desc: fmt.Sprintf("Avoid dist-upgrade in container : %s", cmd.CreatedBy), 234 | }, 235 | HistoryIndex: index, 236 | }) 237 | } 238 | if useSudo(cmdSlices) { 239 | assesses = append(assesses, &AssessmentWithColumns{ 240 | Assessment: types.Assessment{ 241 | Code: types.AvoidSudo, 242 | Filename: ConfigFileName, 243 | Desc: fmt.Sprintf("Avoid sudo in container : %s", cmd.CreatedBy), 244 | }, 245 | HistoryIndex: index, 246 | }) 247 | } 248 | 249 | return assesses 250 | } 251 | 252 | func useSudo(cmdSlices map[int][]string) bool { 253 | for _, cmdSlice := range cmdSlices { 254 | if containsAll(cmdSlice, []string{"sudo"}) { 255 | return true 256 | } 257 | } 258 | return false 259 | 260 | } 261 | 262 | func useDistUpgrade(cmdSlices map[int][]string) bool { 263 | for _, cmdSlice := range cmdSlices { 264 | if checkAptCommand(cmdSlice, "dist-upgrade") { 265 | return true 266 | } 267 | } 268 | return false 269 | } 270 | 271 | func useADDstatement(cmdSlices map[int][]string) bool { 272 | for _, cmdSlice := range cmdSlices { 273 | if containsAll(cmdSlice, []string{"ADD", "in"}) || containsAll(cmdSlice, []string{"ADD", "buildkit"}) { 274 | return true 275 | } 276 | } 277 | return false 278 | } 279 | 280 | func sensitiveVars(cmd string) (bool, string, string) { 281 | if !strings.Contains(cmd, "=") { 282 | return false, "", "" 283 | } 284 | toklexer := shlex.NewLexer(strings.NewReader(strings.ReplaceAll(cmd, "#", ""))) 285 | for { 286 | word, err := toklexer.Next() 287 | if err == io.EOF { 288 | break 289 | } 290 | if !strings.Contains(word, "=") { 291 | continue 292 | } 293 | vars := strings.Split(word, "=") 294 | varName, varVal := vars[0], vars[1] 295 | if strings.Contains(varName, " ") { 296 | continue 297 | } 298 | if varVal == "" { 299 | continue 300 | } 301 | 302 | if _, ok := acceptanceEnvKey[varName]; ok { 303 | continue 304 | } 305 | 306 | if suspiciousCompiler.MatchString(varName) { 307 | return true, varName, varVal 308 | } 309 | } 310 | 311 | return false, "", "" 312 | } 313 | 314 | func checkAptCommand(target []string, command string) bool { 315 | if containsThreshold(target, []string{"apt-get", "apt", command}, 2) { 316 | return true 317 | } 318 | return false 319 | } 320 | 321 | func checkAptLibraryDirChanged(target []string) bool { 322 | if checkAptCommand(target, "update") || checkAptCommand(target, "install") { 323 | return true 324 | } 325 | return false 326 | } 327 | 328 | func reducableAptGetUpdate(cmdSlices map[int][]string) bool { 329 | var useAptUpdate bool 330 | // map order must be sorted 331 | for i := 0; i < len(cmdSlices); i++ { 332 | cmdSlice := cmdSlices[i] 333 | if !useAptUpdate && checkAptCommand(cmdSlice, "update") { 334 | useAptUpdate = true 335 | } 336 | if useAptUpdate { 337 | // apt install/upgrade must be run after library updated 338 | if checkAptCommand(cmdSlice, "install") || checkAptCommand(cmdSlice, "upgrade") { 339 | return false 340 | } 341 | } 342 | } 343 | return useAptUpdate 344 | } 345 | 346 | var removeAptLibCmds = []string{"rm", "-rf", "-fr", "-r", "-fR", "/var/lib/apt/lists", "/var/lib/apt/lists/*", "/var/lib/apt/lists/*;"} 347 | 348 | func reducableAptGetInstall(cmdSlices map[int][]string) bool { 349 | var useAptLibrary bool 350 | // map order must be sorted 351 | for i := 0; i < len(cmdSlices); i++ { 352 | cmdSlice := cmdSlices[i] 353 | if !useAptLibrary && checkAptLibraryDirChanged(cmdSlice) { 354 | useAptLibrary = true 355 | } 356 | // remove cache must be run after apt library directory changed 357 | if useAptLibrary && containsThreshold(cmdSlice, removeAptLibCmds, 3) { 358 | return false 359 | } 360 | } 361 | return useAptLibrary 362 | } 363 | 364 | func reducableApkAdd(cmdSlices map[int][]string) bool { 365 | for _, cmdSlice := range cmdSlices { 366 | if containsAll(cmdSlice, []string{"apk", "add"}) { 367 | if !containsAll(cmdSlice, []string{"--no-cache"}) { 368 | return true 369 | } 370 | } 371 | } 372 | return false 373 | } 374 | 375 | // manifest contains /config 376 | func (a ManifestAssessor) RequiredFiles() []string { 377 | return []string{} 378 | } 379 | 380 | func (a ManifestAssessor) RequiredExtensions() []string { 381 | return []string{} 382 | } 383 | 384 | func (a ManifestAssessor) RequiredPermissions() []os.FileMode { 385 | return []os.FileMode{} 386 | } 387 | 388 | func containsAll(heystack []string, needles []string) bool { 389 | needleMap := map[string]struct{}{} 390 | for _, n := range needles { 391 | needleMap[n] = struct{}{} 392 | } 393 | 394 | for _, v := range heystack { 395 | if _, ok := needleMap[v]; ok { 396 | delete(needleMap, v) 397 | if len(needleMap) == 0 { 398 | return true 399 | } 400 | } 401 | } 402 | return false 403 | } 404 | 405 | func containsThreshold(heystack []string, needles []string, threshold int) bool { 406 | needleMap := map[string]struct{}{} 407 | for _, n := range needles { 408 | needleMap[n] = struct{}{} 409 | } 410 | 411 | existCnt := 0 412 | for _, v := range heystack { 413 | if _, ok := needleMap[v]; ok { 414 | delete(needleMap, v) 415 | existCnt++ 416 | if existCnt >= threshold { 417 | return true 418 | } 419 | } 420 | } 421 | return false 422 | } 423 | -------------------------------------------------------------------------------- /pkg/assessor/manifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/d4l3k/messagediff" 11 | "github.com/goodwithtech/dockle/pkg/types" 12 | ) 13 | 14 | func TestAssess(t *testing.T) { 15 | var tests = map[string]struct { 16 | path string 17 | assesses []*types.Assessment 18 | }{ 19 | "RootDefault": { 20 | path: "./testdata/root_default.json", 21 | assesses: []*types.Assessment{ 22 | { 23 | Code: types.AvoidRootDefault, 24 | Filename: ConfigFileName, 25 | }, 26 | { 27 | Code: types.AddHealthcheck, 28 | Filename: ConfigFileName, 29 | }, 30 | }, 31 | }, 32 | "ApkCached": { 33 | path: "./testdata/apk_cache.json", 34 | 35 | assesses: []*types.Assessment{ 36 | { 37 | Code: types.UseApkAddNoCache, 38 | Filename: ConfigFileName, 39 | }, 40 | { 41 | Code: types.AvoidRootDefault, 42 | Filename: ConfigFileName, 43 | }, 44 | { 45 | Code: types.AddHealthcheck, 46 | Filename: ConfigFileName, 47 | }, 48 | { 49 | Code: types.UseCOPY, 50 | Filename: ConfigFileName, 51 | }, 52 | }, 53 | }, 54 | "AptUpdateUpgrade": { 55 | path: "./testdata/apt_update_upgrade.json", 56 | 57 | assesses: []*types.Assessment{ 58 | { 59 | Code: types.AvoidRootDefault, 60 | Filename: ConfigFileName, 61 | }, 62 | { 63 | Code: types.MinimizeAptGet, 64 | Filename: ConfigFileName, 65 | }, 66 | { 67 | Code: types.AddHealthcheck, 68 | Filename: ConfigFileName, 69 | }, 70 | }, 71 | }, 72 | "ADDStatementNotFirst": { 73 | path: "./testdata/add_with_arg_statement.json", 74 | 75 | assesses: []*types.Assessment{ 76 | { 77 | Code: types.AvoidRootDefault, 78 | Filename: ConfigFileName, 79 | }, 80 | { 81 | Code: types.AddHealthcheck, 82 | Filename: ConfigFileName, 83 | }, 84 | }, 85 | }, 86 | "MultiADDStatements": { 87 | path: "./testdata/multi_add.json", 88 | 89 | assesses: []*types.Assessment{ 90 | { 91 | Code: types.AvoidRootDefault, 92 | Filename: ConfigFileName, 93 | }, 94 | { 95 | Code: types.AddHealthcheck, 96 | Filename: ConfigFileName, 97 | }, 98 | { 99 | Code: types.UseCOPY, 100 | Filename: ConfigFileName, 101 | }, 102 | { 103 | Code: types.UseCOPY, 104 | Filename: ConfigFileName, 105 | }, 106 | }, 107 | }, 108 | } 109 | 110 | for testname, v := range tests { 111 | d, err := loadImageFromFile(v.path) 112 | 113 | if err != nil { 114 | t.Errorf("%s : can't open file %s", testname, v.path) 115 | continue 116 | } 117 | actual, err := checkAssessments(d) 118 | if err != nil { 119 | t.Errorf("%s : catch the error : %v", testname, err) 120 | } 121 | 122 | diff, equal := messagediff.PrettyDiff( 123 | sortByType(v.assesses), 124 | sortByType(actual), 125 | messagediff.IgnoreStructField("Desc"), 126 | ) 127 | if !equal { 128 | t.Errorf("%s diff : %v", testname, diff) 129 | } 130 | } 131 | } 132 | 133 | func TestSplitByCommands(t *testing.T) { 134 | var tests = map[string]struct { 135 | path string 136 | index int 137 | expected map[int][]string 138 | }{ 139 | "RootDefault": { 140 | path: "./testdata/root_default.json", 141 | index: 1, 142 | expected: map[int][]string{ 143 | 0: {"/bin/sh", "-c", "#(nop)", "CMD", "[\"/bin/sh\"]"}, 144 | }, 145 | }, 146 | "Nginx": { 147 | path: "./testdata/nginx.json", 148 | index: 6, 149 | expected: map[int][]string{ 150 | 0: {"/bin/sh", "-c", "set", "-x"}, 151 | 1: {"addgroup", "--system", "--gid", "101", "nginx"}, 152 | 2: {"adduser", "--system", "--disabled-login", "--ingroup", "nginx", "--no-create-home", "--home", "/nonexistent", "--gecos", "\"nginx", "user\"", "--shell", "/bin/false", "--uid", "101", "nginx"}, 153 | 3: {"apt-get", "update"}, 154 | 4: {"apt-get", "install", "--no-install-recommends", "--no-install-suggests", "-y", "gnupg1", "apt-transport-https", "ca-certificates"}, 155 | 5: {"NGINX_GPGKEY=573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62;", "found='';", "for", "server", "in", "ha.pool.sks-keyservers.net", "hkp://keyserver.ubuntu.com:80", "hkp://p80.pool.sks-keyservers.net:80", "pgp.mit.edu", ";", "do", "echo", "\"Fetching", "GPG", "key", "$NGINX_GPGKEY", "from", "$server\";", "apt-key", "adv", "--keyserver", "\"$server\"", "--keyserver-options", "timeout=10", "--recv-keys", "\"$NGINX_GPGKEY\""}, 156 | 6: {"found=yes"}, 157 | 7: {"break;", "done;", "test", "-z", "\"$found\""}, 158 | 8: {"echo", ">&2", "\"error:", "failed", "to", "fetch", "GPG", "key", "$NGINX_GPGKEY\""}, 159 | 9: {"exit", "1;", "apt-get", "remove", "--purge", "--auto-remove", "-y", "gnupg1"}, 160 | 10: {"rm", "-rf", "/var/lib/apt/lists/*"}, 161 | 11: {"dpkgArch=\"$(dpkg", "--print-architecture)\""}, 162 | 12: {"nginxPackages=\"", "nginx=${NGINX_VERSION}-${PKG_RELEASE}", "nginx-module-xslt=${NGINX_VERSION}-${PKG_RELEASE}", "nginx-module-geoip=${NGINX_VERSION}-${PKG_RELEASE}", "nginx-module-image-filter=${NGINX_VERSION}-${PKG_RELEASE}", "nginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-${PKG_RELEASE}", "\""}, 163 | 13: {"case", "\"$dpkgArch\"", "in", "amd64|i386)", "echo", "\"deb", "https://nginx.org/packages/mainline/debian/", "stretch", "nginx\"", ">>", "/etc/apt/sources.list.d/nginx.list"}, 164 | 14: {"apt-get", "update", ";;", "*)", "echo", "\"deb-src", "https://nginx.org/packages/mainline/debian/", "stretch", "nginx\"", ">>", "/etc/apt/sources.list.d/nginx.list"}, 165 | 15: {"tempDir=\"$(mktemp", "-d)\""}, 166 | 16: {"chmod", "777", "\"$tempDir\""}, 167 | 17: {"savedAptMark=\"$(apt-mark", "showmanual)\""}, 168 | 18: {"apt-get", "update"}, 169 | 19: {"apt-get", "build-dep", "-y", "$nginxPackages"}, 170 | 20: {"(", "cd", "\"$tempDir\""}, 171 | 21: {"DEB_BUILD_OPTIONS=\"nocheck", "parallel=$(nproc)\"", "apt-get", "source", "--compile", "$nginxPackages", ")"}, 172 | 22: {"apt-mark", "showmanual", "|", "xargs", "apt-mark", "auto", ">", "/dev/null"}, 173 | 23: {"{", "[", "-z", "\"$savedAptMark\"", "]", "||", "apt-mark", "manual", "$savedAptMark;", "}"}, 174 | 24: {"ls", "-lAFh", "\"$tempDir\""}, 175 | 25: {"(", "cd", "\"$tempDir\""}, 176 | 26: {"dpkg-scanpackages", ".", ">", "Packages", ")"}, 177 | 27: {"grep", "'^Package:", "'", "\"$tempDir/Packages\""}, 178 | 28: {"echo", "\"deb", "[", "trusted=yes", "]", "file://$tempDir", "./\"", ">", "/etc/apt/sources.list.d/temp.list"}, 179 | 29: {"apt-get", "-o", "Acquire::GzipIndexes=false", "update", ";;", "esac"}, 180 | 30: {"apt-get", "install", "--no-install-recommends", "--no-install-suggests", "-y", "$nginxPackages", "gettext-base"}, 181 | 31: {"apt-get", "remove", "--purge", "--auto-remove", "-y", "apt-transport-https", "ca-certificates"}, 182 | 32: {"rm", "-rf", "/var/lib/apt/lists/*", "/etc/apt/sources.list.d/nginx.list"}, 183 | 33: {"if", "[", "-n", "\"$tempDir\"", "];", "then", "apt-get", "purge", "-y", "--auto-remove"}, 184 | 34: {"rm", "-rf", "\"$tempDir\"", "/etc/apt/sources.list.d/temp.list;", "fi"}, 185 | }, 186 | }, 187 | } 188 | 189 | for testname, v := range tests { 190 | d, err := loadImageFromFile(v.path) 191 | if err != nil { 192 | t.Errorf("%s : can't open file %s", testname, v.path) 193 | continue 194 | } 195 | cmd := d.History[v.index] 196 | actual := splitByCommands(cmd.CreatedBy) 197 | diff, equal := messagediff.PrettyDiff( 198 | v.expected, 199 | actual, 200 | ) 201 | if !equal { 202 | t.Errorf("%s diff : %v", testname, diff) 203 | } 204 | } 205 | } 206 | 207 | func TestReducableApkAdd(t *testing.T) { 208 | var tests = map[string]struct { 209 | cmdSlices map[int][]string 210 | expected bool 211 | }{ 212 | "Reducable": { 213 | cmdSlices: map[int][]string{ 214 | 0: { 215 | "apk", "add", "git", 216 | }, 217 | }, 218 | expected: true, 219 | }, 220 | "UnReducable": { 221 | cmdSlices: map[int][]string{ 222 | 0: { 223 | "apk", "add", "--no-cache", "git", 224 | }, 225 | }, 226 | expected: false, 227 | }, 228 | } 229 | for testname, v := range tests { 230 | actual := reducableApkAdd(v.cmdSlices) 231 | if actual != v.expected { 232 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 233 | } 234 | } 235 | } 236 | 237 | func TestReducableAptGetUpdate(t *testing.T) { 238 | var tests = map[string]struct { 239 | cmdSlices map[int][]string 240 | expected bool 241 | }{ 242 | "Reducable": { 243 | cmdSlices: map[int][]string{ 244 | 0: { 245 | "apt-get", "update", 246 | }, 247 | 1: { 248 | "apt-get", "purge", 249 | }, 250 | }, 251 | expected: true, 252 | }, 253 | "NoUpdate": { 254 | cmdSlices: map[int][]string{ 255 | 0: { 256 | "apt-get", "install", 257 | }, 258 | 1: { 259 | "apt-get", "purge", 260 | }, 261 | }, 262 | expected: false, 263 | }, 264 | "UnReducable": { 265 | cmdSlices: map[int][]string{ 266 | 0: { 267 | "apt-get", "update", 268 | }, 269 | 1: { 270 | "apt-get", "-y", "--no-install-recommends", "install", 271 | }, 272 | }, 273 | expected: false, 274 | }, 275 | "UpdateAfterInstalled": { 276 | cmdSlices: map[int][]string{ 277 | 0: { 278 | "apt-get", "-y", "--no-install-recommends", "install", 279 | }, 280 | 1: { 281 | "apt-get", "update", 282 | }, 283 | }, 284 | expected: true, 285 | }, 286 | "CheckAptCommand": { 287 | cmdSlices: map[int][]string{ 288 | 0: { 289 | "apt", "update", 290 | }, 291 | 1: { 292 | "apt", "-y", "--no-install-recommends", "install", 293 | }, 294 | }, 295 | expected: false, 296 | }, 297 | "LongInvalidCommand": { 298 | // https://github.com/docker-library/golang/blob/3f2c52653043f067156ce4f41182c2a758c4c857/1.17/alpine3.14/Dockerfile#L20-L107 299 | // Issue: https://github.com/goodwithtech/dockle/issues/151 300 | cmdSlices: map[int][]string{ 301 | 0: { 302 | "/bin/sh", "-c", "set", "-eux;", "apk", "add", "--no-cache", "--virtual", ".fetch-deps", "gnupg;", "arch=$(apk", "--print-arch);", "url=;", 303 | "case", "$arch", "in", "'x86_64')", "export", "GOARCH='amd64'", "GOOS='linux';", ";;", "'armhf')", "export", "GOARCH='arm'", "GOARM='6'", "GOOS='linux';", ";;", "'armv7')", 304 | "export", "GOARCH='arm'", "GOARM='7'", "GOOS='linux';", ";;", "'aarch64')", "export", "GOARCH='arm64'", "GOOS='linux';", ";;", "'x86')", "export", "GO386='softfloat'", "GOARCH='386'", 305 | "GOOS='linux';", ";;", "'ppc64le')", "export", "GOARCH='ppc64le'", "GOOS='linux';", ";;", "'s390x')", "export", "GOARCH='s390x'", "GOOS='linux';", ";;", "*)", "echo", ">&2", "error:", 306 | "unsupported", "architecture", "'$arch'", "(likely", "packaging", "update", "needed);", "exit", "1", ";;", "esac;", "build=;", "if", "[", "-z", "$url", "];", "then", "build=1;", 307 | "url='https://dl.google.com/go/go1.17.1.src.tar.gz';", "sha256='49dc08339770acd5613312db8c141eaf61779995577b89d93b541ef83067e5b1';", "fi;", "wget", "-O", "go.tgz.asc", "$url.asc;", "wget", 308 | "-O", "go.tgz", "$url;", "echo", "$sha256", "*go.tgz", "|", "sha256sum", "-c", "-;", "GNUPGHOME=$(mktemp", "-d);", "export", "GNUPGHOME;", "gpg", "--batch", "--keyserver", "keyserver.ubuntu.com", 309 | "--recv-keys", "'EB4C", "1BFD", "4F04", "2F6D", "DDCC", "EC91", "7721", "F63B", "D38B", "4796';", "gpg", "--batch", "--verify", "go.tgz.asc", "go.tgz;", "gpgconf", "--kill", "all;", "rm", "-rf", "$GNUPGHOME", 310 | "go.tgz.asc;", "tar", "-C", "/usr/local", "-xzf", "go.tgz;", "rm", "go.tgz;", "if", "[", "-n", "$build", "];", "then", "apk", "add", "--no-cache", "--virtual", ".build-deps", "bash", "gcc", "go", "musl-dev", 311 | ";", "(", "cd", "/usr/local/go/src;", "export", "GOROOT_BOOTSTRAP=$(go", "env", "GOROOT)", "GOHOSTOS=$GOOS", "GOHOSTARCH=$GOARCH;", "./make.bash;", ");", "apk", "del", "--no-network", ".build-deps;", "go", "install", 312 | "std;", "rm", "-rf", "/usr/local/go/pkg/*/cmd", "/usr/local/go/pkg/bootstrap", "/usr/local/go/pkg/obj", "/usr/local/go/pkg/tool/*/api", "/usr/local/go/pkg/tool/*/go_bootstrap", 313 | "/usr/local/go/src/cmd/dist/dist", ";", "fi;", "apk", "del", "--no-network", ".fetch-deps;", "go", "version", 314 | }, 315 | }, 316 | expected: false, 317 | }, 318 | } 319 | for testname, v := range tests { 320 | actual := reducableAptGetUpdate(v.cmdSlices) 321 | if actual != v.expected { 322 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 323 | } 324 | } 325 | } 326 | 327 | func TestReducableAptGetInstall(t *testing.T) { 328 | var tests = map[string]struct { 329 | cmdSlices map[int][]string 330 | expected bool 331 | }{ 332 | "Reducable": { 333 | cmdSlices: map[int][]string{ 334 | 0: { 335 | "apt-get", "-y", "install", 336 | }, 337 | 1: { 338 | "apt-get", "update", 339 | }, 340 | }, 341 | expected: true, 342 | }, 343 | "OnlyUpdate": { 344 | cmdSlices: map[int][]string{ 345 | 0: { 346 | "apt-get", "update", 347 | }, 348 | 1: { 349 | "apt-get", "purge", 350 | }, 351 | }, 352 | expected: true, 353 | }, 354 | "NoUpdateInstall": { 355 | cmdSlices: map[int][]string{ 356 | 0: { 357 | "apt-get", "purge", 358 | }, 359 | }, 360 | expected: false, 361 | }, 362 | "UnReducable": { 363 | cmdSlices: map[int][]string{ 364 | 0: { 365 | "apt-get", "install", 366 | }, 367 | 1: { 368 | "rm", "-fR", "/var/lib/apt/lists/*", 369 | }, 370 | }, 371 | expected: false, 372 | }, 373 | "UnReducable2": { 374 | cmdSlices: map[int][]string{ 375 | 0: { 376 | "apt-get", "install", "-y", "git", 377 | }, 378 | 1: { 379 | "rm", "-rf", "/var/lib/apt/lists", 380 | }, 381 | }, 382 | expected: false, 383 | }, 384 | "UnReducable3": { 385 | cmdSlices: map[int][]string{ 386 | 0: { 387 | "apt-get", "install", "-y", "git", 388 | }, 389 | 1: { 390 | "rm", "-r", "/var/lib/apt/lists", 391 | }, 392 | }, 393 | expected: false, 394 | }, 395 | } 396 | for testname, v := range tests { 397 | actual := reducableAptGetInstall(v.cmdSlices) 398 | if actual != v.expected { 399 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 400 | } 401 | } 402 | } 403 | 404 | func TestAddStatement(t *testing.T) { 405 | var tests = map[string]struct { 406 | cmdSlices map[int][]string 407 | expected bool 408 | }{ 409 | "UseADD": { 410 | cmdSlices: map[int][]string{ 411 | 0: { 412 | "/bin/sh", "-c", "#(nop)", "ADD", "file:2e3a37883f56a4a278bec2931fc9f91fb9ebdaa9047540fe8fde419b84a1701b", "in", "/cmd", 413 | }, 414 | }, 415 | expected: true, 416 | }, 417 | "NotADD": { 418 | cmdSlices: map[int][]string{ 419 | 0: {"/bin/sh", "-c", "set", "-x"}, 420 | 1: {"addgroup", "--system", "--gid", "101", "nginx"}, 421 | }, 422 | expected: false, 423 | }, 424 | "UseADDR": { 425 | cmdSlices: map[int][]string{ 426 | 0: {"/bin/sh", "-c", "set", "-x"}, 427 | 1: {"/bin/sh", "-c", "RETHINKDB_CLUSTER_IP_ADDR"}, 428 | }, 429 | expected: false, 430 | }, 431 | } 432 | for testname, v := range tests { 433 | actual := useADDstatement(v.cmdSlices) 434 | if actual != v.expected { 435 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 436 | } 437 | } 438 | } 439 | 440 | func TestSensitiveVars(t *testing.T) { 441 | if err := compileSensitivePatterns(); err != nil { 442 | t.Fatalf("compile sensitive var patterns: %s", err) 443 | } 444 | var tests = map[string]struct { 445 | cmd string 446 | expected bool 447 | }{ 448 | "basic": {cmd: "/bin/sh -c #(nop) ENV PASS=ADMIN", expected: true}, 449 | "empty value": {cmd: "/bin/sh -c #(nop) ENV PASS=", expected: false}, 450 | "mixed cases": {cmd: "/bin/sh -c #(nop) ENV PasS=ADMIN", expected: true}, 451 | "two vars": {cmd: "/bin/sh -c #(nop) ENV abc=hello password=sensibledata", expected: true}, 452 | "empty two value": {cmd: "/bin/sh -c #(nop) ENV ABC=hello PASS= ", expected: false}, 453 | "run command": {cmd: `/bin/sh -c SECRET_API_KEY=63AF7AA15067C05616FDDD88A3A2E8F226F0BC06 echo "data"`, expected: true}, 454 | "run false positive": {cmd: `/bin/sh -c HELLO="PASS=\"notThis\"" echo "false positive"`, expected: false}, 455 | "run command 2": {cmd: `/bin/sh -c SECRET=myLittleSecret VAR2=VALUE2 VAR3=VALUE3 echo "Do something"`, expected: true}, 456 | "secret with space": {cmd: `/bin/sh -c SECRET="hello world"`, expected: true}, 457 | "skip space key": {cmd: `/bin/sh -c echo 'secret = foo;' > test.conf`, expected: false}, 458 | // TODO : expected must be false 459 | //"skip echo string": {cmd: `/bin/sh -c echo 'secret=foo;' > test.conf`, expected: true}, 460 | } 461 | for testname, v := range tests { 462 | actual, _, _ := sensitiveVars(v.cmd) 463 | if actual != v.expected { 464 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 465 | } 466 | } 467 | 468 | } 469 | 470 | func TestUseDistUpgrade(t *testing.T) { 471 | var tests = map[string]struct { 472 | cmdSlices map[int][]string 473 | expected bool 474 | }{ 475 | "UseUpgrade": { 476 | cmdSlices: map[int][]string{ 477 | 0: { 478 | "apt-get", "upgrade", 479 | }, 480 | }, 481 | expected: false, 482 | }, 483 | "UseAptUpgrade": { 484 | cmdSlices: map[int][]string{ 485 | 0: {"apt", "upgrade"}, 486 | 1: {"addgroup", "--system", "--gid", "101", "nginx"}, 487 | }, 488 | expected: false, 489 | }, 490 | "UseDistUpgrade": { 491 | cmdSlices: map[int][]string{ 492 | 0: {"apt-get", "dist-upgrade"}, 493 | }, 494 | expected: true, 495 | }, 496 | "UseAptDistUpgrade": { 497 | cmdSlices: map[int][]string{ 498 | 0: {"apt", "dist-upgrade"}, 499 | }, 500 | expected: true, 501 | }, 502 | 503 | "NoAptDistUpgrade": { 504 | cmdSlices: map[int][]string{ 505 | 0: {"somecommand", "dist-upgrade", "pip", "setuptools"}, 506 | }, 507 | expected: false, 508 | }, 509 | } 510 | for testname, v := range tests { 511 | actual := useDistUpgrade(v.cmdSlices) 512 | if actual != v.expected { 513 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 514 | } 515 | } 516 | } 517 | 518 | func TestContainsThreshold(t *testing.T) { 519 | var tests = map[string]struct { 520 | heystack []string 521 | needles []string 522 | threshold int 523 | expected bool 524 | }{ 525 | "SimpleSuccess2": { 526 | heystack: []string{"a", "b", "c", "d"}, 527 | needles: []string{"a", "b", "x", "z"}, 528 | threshold: 2, 529 | expected: true, 530 | }, 531 | "SimpleFail2": { 532 | heystack: []string{"a", "b", "c", "d"}, 533 | needles: []string{"a", "x", "y", "z"}, 534 | threshold: 3, 535 | expected: false, 536 | }, 537 | "SimpleSuccess3": { 538 | heystack: []string{"a", "b", "c", "d"}, 539 | needles: []string{"a", "b", "c", "z"}, 540 | threshold: 3, 541 | expected: true, 542 | }, 543 | "SimpleFail3": { 544 | heystack: []string{"a", "b", "d", "f"}, 545 | needles: []string{"a", "b", "c", "z"}, 546 | threshold: 3, 547 | expected: false, 548 | }, 549 | "DuplicateHeystackSuccess": { 550 | heystack: []string{"a", "a", "b", "c"}, 551 | needles: []string{"a", "a", "y", "z"}, 552 | threshold: 2, 553 | expected: false, 554 | }, 555 | "DuplicateHeystackFail": { 556 | heystack: []string{"a", "a", "b", "c"}, 557 | needles: []string{"a", "x", "y", "z"}, 558 | threshold: 2, 559 | expected: false, 560 | }, 561 | } 562 | for testname, v := range tests { 563 | actual := containsThreshold(v.heystack, v.needles, v.threshold) 564 | if actual != v.expected { 565 | t.Errorf("%s want: %t, got %t", testname, v.expected, actual) 566 | } 567 | } 568 | } 569 | 570 | func loadImageFromFile(path string) (config types.Image, err error) { 571 | read, err := os.Open(path) 572 | if err != nil { 573 | return config, err 574 | } 575 | filebytes, err := io.ReadAll(read) 576 | if err != nil { 577 | return config, err 578 | } 579 | err = json.Unmarshal(filebytes, &config) 580 | if err != nil { 581 | return config, err 582 | } 583 | return config, nil 584 | } 585 | 586 | func sortByType(assesses []*types.Assessment) []*types.Assessment { 587 | sort.Slice(assesses, func(i, j int) bool { 588 | if assesses[i].Code != assesses[j].Code { 589 | return assesses[i].Code < assesses[j].Code 590 | } 591 | return assesses[i].Code < assesses[j].Code 592 | }) 593 | return assesses 594 | } 595 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Financial Contributors on Open Collective](https://opencollective.com/dockle/all/badge.svg?label=financial+contributors)](https://opencollective.com/dockle) [![GitHub release](https://img.shields.io/github/release/goodwithtech/dockle.svg)](https://github.com/goodwithtech/dockle/releases/latest) 4 | [![CircleCI](https://circleci.com/gh/goodwithtech/dockle.svg?style=svg)](https://circleci.com/gh/goodwithtech/dockle) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/goodwithtech/dockle)](https://goreportcard.com/report/github.com/goodwithtech/dockle) 6 | [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) 7 | 8 | > Dockle - Container Image Linter for Security, Helping build the Best-Practice Docker Image, Easy to start 9 | 10 | `Dockle` helps you: 11 | 12 | 1. Build [Best Practice](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) Docker images 13 | 2. Build secure Docker images 14 | - Checkpoints includes [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks/) 15 | 16 | ```bash 17 | $ brew untap goodwithtech/dockle # who use 0.1.16 or older version 18 | $ brew install goodwithtech/r/dockle 19 | $ dockle [YOUR_IMAGE_NAME] 20 | ``` 21 | See [Installation](#installation) and [Common Examples](#common-examples) 22 | 23 | 24 | 25 | # Checkpoints Comparison 26 | 27 | 28 | 29 | 30 | # TOC 31 | 32 | - [Features](#features) 33 | - [Comparison](#comparison) 34 | - [Installation](#installation) 35 | - [Homebrew (Mac OS X / Linux and WSL)](#homebrew-mac-os-x--linux-and-wsl) 36 | - [RHEL/CentOS](#rhelcentos) 37 | - [Debian/Ubuntu](#debianubuntu) 38 | - [Arch Linux](#arch-linux) 39 | - [Windows](#windows) 40 | - [Microsoft PowerShell 7](#microsoft-powershell-7) 41 | - [Binary](#binary) 42 | - [asdf](#asdf) 43 | - [From source](#from-source) 44 | - [Use Docker](#use-docker) 45 | - [Quick Start](#quick-start) 46 | - [Basic](#basic) 47 | - [Docker](#docker) 48 | - [Checkpoint Summary](#checkpoint-summary) 49 | - [Common Examples](#common-examples) 50 | - [Scan an image](#scan-an-image) 51 | - [Scan an image file](#scan-an-image-file) 52 | - [Get or Save the results as JSON](#get-or-save-the-results-as-json) 53 | - [Specify exit code](#specify-exit-code) 54 | - [Specify exit level](#specify-exit-level) 55 | - [Ignore the specified checkpoints](#ignore-the-specified-checkpoints) 56 | - [Continuous Integration](#continuous-integration-ci) 57 | - [GitHub Action](#github-action) 58 | - [Travis CI](#travis-ci) 59 | - [CircleCI](#circleci) 60 | - [GitLab CI](#gitlab-ci) 61 | - [Authorization for Private Docker Registry](#authorization-for-private-docker-registry) 62 | - [Checkpoint Details](CHECKPOINT.md) 63 | - CIS's Docker Image Checkpoints 64 | - Dockle Checkpoints for Docker 65 | - Dockle Checkpoints for Linux 66 | - [Credits](#credits) 67 | - [Roadmap](#roadmap) 68 | 69 | # Features 70 | 71 | - Detect container's vulnerabilities 72 | - Helping build best-practice Dockerfile 73 | - Simple usage 74 | - Specify only the image name 75 | - See [Quick Start](#quick-start) and [Common Examples](#common-examples) 76 | - CIS Benchmarks Support 77 | - High accuracy 78 | - DevSecOps 79 | - Suitable for CI such as Travis CI, CircleCI, Jenkins, etc. 80 | - See [CI Example](#continuous-integration-ci) 81 | 82 | # Comparison 83 | 84 | |  | [Dockle](https://github.com/goodwithtech/dockle) | [Hadolint](https://github.com/hadolint/hadolint) | [Docker Bench for Security](https://github.com/docker/docker-bench-security) | [Clair](https://github.com/coreos/clair) | 85 | |--- |---:|---:|---:|---:| 86 | | Target | Image | Dockerfile | Host
Docker Daemon
Image
Container Runtime | Image | 87 | | How to run | Binary | Binary | ShellScript | Binary | 88 | | Dependency | No | No | Some dependencies | No | 89 | | CI Suitable | ✓ | ✓ | x | x | 90 | | Purpose |Security Audit
Dockerfile Lint| Dockerfile Lint | Security Audit
Dockerfile Lint | Scan Vulnerabilities | 91 | 92 | # Installation 93 | 94 | ## Homebrew (Mac OS X / Linux and WSL) 95 | 96 | You can use Homebrew on [Mac OS X](https://brew.sh/) or [Linux and WSL (Windows Subsystem for Linux)](https://docs.brew.sh/Homebrew-on-Linux). 97 | 98 | ```bash 99 | $ brew install goodwithtech/r/dockle 100 | ``` 101 | 102 | ## RHEL/CentOS 103 | 104 | ```bash 105 | VERSION=$( 106 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 107 | grep '"tag_name":' | \ 108 | sed -E 's/.*"v([^"]+)".*/\1/' \ 109 | ) && rpm -ivh https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.rpm 110 | ``` 111 | 112 | ## Debian/Ubuntu 113 | 114 | ```bash 115 | VERSION=$( 116 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 117 | grep '"tag_name":' | \ 118 | sed -E 's/.*"v([^"]+)".*/\1/' \ 119 | ) && curl -L -o dockle.deb https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.deb 120 | $ sudo dpkg -i dockle.deb && rm dockle.deb 121 | ``` 122 | ## Arch Linux 123 | dockle can be installed from the Arch User Repository using `dockle` or `dockle-bin` package. 124 | ``` 125 | git clone https://aur.archlinux.org/dockle-bin.git 126 | cd dockle-bin 127 | makepkg -sri 128 | ``` 129 | ## Windows 130 | 131 | ```bash 132 | VERSION=$( 133 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 134 | grep '"tag_name":' | \ 135 | sed -E 's/.*"v([^"]+)".*/\1/' \ 136 | ) && curl -L -o dockle.zip https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Windows-64bit.zip 137 | $ unzip dockle.zip && rm dockle.zip 138 | $ ./dockle.exe [IMAGE_NAME] 139 | ``` 140 | ## Microsoft PowerShell 7 141 | ```bash 142 | if (((Invoke-WebRequest "https://api.github.com/repos/goodwithtech/dockle/releases/latest").Content) -match '"tag_name":"v(?[^"]+)"') { 143 | $VERSION=$Matches.ver && 144 | Invoke-WebRequest "https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Windows-64bit.zip" -OutFile dockle.zip && 145 | Expand-Archive dockle.zip && Remove-Item dockle.zip } 146 | ``` 147 | ## Binary 148 | 149 | You can get the latest version binary from [releases page](https://github.com/goodwithtech/dockle/releases/latest). 150 | 151 | Download the archive file for your operating system/architecture. Unpack the archive, and put the binary somewhere in your `$PATH` (on UNIX-y systems, `/usr/local/bin` or the like). 152 | 153 | - NOTE: Make sure that it's execution bits turned on. (`chmod +x dockle`) 154 | 155 | ## asdf 156 | 157 | You can install dockle with the [asdf version manager](https://asdf-vm.com/) with this [plugin](https://github.com/mathew-fleisch/asdf-dockle), which automates the process of installing (and switching between) various versions of github release binaries. With asdf already installed, run these commands to install dockle: 158 | 159 | ```bash 160 | # Add dockle plugin 161 | asdf plugin add dockle 162 | 163 | # Show all installable versions 164 | asdf list-all dockle 165 | 166 | # Install specific version 167 | asdf install dockle latest 168 | 169 | # Set a version globally (on your ~/.tool-versions file) 170 | asdf global dockle latest 171 | 172 | # Now dockle commands are available 173 | dockle --version 174 | ``` 175 | 176 | ## From source 177 | 178 | ```bash 179 | $ GO111MODULE=off go get github.com/goodwithtech/dockle/cmd/dockle 180 | $ cd $GOPATH/src/github.com/goodwithtech/dockle && GO111MODULE=on go build -o $GOPATH/bin/dockle cmd/dockle/main.go 181 | ``` 182 | 183 | ## Use Docker 184 | 185 | There's a [`Dockle` image on Docker Hub](https://hub.docker.com/r/goodwithtech/dockle) also. You can try `dockle` before installing the command. 186 | 187 | ``` 188 | $ VERSION=$( 189 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 190 | grep '"tag_name":' | \ 191 | sed -E 's/.*"v([^"]+)".*/\1/' \ 192 | ) && docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ 193 | goodwithtech/dockle:v${VERSION} [YOUR_IMAGE_NAME] 194 | ``` 195 | 196 | You only need `-v /var/run/docker.sock:/var/run/docker.sock` when you'd like to scan the image on your host machine. 197 | 198 | # Quick Start 199 | 200 | ## Basic 201 | 202 | Simply specify an image name (and a tag). 203 | 204 | ```bash 205 | $ dockle [YOUR_IMAGE_NAME] 206 | ``` 207 | 208 |
209 | Result 210 | 211 | ``` 212 | FATAL - CIS-DI-0009: Use COPY instead of ADD in Dockerfile 213 | * Use COPY : /bin/sh -c #(nop) ADD file:81c0a803075715d1a6b4f75a29f8a01b21cc170cfc1bff6702317d1be2fe71a3 in /app/credentials.json 214 | FATAL - CIS-DI-0010: Do not store credential in ENVIRONMENT vars/files 215 | * Suspicious filename found : app/credentials.json 216 | FATAL - DKL-DI-0005: Clear apt-get caches 217 | * Use 'rm -rf /var/lib/apt/lists' after 'apt-get install' : /bin/sh -c apt-get update && apt-get install -y git 218 | FATAL - DKL-LI-0001: Avoid empty password 219 | * No password user found! username : nopasswd 220 | WARN - CIS-DI-0001: Create a user for the container 221 | * Last user should not be root 222 | INFO - CIS-DI-0005: Enable Content trust for Docker 223 | * export DOCKER_CONTENT_TRUST=1 before docker pull/build 224 | INFO - CIS-DI-0008: Confirm safety of setuid/setgid files 225 | * setuid file: app/suid.txt urw-r--r-- 226 | * setgid file: app/gid.txt grw-r--r-- 227 | * setuid file: usr/bin/gpasswd urwxr-xr-x 228 | * setgid file: usr/bin/wall grwxr-xr-x 229 | * setuid file: bin/su urwxr-xr-x 230 | * setuid file: bin/umount urwxr-xr-x 231 | * setuid file: bin/mount urwxr-xr-x 232 | * setgid file: usr/bin/ssh-agent grwxr-xr-x 233 | * setuid file: etc/shadow urw-r----- 234 | * setuid file: usr/bin/chsh urwxr-xr-x 235 | * setuid file: usr/bin/chfn urwxr-xr-x 236 | * setuid file: usr/lib/openssh/ssh-keysign urwxr-xr-x 237 | * setgid file: etc/passwd grw-r--r-- 238 | * setgid file: sbin/unix_chkpwd grwxr-xr-x 239 | * setgid file: usr/bin/chage grwxr-xr-x 240 | * setuid file: usr/bin/passwd urwxr-xr-x 241 | * setgid file: usr/bin/expiry grwxr-xr-x 242 | * setuid file: usr/bin/newgrp urwxr-xr-x 243 | IGNORE - CIS-DI-0006: Add HEALTHCHECK instruction to the container image 244 | 245 | ``` 246 | 247 |
248 | 249 | ## Docker 250 | 251 | Also, you can use Docker to use `dockle` command as follow. 252 | 253 | ```bash 254 | $ export DOCKLE_LATEST=$( 255 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 256 | grep '"tag_name":' | \ 257 | sed -E 's/.*"v([^"]+)".*/\1/' \ 258 | ) 259 | $ docker run --rm goodwithtech/dockle:v${DOCKLE_LATEST} [YOUR_IMAGE_NAME] 260 | ``` 261 | 262 | - If you'd like to scan the image on your host machine, you need to mount `docker.sock`. 263 | 264 | ```bash 265 | $ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock ... 266 | ``` 267 | 268 | # Checkpoint Summary 269 | 270 | - Details of each checkpoint see [CHECKPOINT.md](CHECKPOINT.md) 271 | 272 | | CODE | DESCRIPTION | LEVEL[※](#level) | 273 | |---|---|:---:| 274 | | | [CIS's Docker Image Checkpoints](CHECKPOINT.md#docker-image-checkpoints) | | 275 | | [CIS-DI-0001](CHECKPOINT.md#cis-di-0001) | Create a user for the container | WARN | 276 | | [CIS-DI-0002](CHECKPOINT.md#cis-di-0002) | Use trusted base images for containers | FATAL 277 | | [CIS-DI-0003](CHECKPOINT.md#cis-di-0003) | Do not install unnecessary packages in the container | FATAL 278 | | [CIS-DI-0004](CHECKPOINT.md#cis-di-0004) | Scan and rebuild the images to include security patches | FATAL 279 | | [CIS-DI-0005](CHECKPOINT.md#cis-di-0005) | Enable Content trust for Docker | INFO 280 | | [CIS-DI-0006](CHECKPOINT.md#cis-di-0006) | Add `HEALTHCHECK` instruction to the container image | INFO 281 | | [CIS-DI-0007](CHECKPOINT.md#cis-di-0007) | Do not use `update` instructions alone in the Dockerfile | FATAL 282 | | [CIS-DI-0008](CHECKPOINT.md#cis-di-0008) | Confirm safety of `setuid` and `setgid` files | INFO 283 | | [CIS-DI-0009](CHECKPOINT.md#cis-di-0009) | Use `COPY` instead of `ADD` in Dockerfile | FATAL 284 | | [CIS-DI-0010](CHECKPOINT.md#cis-di-0010) | Do not store secrets in Dockerfiles | FATAL 285 | | [CIS-DI-0011](CHECKPOINT.md#cis-di-0011) | Install verified packages only | INFO 286 | || [Dockle Checkpoints for Docker](CHECKPOINT.md#dockle-checkpoints-for-docker) | 287 | | [DKL-DI-0001](CHECKPOINT.md#dkl-di-0001) | Avoid `sudo` command | FATAL 288 | | [DKL-DI-0002](CHECKPOINT.md#dkl-di-0002) | Avoid sensitive directory mounting | FATAL 289 | | [DKL-DI-0003](CHECKPOINT.md#dkl-di-0003) | Avoid `apt-get dist-upgrade` | WARN 290 | | [DKL-DI-0004](CHECKPOINT.md#dkl-di-0004) | Use `apk add` with `--no-cache` | FATAL 291 | | [DKL-DI-0005](CHECKPOINT.md#dkl-di-0005) | Clear `apt-get` caches | FATAL 292 | | [DKL-DI-0006](CHECKPOINT.md#dkl-di-0006) | Avoid `latest` tag | WARN 293 | || [Dockle Checkpoints for Linux](CHECKPOINT.md#dockerdockle-checkpoints-for-linux) | 294 | | [DKL-LI-0001](CHECKPOINT.md#dkl-li-0001) | Avoid empty password | FATAL 295 | | [DKL-LI-0002](CHECKPOINT.md#dkl-li-0002) | Be unique UID/GROUPs | FATAL 296 | | [DKL-LI-0003](CHECKPOINT.md#dkl-li-0003) | Only put necessary files | INFO 297 | 298 | ## Level 299 | 300 | `Dockle` has 5 check levels. 301 | 302 | | LEVEL | DESCRIPTION | 303 | |:---:|---| 304 | | FATAL | Be practical and prudent | 305 | | WARN | Be practical and prudent, but limited uses (even if official images) | 306 | | INFO | May negatively inhibit the utility or performance | 307 | | SKIP | Not found target files | 308 | | PASS | Not found any problems | 309 | 310 | ## Common Examples 311 | 312 | ### Scan an image 313 | 314 | Simply specify an image name (and a tag). 315 | 316 | ```bash 317 | $ dockle goodwithtech/test-image:v1 318 | ``` 319 | 320 |
321 | Result 322 | 323 | ``` 324 | FATAL - CIS-DI-0001: Create a user for the container 325 | * Last user should not be root 326 | WARN - CIS-DI-0005: Enable Content trust for Docker 327 | * export DOCKER_CONTENT_TRUST=1 before docker pull/build 328 | FATAL - CIS-DI-0006: Add HEALTHCHECK instruction to the container image 329 | * not found HEALTHCHECK statement 330 | FATAL - CIS-DI-0007: Do not use update instructions alone in the Dockerfile 331 | * Use 'Always combine RUN 'apt-get update' with 'apt-get install' : /bin/sh -c apt-get update && apt-get install -y git 332 | FATAL - CIS-DI-0008: Remove setuid and setgid permissions in the images 333 | * Found setuid file: etc/passwd grw-r--r-- 334 | * Found setuid file: usr/lib/openssh/ssh-keysign urwxr-xr-x 335 | * Found setuid file: app/hoge.txt ugrw-r--r-- 336 | * Found setuid file: app/hoge.txt ugrw-r--r-- 337 | * Found setuid file: etc/shadow urw-r----- 338 | FATAL - CIS-DI-0009: Use COPY instead of ADD in Dockerfile 339 | * Use COPY : /bin/sh -c #(nop) ADD file:81c0a803075715d1a6b4f75a29f8a01b21cc170cfc1bff6702317d1be2fe71a3 in /app/credentials.json 340 | FATAL - CIS-DI-0010: Do not store secrets in ENVIRONMENT variables 341 | * Suspicious ENV key found : MYSQL_PASSWD 342 | FATAL - CIS-DI-0010: Do not store secret files 343 | * Suspicious filename found : app/credentials.json 344 | PASS - DKL-DI-0001: Avoid sudo command 345 | FATAL - DKL-DI-0002: Avoid sensitive directory mounting 346 | * Avoid mounting sensitive dirs : /usr 347 | PASS - DKL-DI-0003: Avoid apt-get/apk/dist-upgrade 348 | PASS - DKL-DI-0004: Use apk add with --no-cache 349 | FATAL - DKL-DI-0005: Clear apt-get caches 350 | * Use 'apt-get clean && rm -rf /var/lib/apt/lists/*' : /bin/sh -c apt-get update && apt-get install -y git 351 | PASS - DKL-DI-0006: Avoid latest tag 352 | FATAL - DKL-LI-0001: Avoid empty password 353 | * No password user found! username : nopasswd 354 | PASS - DKL-LI-0002: Be unique UID 355 | PASS - DKL-LI-0002: Be unique GROUP 356 | ``` 357 |
358 | 359 | ### Scan an image file 360 | 361 | ```bash 362 | $ docker save alpine:latest -o alpine.tar 363 | $ dockle --input alpine.tar 364 | ``` 365 | 366 | ### Get or Save the results as JSON 367 | 368 | ```bash 369 | $ dockle -f json goodwithtech/test-image:v1 370 | $ dockle -f json -o results.json goodwithtech/test-image:v1 371 | ``` 372 | 373 |
374 | Result 375 | 376 | ```json 377 | { 378 | "summary": { 379 | "fatal": 6, 380 | "warn": 2, 381 | "info": 2, 382 | "pass": 7 383 | }, 384 | "details": [ 385 | { 386 | "code": "CIS-DI-0001", 387 | "title": "Create a user for the container", 388 | "level": "WARN", 389 | "alerts": [ 390 | "Last user should not be root" 391 | ] 392 | }, 393 | { 394 | "code": "CIS-DI-0005", 395 | "title": "Enable Content trust for Docker", 396 | "level": "INFO", 397 | "alerts": [ 398 | "export DOCKER_CONTENT_TRUST=1 before docker pull/build" 399 | ] 400 | }, 401 | { 402 | "code": "CIS-DI-0006", 403 | "title": "Add HEALTHCHECK instruction to the container image", 404 | "level": "WARN", 405 | "alerts": [ 406 | "not found HEALTHCHECK statement" 407 | ] 408 | }, 409 | { 410 | "code": "CIS-DI-0008", 411 | "title": "Remove setuid and setgid permissions in the images", 412 | "level": "INFO", 413 | "alerts": [ 414 | "Found setuid file: usr/lib/openssh/ssh-keysign urwxr-xr-x" 415 | ] 416 | }, 417 | { 418 | "code": "CIS-DI-0009", 419 | "title": "Use COPY instead of ADD in Dockerfile", 420 | "level": "FATAL", 421 | "alerts": [ 422 | "Use COPY : /bin/sh -c #(nop) ADD file:81c0a803075715d1a6b4f75a29f8a01b21cc170cfc1bff6702317d1be2fe71a3 in /app/credentials.json " 423 | ] 424 | }, 425 | { 426 | "code": "CIS-DI-0010", 427 | "title": "Do not store secrets in ENVIRONMENT variables", 428 | "level": "FATAL", 429 | "alerts": [ 430 | "Suspicious ENV key found : MYSQL_PASSWD" 431 | ] 432 | }, 433 | { 434 | "code": "CIS-DI-0010", 435 | "title": "Do not store secret files", 436 | "level": "FATAL", 437 | "alerts": [ 438 | "Suspicious filename found : app/credentials.json " 439 | ] 440 | }, 441 | { 442 | "code": "DKL-DI-0002", 443 | "title": "Avoid sensitive directory mounting", 444 | "level": "FATAL", 445 | "alerts": [ 446 | "Avoid mounting sensitive dirs : /usr" 447 | ] 448 | }, 449 | { 450 | "code": "DKL-DI-0005", 451 | "title": "Clear apt-get caches", 452 | "level": "FATAL", 453 | "alerts": [ 454 | "Use 'rm -rf /var/lib/apt/lists' after 'apt-get install' : /bin/sh -c apt-get update \u0026\u0026 apt-get install -y git" 455 | ] 456 | }, 457 | { 458 | "code": "DKL-LI-0001", 459 | "title": "Avoid empty password", 460 | "level": "FATAL", 461 | "alerts": [ 462 | "No password user found! username : nopasswd" 463 | ] 464 | } 465 | ] 466 | } 467 | ``` 468 | 469 |
470 | 471 | ### Get or Save the results as SARIF 472 | 473 | ```bash 474 | $ dockle -f sarif goodwithtech/test-image:v1 475 | $ dockle -f sarif -o results.json goodwithtech/test-image:v1 476 | ``` 477 | 478 |
479 | Result 480 | 481 | ```json 482 | { 483 | "version": "2.1.0", 484 | "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", 485 | "runs": [ 486 | { 487 | "tool": { 488 | "driver": { 489 | "name": "Dockle", 490 | "informationUri": "https://github.com/goodwithtech/dockle", 491 | "rules": [ 492 | { 493 | "id": "CIS-DI-0009", 494 | "shortDescription": { 495 | "text": "Use COPY instead of ADD in Dockerfile" 496 | }, 497 | "help": { 498 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0009" 499 | } 500 | }, 501 | { 502 | "id": "CIS-DI-0010", 503 | "shortDescription": { 504 | "text": "Do not store credential in ENVIRONMENT vars/files" 505 | }, 506 | "help": { 507 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0010" 508 | } 509 | }, 510 | { 511 | "id": "DKL-DI-0005", 512 | "shortDescription": { 513 | "text": "Clear apt-get caches" 514 | }, 515 | "help": { 516 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#DKL-DI-0005" 517 | } 518 | }, 519 | { 520 | "id": "DKL-LI-0001", 521 | "shortDescription": { 522 | "text": "Avoid empty password" 523 | }, 524 | "help": { 525 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#DKL-LI-0001" 526 | } 527 | }, 528 | { 529 | "id": "CIS-DI-0005", 530 | "shortDescription": { 531 | "text": "Enable Content trust for Docker" 532 | }, 533 | "help": { 534 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0005" 535 | } 536 | }, 537 | { 538 | "id": "CIS-DI-0008", 539 | "shortDescription": { 540 | "text": "Confirm safety of setuid/setgid files" 541 | }, 542 | "help": { 543 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0008" 544 | } 545 | }, 546 | { 547 | "id": "CIS-DI-0001", 548 | "shortDescription": { 549 | "text": "Create a user for the container" 550 | }, 551 | "help": { 552 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0001" 553 | } 554 | }, 555 | { 556 | "id": "CIS-DI-0006", 557 | "shortDescription": { 558 | "text": "Add HEALTHCHECK instruction to the container image" 559 | }, 560 | "help": { 561 | "text": "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#CIS-DI-0006" 562 | } 563 | } 564 | ] 565 | } 566 | }, 567 | "results": [ 568 | { 569 | "ruleId": "CIS-DI-0009", 570 | "level": "error", 571 | "message": { 572 | "text": "Use COPY : /bin/sh -c #(nop) ADD file:81c0a803075715d1a6b4f75a29f8a01b21cc170cfc1bff6702317d1be2fe71a3 in /app/credentials.json " 573 | } 574 | }, 575 | { 576 | "ruleId": "CIS-DI-0010", 577 | "level": "error", 578 | "message": { 579 | "text": "Suspicious filename found : app/credentials.json , Suspicious ENV key found : MYSQL_PASSWD" 580 | } 581 | }, 582 | { 583 | "ruleId": "DKL-DI-0005", 584 | "level": "error", 585 | "message": { 586 | "text": "Use 'rm -rf /var/lib/apt/lists' after 'apt-get install' : /bin/sh -c apt-get update \u0026\u0026 apt-get install -y git" 587 | } 588 | }, 589 | { 590 | "ruleId": "DKL-LI-0001", 591 | "level": "error", 592 | "message": { 593 | "text": "No password user found! username : nopasswd" 594 | } 595 | }, 596 | { 597 | "ruleId": "CIS-DI-0005", 598 | "level": "note", 599 | "message": { 600 | "text": "export DOCKER_CONTENT_TRUST=1 before docker pull/build" 601 | } 602 | }, 603 | { 604 | "ruleId": "CIS-DI-0008", 605 | "level": "note", 606 | "message": { 607 | "text": "setuid file: urwxr-xr-x usr/bin/newgrp, setgid file: grwxr-xr-x usr/bin/ssh-agent, setgid file: grwxr-xr-x usr/bin/expiry, setuid file: urwxr-xr-x usr/lib/openssh/ssh-keysign, setuid file: urwxr-xr-x bin/umount, setgid file: grwxr-xr-x usr/bin/chage, setuid file: urwxr-xr-x usr/bin/passwd, setgid file: grwxr-xr-x sbin/unix_chkpwd, setuid file: urwxr-xr-x usr/bin/chsh, setgid file: grwxr-xr-x usr/bin/wall, setuid file: urwxr-xr-x bin/ping, setuid file: urwxr-xr-x bin/su, setuid file: urwxr-xr-x usr/bin/chfn, setuid file: urwxr-xr-x usr/bin/gpasswd, setuid file: urwxr-xr-x bin/mount" 608 | } 609 | }, 610 | { 611 | "ruleId": "CIS-DI-0001", 612 | "level": "none", 613 | "message": { 614 | "text": "Last user should not be root" 615 | } 616 | }, 617 | { 618 | "ruleId": "CIS-DI-0006", 619 | "level": "none", 620 | "message": { 621 | "text": "not found HEALTHCHECK statement" 622 | } 623 | } 624 | ] 625 | } 626 | ] 627 | } 628 | ``` 629 |
630 | 631 | ### Specify exit code 632 | 633 | By default, `Dockle` exits with code `0` even if there are some problems. 634 | 635 | Use the `--exit-code, -c` option to exit with a non-zero exit code if `WARN` or `FATAL` alert were found. 636 | 637 | ```bash 638 | $ dockle --exit-code 1 [IMAGE_NAME] 639 | ``` 640 | 641 | ### Specify exit level 642 | 643 | By default, `--exit-code` run when there are `WARN` or `FATAL` level alerts. 644 | 645 | Use the `--exit-level, -l` option to change alert level. You can set `info`, `warn` or `fatal`. 646 | 647 | ```bash 648 | $ dockle --exit-code 1 --exit-level info [IMAGE_NAME] 649 | $ dockle --exit-code 1 --exit-level fatal [IMAGE_NAME] 650 | ``` 651 | 652 | ### Ignore the specified checkpoints 653 | 654 | The `--ignore, -i` option can ignore specified checkpoints. 655 | 656 | ```bash 657 | $ dockle -i CIS-DI-0001 -i DKL-DI-0006 [IMAGE_NAME] 658 | ``` 659 | 660 | Or, use `DOCKLE_IGNORES`: 661 | 662 | ``` 663 | export DOCKLE_IGNORES=CIS-DI-0001,DKL-DI-0006 664 | dockle [IMAGE_NAME] 665 | ``` 666 | 667 | Or, use `.dockleignore` file: 668 | 669 | ```bash 670 | $ cat .dockleignore 671 | # set root to default user because we want to run nginx 672 | CIS-DI-0001 673 | # Use latest tag because to check the image inside only 674 | DKL-DI-0006 675 | ``` 676 | 677 | ### Accept suspicious `environment variables` / `files` / `file extensions` 678 | 679 | ```bash 680 | # --accept-key value, --ak value You can add acceptable keywords. 681 | dockle -ak GPG_KEY -ak KEYCLOAK_VERSION [IMAGE_NAME] 682 | or DOCKLE_ACCEPT_KEYS=GPG_KEY,KEYCLOAK_VERSION dockle [IMAGE_NAME] 683 | 684 | # --accept-file value, --af value You can add acceptable file names. 685 | dockle -af id_rsa -af id_dsa [IMAGE_NAME] 686 | or DOCKLE_ACCEPT_FILES=id_rsa,id_dsa dockle [IMAGE_NAME] 687 | 688 | # --accept-file-extension value, --ae value You can add acceptable file extensions. 689 | dockle -ae pem -ae log [IMAGE_NAME] 690 | or DOCKLE_ACCEPT_FILE_EXTENSIONS=pem,log dockle [IMAGE_NAME] 691 | ``` 692 | 693 | ### Reject suspicious `environment variables` / `files` / `file extensions` 694 | 695 | ```bash 696 | # --sensitive-word value, --sw value You can add acceptable keywords. 697 | dockle -sw PRIVATE [IMAGE_NAME] 698 | or DOCKLE_ACCEPT_KEYS=GPG_KEY,KEYCLOAK_VERSION dockle [IMAGE_NAME] 699 | 700 | # --sensitive-file value, --sf value You can add acceptable file names. 701 | dockle -sf .env [IMAGE_NAME] 702 | or DOCKLE_REJECT_FILES=.env dockle [IMAGE_NAME] 703 | 704 | # --sensitive-file-extension value, --se value You can add acceptable file extensions. 705 | dockle -se pfx [IMAGE_NAME] 706 | or DOCKLE_REJECT_FILE_EXTENSIONS=pfx dockle [IMAGE_NAME] 707 | ``` 708 | 709 | ## Continuous Integration (CI) 710 | 711 | You can scan your built image with `Dockle` in Travis CI/CircleCI. 712 | 713 | In these examples, the test will fail with if any warnings were found. 714 | 715 | Though, you can ignore the specified target checkpoints by using `.dockleignore` file. 716 | 717 | Or, if you just want the results to display and not let the test fail for this, specify `--exit-code` to `0` in `dockle` command. 718 | 719 | ### GitHub Action 720 | 721 | We provide [goodwithtech/dockle-action](https://github.com/goodwithtech/dockle-action). 722 | 723 | ```yaml 724 | - uses: goodwithtech/dockle-action@main 725 | with: 726 | image: 'target' 727 | format: 'list' 728 | exit-code: '1' 729 | exit-level: 'warn' 730 | ignore: 'CIS-DI-0001,DKL-DI-0006' 731 | ``` 732 | 733 | 734 | ### Travis CI 735 | 736 |
737 | .travis.yml 738 | 739 | ```yaml 740 | services: 741 | - docker 742 | 743 | env: 744 | global: 745 | - COMMIT=${TRAVIS_COMMIT::8} 746 | 747 | before_install: 748 | - docker build -t dockle-ci-test:${COMMIT} . 749 | - export VERSION=$(curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') 750 | - wget https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz 751 | - tar zxvf dockle_${VERSION}_Linux-64bit.tar.gz 752 | script: 753 | - ./dockle dockle-ci-test:${COMMIT} 754 | - ./dockle --exit-code 1 dockle-ci-test:${COMMIT} 755 | ``` 756 |
757 | 758 | - Example: https://travis-ci.org/goodwithtech/dockle-ci-test 759 | - Repository: https://github.com/goodwithtech/dockle-ci-test 760 | 761 | ### CircleCI 762 | 763 |
764 | .circleci/config.yml 765 | 766 | ```yaml 767 | jobs: 768 | build: 769 | docker: 770 | - image: docker:18.09-git 771 | steps: 772 | - checkout 773 | - setup_remote_docker 774 | - run: 775 | name: Build image 776 | command: docker build -t dockle-ci-test:${CIRCLE_SHA1} . 777 | - run: 778 | name: Install dockle 779 | command: | 780 | apk add --update curl 781 | VERSION=$( 782 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 783 | grep '"tag_name":' | \ 784 | sed -E 's/.*"v([^"]+)".*/\1/' 785 | ) 786 | wget https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz 787 | tar zxvf dockle_${VERSION}_Linux-64bit.tar.gz 788 | mv dockle /usr/local/bin 789 | - run: 790 | name: Scan the local image with dockle 791 | command: dockle --exit-code 1 dockle-ci-test:${CIRCLE_SHA1} 792 | workflows: 793 | version: 2 794 | release: 795 | jobs: 796 | - build 797 | ``` 798 |
799 | 800 | - Example: https://circleci.com/gh/goodwithtech/dockle-ci-test 801 | - Repository: https://github.com/goodwithtech/dockle-ci-test 802 | 803 | ## GitLab CI 804 | 805 |
806 | .gitlab-ci.yml 807 | 808 | ```yaml 809 | image: docker:stable 810 | stages: 811 | - test 812 | 813 | variables: 814 | DOCKER_HOST: tcp://docker:2375/ 815 | DOCKER_DRIVER: overlay2 816 | services: 817 | - docker:dind 818 | 819 | unit_test: 820 | stage: test 821 | before_script: 822 | - apk -Uuv add bash git curl tar sed grep 823 | script: 824 | - docker build -t dockle-ci-test:${CI_COMMIT_SHORT_SHA} . 825 | - | 826 | VERSION=$( 827 | curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ 828 | grep '"tag_name":' | \ 829 | sed -E 's/.*"v([^"]+)".*/\1/' \ 830 | ) && curl -L -o dockle.tar.gz https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz && \ 831 | tar zxvf dockle.tar.gz 832 | - ./dockle --exit-code 1 dockle-ci-test:${CI_COMMIT_SHORT_SHA} 833 | ``` 834 |
835 | 836 | - Example: https://gitlab.com/tomoyamachi/dockle-ci-test/-/jobs/238215077 837 | - Repository: https://github.com/goodwithtech/dockle-ci-test 838 | 839 | ## Authorization for Private Docker Registry 840 | 841 | `Dockle` can download images from a private registry, without installing `Docker` or any other 3rd party tools. It's designed so for ease of use in a CI process. 842 | 843 | All you have to do is: install `Dockle` and set ENVIRONMENT variables. 844 | 845 | - NOTE: I don't recommend using ENV vars in your local machine. 846 | 847 | ### Docker Hub 848 | 849 | To download the private repository from Docker Hub, you need to set `DOCKLE_AUTH_URL`, `DOCKLE_USERNAME` and `DOCKLE_PASSWORD` ENV vars. 850 | 851 | 852 | ```bash 853 | export DOCKLE_AUTH_URL=https://registry.hub.docker.com 854 | export DOCKLE_USERNAME={DOCKERHUB_USERNAME} 855 | export DOCKLE_PASSWORD={DOCKERHUB_PASSWORD} 856 | ``` 857 | 858 | - NOTE: You don't need to set ENV vars when downloading from the public repository. 859 | 860 | ### Amazon ECR (Elastic Container Registry) 861 | 862 | `Dockle` uses the AWS SDK. You don't need to install `aws` CLI tool. 863 | 864 | Use [AWS CLI's ENVIRONMENT variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html). 865 | 866 | ```bash 867 | export AWS_ACCESS_KEY_ID={AWS ACCESS KEY} 868 | export AWS_SECRET_ACCESS_KEY={SECRET KEY} 869 | export AWS_DEFAULT_REGION={AWS REGION} 870 | ``` 871 | 872 | ### GCR (Google Container Registry) 873 | 874 | `Dockle` uses the Google Cloud SDK. So, you don't need to install `gcloud` command. 875 | 876 | If you'd like to use the target project's repository, you can settle via `GOOGLE_APPLICATION_CREDENTIAL`. 877 | 878 | ```bash 879 | # must set DOCKLE_USERNAME empty char 880 | export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credential.json 881 | ``` 882 | 883 | ### Self Hosted Registry (BasicAuth) 884 | 885 | BasicAuth server needs `DOCKLE_USERNAME` and `DOCKLE_PASSWORD`. 886 | 887 | ```bash 888 | export DOCKLE_USERNAME={USERNAME} 889 | export DOCKLE_PASSWORD={PASSWORD} 890 | 891 | # if you'd like to use 80 port, use NonSSL 892 | export DOCKLE_NON_SSL=true 893 | ``` 894 | 895 | ## Contributors 896 | 897 | ### Code Contributors 898 | 899 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 900 | 901 | 902 | ### Financial Contributors 903 | 904 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/dockle/contribute)] 905 | 906 | #### Individuals 907 | 908 | 909 | 910 | #### Organizations 911 | 912 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/dockle/contribute)] 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | # License 926 | 927 | - Apache License 2.0 928 | 929 | 930 | # Author 931 | 932 | [@tomoyamachi](https://github.com/tomoyamachi) (Tomoya Amachi) 933 | 934 | Special Thanks to [@knqyf263](https://github.com/knqyf263) (Teppei Fukuda) and [Trivy](https://github.com/knqyf263/trivy) 935 | --------------------------------------------------------------------------------