diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2ab2b31 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/layerlint.yml b/.github/workflows/layerlint.yml index 5b845b8..664ebd5 100644 --- a/.github/workflows/layerlint.yml +++ b/.github/workflows/layerlint.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Build LayerLint run: go build -o layerlint ./cmd/layerlint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73d4d55..f41919a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..f1bcad5 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,35 @@ +name: Backend Verify + +on: + pull_request: + paths: + - "**.go" + - "go.mod" + - "go.sum" + - ".github/workflows/verify.yml" + push: + branches: + - main + paths: + - "**.go" + - "go.mod" + - "go.sum" + - ".github/workflows/verify.yml" + +jobs: + test-backend: + runs-on: ubuntu-latest + name: Run backend test cases + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.5" + cache: true + + - name: Run Go tests + run: go test ./... diff --git a/Dockerfile b/Dockerfile index 6dd64ce..4291cbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.3-alpine +FROM golang:1.26.5-alpine WORKDIR /app @@ -12,4 +12,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + ENTRYPOINT ["/app/layerlint"] diff --git a/docs/rules.md b/docs/rules.md index a004e22..ecd0265 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -236,3 +236,45 @@ RUN curl -L https://example.com/binary -o /usr/local/bin/tool && \ ``` Get the checksum from the official source, verify it. + +## missing-healthcheck + +**Medium Severity** + +Production containers should tell orchestrators if they're healthy. Without `HEALTHCHECK`, Kubernetes and Docker can't tell if your app is actually serving traffic or just running but broken. + +Bad: +```dockerfile +FROM node:18 +CMD ["node", "server.js"] # No way to check if it's working +``` + +Good: +```dockerfile +FROM node:18 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 +CMD ["node", "server.js"] +``` + +Add a health endpoint to your app and check it. Makes rollouts safer, debugging easier. + +## apt-update-without-install (enhanced) + +**Medium Severity** + +Don't run `apt-get update` in a separate RUN from `apt-get install`. The update layer gets cached and goes stale. + +Also: always use `--no-install-recommends` with `apt-get install`. It skips recommended packages that are usually unnecessary — smaller image, smaller attack surface. + +Bad: +```dockerfile +RUN apt-get update && apt-get install -y curl +``` + +Good: +```dockerfile +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +``` + +Chain them together, use `--no-install-recommends`, clean up after. diff --git a/frontend/src/components/RulesShowcase.jsx b/frontend/src/components/RulesShowcase.jsx index eb0b087..c3941ed 100644 --- a/frontend/src/components/RulesShowcase.jsx +++ b/frontend/src/components/RulesShowcase.jsx @@ -8,16 +8,48 @@ const RulesShowcase = () => { severity: 'Critical', title: 'Broad COPY Before Dependencies', description: 'Copying all source files before installing dependencies breaks layer cache and forces unnecessary rebuilds.', - impact: '30-45s slower builds', + impact: '30-45s slower builds per change', fix: 'Copy dependency manifests first, install dependencies, then copy source code' }, { - id: 'dockerfile/inefficient-cache', + id: 'dockerfile/missing-healthcheck', severity: 'High', - title: 'Inefficient Layer Ordering', - description: 'Frequently changing instructions placed before stable ones invalidate cache unnecessarily.', - impact: '15-20s slower builds', - fix: 'Place static dependencies and configuration before frequently changing source code' + title: 'Missing HEALTHCHECK Instruction', + description: 'Production containers need HEALTHCHECK so orchestrators can detect if the app is actually healthy, not just running.', + impact: 'Unreliable rollouts, no auto-healing', + fix: 'Add HEALTHCHECK with curl/wget against a health endpoint before CMD/ENTRYPOINT' + }, + { + id: 'dockerfile/run-as-root', + severity: 'High', + title: 'Container Runs as Root', + description: 'Running as root gives processes full system privileges, violating the principle of least privilege.', + impact: 'Security risk, compliance failures', + fix: 'Create non-root user, use USER instruction before CMD' + }, + { + id: 'dockerfile/copying-secrets', + severity: 'High', + title: 'Secrets Copied Into Image', + description: 'Secrets copied into images remain in layer history even if deleted later.', + impact: 'Credential leaks, security breaches', + fix: 'Use BuildKit --mount=type=secret instead of COPY for sensitive files' + }, + { + id: 'dockerfile/apt-no-recommends', + severity: 'Medium', + title: 'apt-get Without --no-install-recommends', + description: 'Installing packages without --no-install-recommends pulls in unnecessary dependencies, bloating the image.', + impact: '50-200MB larger images, increased attack surface', + fix: 'Add --no-install-recommends to all apt-get install commands' + }, + { + id: 'dockerfile/wget-curl-checksum', + severity: 'Medium', + title: 'Downloads Without Checksum Verification', + description: 'Downloading files without verifying checksums opens the door to supply chain attacks.', + impact: 'Supply chain attacks, corrupted binaries', + fix: 'Verify all downloads with sha256sum' }, { id: 'dockerfile/missing-cache-mounts', @@ -53,7 +85,7 @@ const RulesShowcase = () => { Comprehensive Coverage
- 12+ intelligent rules covering all Docker caching anti-patterns + 14+ intelligent rules covering Docker caching, security, and production-readiness anti-patterns
@@ -83,7 +115,7 @@ const RulesShowcase = () => { - +
{selectedRule.badExample}
@@ -464,7 +480,7 @@ function Docs() {
- ✅ Good Practice
+ ✓ Good Practice
{selectedRule.goodExample}
diff --git a/go.mod b/go.mod
index e1ded19..74b3fcf 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/vviveksharma/layerLint
-go 1.26.3
+go 1.26.5
require (
github.com/moby/buildkit v0.30.0
@@ -8,11 +8,12 @@ require (
)
require (
- github.com/containerd/typeurl/v2 v2.2.3 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/containerd/typeurl/v2 v2.3.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/go.sum b/go.sum
index cfae2ed..6c19c99 100644
--- a/go.sum
+++ b/go.sum
@@ -1,24 +1,20 @@
-github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
-github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
+github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ=
+github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/moby/buildkit v0.30.0 h1:OsK8T3BaYH52UNStpKd7gytDtHWWt2Fawak/lAPWatU=
github.com/moby/buildkit v0.30.0/go.mod h1:k2wuw5ddaOqzh58RLt+mBn2XhK34gi6+gd0faONQ1xU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -27,34 +23,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/.DS_Store b/internal/.DS_Store
new file mode 100644
index 0000000..e0450ea
Binary files /dev/null and b/internal/.DS_Store differ
diff --git a/internal/rules/apt-update-without-install.go b/internal/rules/apt-update-without-install.go
index b2ff73f..4991fdc 100644
--- a/internal/rules/apt-update-without-install.go
+++ b/internal/rules/apt-update-without-install.go
@@ -19,29 +19,45 @@ func (r AptUpdateWithoutInstall) Check(file string, instructions []models.Instru
continue
}
if strings.Contains(ins.Args, "apt-get update") {
- has_install := strings.Contains(ins.Args, "apt-get install")
+ hasInstall := strings.Contains(ins.Args, "apt-get install")
- if !has_install {
+ if !hasInstall {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "medium",
- File: file,
- Line: ins.Line,
- Title: "apt-get update without install in same layer",
- Message: "Running apt-get update in a separate RUN creates a cached layer that may become stale.",
- Suggestion: "Combine apt-get update and apt-get install in the same RUN: RUN apt-get update && apt-get install -y ",
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: ins.Line,
+ Title: "apt-get update without install in same layer",
+ Message: "Running apt-get update in a separate RUN creates a cached layer that may become stale.",
+ Suggestion: "Combine apt-get update and apt-get install in the same RUN: RUN apt-get update && apt-get install -y ",
})
}
- has_rm_rf := strings.Contains(ins.Args, "rm -rf /var/lib/apt/lists/*")
- if !has_rm_rf {
+ hasCleanup := strings.Contains(ins.Args, "rm -rf /var/lib/apt/lists")
+ if !hasCleanup {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "low",
- File: file,
- Line: ins.Line,
- Message: "Running apt-get update in a separate RUN creates a cached layer that may become stale.",
- Suggestion: "Combine apt-get update and apt-get install in the same RUN: RUN apt-get update && apt-get install -y ",
+ RuleID: r.ID(),
+ Severity: "low",
+ File: file,
+ Line: ins.Line,
+ Title: "apt-get update without list cleanup",
+ Message: "The apt package lists are not cleaned up after install, adding unnecessary size to the image layer.",
+ Suggestion: "Add cleanup at the end of the RUN: && rm -rf /var/lib/apt/lists/*",
+ })
+ }
+ }
+
+ if strings.Contains(ins.Args, "apt-get install") {
+ hasNoRecommends := strings.Contains(ins.Args, "--no-install-recommends")
+ if !hasNoRecommends {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: ins.Line,
+ Title: "apt-get install without --no-install-recommends",
+ Message: "apt-get install is called without '--no-install-recommends', which causes unnecessary recommended packages to be installed, increasing image size and attack surface.",
+ Suggestion: "Add --no-install-recommends to the apt-get install command: RUN apt-get update && apt-get install -y --no-install-recommends && rm -rf /var/lib/apt/lists/*",
})
}
}
diff --git a/internal/rules/apt_update_without_install_test.go b/internal/rules/apt_update_without_install_test.go
new file mode 100644
index 0000000..0efb25e
--- /dev/null
+++ b/internal/rules/apt_update_without_install_test.go
@@ -0,0 +1,84 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestAptUpdateWithoutInstall(t *testing.T) {
+ rule := AptUpdateWithoutInstall{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ checkTitles []string
+ }{
+ {
+ name: "update alone produces two findings",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN apt-get update", Line: 1},
+ },
+ wantCount: 2,
+ checkTitles: []string{"apt-get update without install in same layer", "apt-get update without list cleanup"},
+ },
+ {
+ name: "update with install but no cleanup",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN apt-get update && apt-get install -y curl", Line: 1},
+ },
+ wantCount: 2,
+ checkTitles: []string{"apt-get update without list cleanup", "apt-get install without --no-install-recommends"},
+ },
+ {
+ name: "update with install and cleanup but no --no-install-recommends",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*", Line: 1},
+ },
+ wantCount: 1,
+ checkTitles: []string{"apt-get install without --no-install-recommends"},
+ },
+ {
+ name: "update with install cleanup and --no-install-recommends is clean",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "non-RUN instruction ignored",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY . .", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "RUN without apt-get ignored",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN echo hello", Line: 1},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ for i, title := range tt.checkTitles {
+ if i < len(findings) && findings[i].Title != title {
+ t.Errorf("finding[%d].Title = %q, want %q", i, findings[i].Title, title)
+ }
+ }
+
+ for i, f := range findings {
+ if f.Title == "" {
+ t.Errorf("finding[%d] has empty Title", i)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/rules/broad_copy_before_deps.go b/internal/rules/broad_copy_before_deps.go
index 25c89b0..172da7d 100644
--- a/internal/rules/broad_copy_before_deps.go
+++ b/internal/rules/broad_copy_before_deps.go
@@ -1,8 +1,6 @@
package rules
import (
- "strings"
-
"github.com/vviveksharma/layerLint/internal/models"
)
@@ -17,53 +15,28 @@ func (r BroadCopyBeforeDepsRule) Check(file string, instructions []models.Instru
broadCopySeen := false
for _, ins := range instructions {
+
+ if ins.Command == "FROM" {
+ broadCopySeen = false
+ continue
+ }
+
if isCopyOrAdd(ins.Command) && isBroadCopy(ins.Args) {
broadCopySeen = true
}
if ins.Command == "RUN" && broadCopySeen && isDependencyInstall(ins.Args) {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "high",
- File: file,
- Line: ins.Line,
- Title: "Dependency install runs after broad source copy",
- Message: "This dependency step runs after a broad COPY/ADD, so source changes can invalidate the dependency cache.",
- Suggestion: "Copy dependency manifests first, install dependencies, then copy the rest of the source.",
+ RuleID: r.ID(),
+ Severity: "high",
+ File: file,
+ Line: ins.Line,
+ Title: "Dependency install runs after broad source copy",
+ Message: "This dependency step runs after a broad COPY/ADD, so source changes can invalidate the dependency cache.",
+ Suggestion: "Copy dependency manifests first, install dependencies, then copy the rest of the source.",
})
}
}
return findings
}
-
-func isCopyOrAdd(command string) bool {
- return command == "COPY" || command == "ADD"
-}
-
-func isBroadCopy(args string) bool {
- return strings.Contains(args, "COPY . .") ||
- strings.Contains(args, "COPY . /") ||
- strings.Contains(args, "ADD . .") ||
- strings.Contains(args, "ADD . /")
-}
-
-func isDependencyInstall(args string) bool {
- patterns := []string{
- "go mod download",
- "npm install",
- "npm ci",
- "pnpm install",
- "yarn install",
- "pip install",
- "poetry install",
- }
-
- for _, pattern := range patterns {
- if strings.Contains(args, pattern) {
- return true
- }
- }
-
- return false
-}
diff --git a/internal/rules/broad_copy_before_deps_test.go b/internal/rules/broad_copy_before_deps_test.go
new file mode 100644
index 0000000..b24ed30
--- /dev/null
+++ b/internal/rules/broad_copy_before_deps_test.go
@@ -0,0 +1,75 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestBroadCopyBeforeDeps(t *testing.T) {
+ rule := BroadCopyBeforeDepsRule{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "broad copy then dep install",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "RUN", Args: "RUN npm install", Line: 3},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "proper ordering is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN npm install", Line: 3},
+ {Command: "COPY", Args: "COPY . .", Line: 4},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "multi-stage resets state",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18 AS builder", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "FROM", Args: "FROM node:18-slim", Line: 5},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 6},
+ {Command: "RUN", Args: "RUN npm ci", Line: 7},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "ADD broad copy also detected",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM golang:1.22", Line: 1},
+ {Command: "ADD", Args: "ADD . .", Line: 2},
+ {Command: "RUN", Args: "RUN go mod download", Line: 3},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "no dep install is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "RUN", Args: "RUN echo hello", Line: 3},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
diff --git a/internal/rules/build-without-cache-mount.go b/internal/rules/build-without-cache-mount.go
index 5db9608..0e6e54d 100644
--- a/internal/rules/build-without-cache-mount.go
+++ b/internal/rules/build-without-cache-mount.go
@@ -38,15 +38,21 @@ func (r BuildWithoutCacheMount) Check(file string, instructions []models.Instruc
func isCacheableRun(args string) bool {
patterns := []string{
- "RUN go build",
- "RUN go test",
- "RUN go mod download",
- "RUN npm ci",
- "RUN npm install",
- "RUN pnpm install",
- "RUN yarn install",
- "RUN pip install",
- "RUN poetry install",
+ "go build",
+ "go test",
+ "go mod download",
+ "npm ci",
+ "npm install",
+ "pnpm install",
+ "yarn install",
+ "pip install",
+ "poetry install",
+ "bundle install",
+ "cargo build",
+ "mvn package",
+ "mvn install",
+ "gradle build",
+ "composer install",
}
for _, pattern := range patterns {
diff --git a/internal/rules/build_without_cache_mount_test.go b/internal/rules/build_without_cache_mount_test.go
new file mode 100644
index 0000000..1e36083
--- /dev/null
+++ b/internal/rules/build_without_cache_mount_test.go
@@ -0,0 +1,69 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestBuildWithoutCacheMount(t *testing.T) {
+ rule := BuildWithoutCacheMount{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "go build without cache mount",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN go build -o app", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "go build with cache mount is clean",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN --mount=type=cache,target=/go/pkg/mod go build -o app", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "npm ci without cache mount",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN npm ci", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "non-cacheable command is clean",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN echo hello", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "cargo build without cache",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN cargo build --release", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "non-RUN instruction ignored",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY . .", Line: 1},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
diff --git a/internal/rules/copying-secrets-into-image.go b/internal/rules/copying-secrets-into-image.go
index bc2d3a0..cb44dd5 100644
--- a/internal/rules/copying-secrets-into-image.go
+++ b/internal/rules/copying-secrets-into-image.go
@@ -1,6 +1,7 @@
package rules
import (
+ "path/filepath"
"strings"
"github.com/vviveksharma/layerLint/internal/models"
@@ -9,7 +10,7 @@ import (
type CopyingSecretsintoImage struct{}
func (r CopyingSecretsintoImage) ID() string {
- return "copying-secrets-into-image"
+ return "dockerfile/copying-secrets-into-image"
}
func (r CopyingSecretsintoImage) Check(file string, instructions []models.Instruction) []models.Finding {
@@ -18,40 +19,67 @@ func (r CopyingSecretsintoImage) Check(file string, instructions []models.Instru
if ins.Command != "COPY" && ins.Command != "ADD" {
continue
}
- if containsSecretFile(ins.Args) {
- findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "high",
- File: file,
- Line: ins.Line,
- Title: "Sensitive file copied into image",
- Message: "Detected copying of sensitive files (.env, credentials, etc.) into the Docker image.",
- Suggestion: "Use build secrets (--secret), environment variables at runtime, or ensure sensitive files are in .dockerignore.",
- })
+
+ if isInterStageCopy(ins.Args) {
+ continue
+ }
+
+ sources := extractSourceFiles(ins.Args)
+ for _, src := range sources {
+ if isSecretFile(src) {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "high",
+ File: file,
+ Line: ins.Line,
+ Title: "Sensitive file copied into image",
+ Message: "Detected copying of sensitive file '" + src + "' into the Docker image.",
+ Suggestion: "Use build secrets (--mount=type=secret), environment variables at runtime, or ensure sensitive files are in .dockerignore.",
+ })
+ }
}
}
return findings
}
-func containsSecretFile(args string) bool {
- secretPatterns := []string{
+// isSecretFile checks if a filename matches known secret file patterns.
+// Uses basename matching to avoid substring false positives.
+func isSecretFile(filename string) bool {
+ base := filepath.Base(filename)
+
+ exactMatches := []string{
".env",
".env.local",
".env.production",
+ ".env.staging",
"id_rsa",
"id_dsa",
- ".pem",
- ".key",
+ "id_ecdsa",
+ "id_ed25519",
".npmrc",
".aws",
"credentials",
- "secrets",
".git",
+ ".gitconfig",
}
- for _, pattern := range secretPatterns {
- if strings.Contains(args, pattern) {
+ for _, m := range exactMatches {
+ if base == m {
return true
}
}
+
+ secretSuffixes := []string{
+ ".pem",
+ ".key",
+ ".p12",
+ ".pfx",
+ ".jks",
+ }
+ for _, suffix := range secretSuffixes {
+ if strings.HasSuffix(base, suffix) {
+ return true
+ }
+ }
+
return false
}
diff --git a/internal/rules/copying_secrets_into_image_test.go b/internal/rules/copying_secrets_into_image_test.go
new file mode 100644
index 0000000..75ffa98
--- /dev/null
+++ b/internal/rules/copying_secrets_into_image_test.go
@@ -0,0 +1,103 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestCopyingSecretsIntoImage(t *testing.T) {
+ rule := CopyingSecretsintoImage{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "copy env file flagged",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY .env ./", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "copy id_rsa flagged",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY id_rsa /root/.ssh/", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "copy pem file flagged",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY server.pem /etc/ssl/", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "copy key file flagged",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY server.key /etc/ssl/", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "inter-stage copy is safe",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY --from=builder /app/binary /app/", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "normal file is clean",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY package.json ./", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "environment txt not false positive",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY environment.txt ./", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "rule ID has dockerfile prefix",
+ instructions: []models.Instruction{
+ {Command: "COPY", Args: "COPY .env ./", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "ADD with secret also flagged",
+ instructions: []models.Instruction{
+ {Command: "ADD", Args: "ADD .npmrc ./", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "RUN instruction ignored",
+ instructions: []models.Instruction{
+ {Command: "RUN", Args: "RUN echo .env", Line: 1},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+
+ for _, f := range findings {
+ if f.RuleID != "dockerfile/copying-secrets-into-image" {
+ t.Errorf("unexpected rule ID: %q", f.RuleID)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/rules/helpers.go b/internal/rules/helpers.go
new file mode 100644
index 0000000..20cae14
--- /dev/null
+++ b/internal/rules/helpers.go
@@ -0,0 +1,87 @@
+package rules
+
+import "strings"
+
+// isCopyOrAdd returns true if the command is COPY or ADD.
+func isCopyOrAdd(command string) bool {
+ return command == "COPY" || command == "ADD"
+}
+
+// isBroadCopy checks if the instruction args represent a broad copy (COPY . . or ADD . /).
+// Note: Args contains the full original line including the command keyword.
+func isBroadCopy(args string) bool {
+ broadPatterns := []string{
+ "COPY . .",
+ "COPY . /",
+ "ADD . .",
+ "ADD . /",
+ }
+ for _, p := range broadPatterns {
+ if strings.Contains(args, p) {
+ return true
+ }
+ }
+ return false
+}
+
+// isDependencyInstall checks if args contain a known dependency install command.
+func isDependencyInstall(args string) bool {
+ patterns := []string{
+ "go mod download",
+ "npm install",
+ "npm ci",
+ "pnpm install",
+ "yarn install",
+ "pip install",
+ "poetry install",
+ "bundle install",
+ "composer install",
+ }
+ for _, pattern := range patterns {
+ if strings.Contains(args, pattern) {
+ return true
+ }
+ }
+ return false
+}
+
+// isInterStageCopy checks if a COPY/ADD instruction uses --from= flag (inter-stage copy).
+func isInterStageCopy(args string) bool {
+ return strings.Contains(args, "--from=")
+}
+
+// extractSourceFiles extracts the source file names from a COPY/ADD instruction's args.
+// It skips the command keyword, any --flags, and the last token (destination).
+// Example: "COPY --chown=node package.json package-lock.json ./" → ["package.json", "package-lock.json"]
+func extractSourceFiles(args string) []string {
+ fields := strings.Fields(args)
+ if len(fields) < 3 {
+ return nil
+ }
+
+ middle := fields[1 : len(fields)-1]
+
+ var sources []string
+ for _, f := range middle {
+ if strings.HasPrefix(f, "--") {
+ continue
+ }
+ sources = append(sources, f)
+ }
+ return sources
+}
+
+// isScratchImage returns true if the image name is the special Docker scratch image.
+func isScratchImage(imageName string) bool {
+ return imageName == "scratch"
+}
+
+// isDigestPinned returns true if the image reference uses a digest (@sha256:...).
+func isDigestPinned(imageName string) bool {
+ return strings.Contains(imageName, "@")
+}
+
+// isVariableImage returns true if the image name contains build arg variables.
+func isVariableImage(imageName string) bool {
+ return strings.Contains(imageName, "$")
+}
diff --git a/internal/rules/helpers_test.go b/internal/rules/helpers_test.go
new file mode 100644
index 0000000..1319680
--- /dev/null
+++ b/internal/rules/helpers_test.go
@@ -0,0 +1,180 @@
+package rules
+
+import "testing"
+
+func TestIsCopyOrAdd(t *testing.T) {
+ tests := []struct {
+ name string
+ command string
+ want bool
+ }{
+ {"COPY command", "COPY", true},
+ {"ADD command", "ADD", true},
+ {"RUN command", "RUN", false},
+ {"FROM command", "FROM", false},
+ {"empty string", "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isCopyOrAdd(tt.command); got != tt.want {
+ t.Errorf("isCopyOrAdd(%q) = %v, want %v", tt.command, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsBroadCopy(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want bool
+ }{
+ {"COPY dot dot", "COPY . .", true},
+ {"COPY dot slash", "COPY . /", true},
+ {"ADD dot dot", "ADD . .", true},
+ {"ADD dot slash", "ADD . /", true},
+ {"specific file COPY", "COPY package.json ./", false},
+ {"COPY to specific dir", "COPY src/ /app/src/", false},
+ {"empty args", "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isBroadCopy(tt.args); got != tt.want {
+ t.Errorf("isBroadCopy(%q) = %v, want %v", tt.args, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsDependencyInstall(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want bool
+ }{
+ {"npm install", "RUN npm install", true},
+ {"npm ci", "RUN npm ci", true},
+ {"go mod download", "RUN go mod download", true},
+ {"pip install", "RUN pip install -r requirements.txt", true},
+ {"yarn install", "RUN yarn install", true},
+ {"pnpm install", "RUN pnpm install", true},
+ {"poetry install", "RUN poetry install", true},
+ {"bundle install", "RUN bundle install", true},
+ {"composer install", "RUN composer install", true},
+ {"go build not dep install", "RUN go build -o app", false},
+ {"echo command", "RUN echo hello", false},
+ {"empty", "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isDependencyInstall(tt.args); got != tt.want {
+ t.Errorf("isDependencyInstall(%q) = %v, want %v", tt.args, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsInterStageCopy(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want bool
+ }{
+ {"inter-stage copy", "COPY --from=builder /app/bin /usr/local/bin/", true},
+ {"normal copy", "COPY package.json ./", false},
+ {"chown copy", "COPY --chown=node package.json ./", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isInterStageCopy(tt.args); got != tt.want {
+ t.Errorf("isInterStageCopy(%q) = %v, want %v", tt.args, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractSourceFiles(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want []string
+ }{
+ {"single file", "COPY package.json ./", []string{"package.json"}},
+ {"multiple files", "COPY package.json package-lock.json ./", []string{"package.json", "package-lock.json"}},
+ {"with chown flag", "COPY --chown=node package.json ./", []string{"package.json"}},
+ {"broad copy", "COPY . .", []string{"."}},
+ {"too few fields", "COPY ./", nil},
+ {"empty", "", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractSourceFiles(tt.args)
+ if len(got) != len(tt.want) {
+ t.Errorf("extractSourceFiles(%q) = %v, want %v", tt.args, got, tt.want)
+ return
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("extractSourceFiles(%q)[%d] = %q, want %q", tt.args, i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestIsScratchImage(t *testing.T) {
+ tests := []struct {
+ name string
+ image string
+ want bool
+ }{
+ {"scratch", "scratch", true},
+ {"golang", "golang", false},
+ {"empty", "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isScratchImage(tt.image); got != tt.want {
+ t.Errorf("isScratchImage(%q) = %v, want %v", tt.image, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsDigestPinned(t *testing.T) {
+ tests := []struct {
+ name string
+ image string
+ want bool
+ }{
+ {"digest pinned", "node@sha256:abc123", true},
+ {"tag pinned", "node:18", false},
+ {"no tag", "node", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isDigestPinned(tt.image); got != tt.want {
+ t.Errorf("isDigestPinned(%q) = %v, want %v", tt.image, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsVariableImage(t *testing.T) {
+ tests := []struct {
+ name string
+ image string
+ want bool
+ }{
+ {"dollar variable", "$BASE_IMAGE", true},
+ {"braced variable", "${BASE_IMAGE}:${VERSION}", true},
+ {"normal image", "node:18", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isVariableImage(tt.image); got != tt.want {
+ t.Errorf("isVariableImage(%q) = %v, want %v", tt.image, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/rules/manifest-without-lockfile.go b/internal/rules/manifest-without-lockfile.go
index 755c61f..d47d57c 100644
--- a/internal/rules/manifest-without-lockfile.go
+++ b/internal/rules/manifest-without-lockfile.go
@@ -17,8 +17,14 @@ func (r ManifestWithoutLockfile) Check(file string, instructions []models.Instru
copiedFiles := map[string]bool{}
for _, ins := range instructions {
+
+ if ins.Command == "FROM" {
+ copiedFiles = map[string]bool{}
+ continue
+ }
+
if isCopyOrAdd(ins.Command) {
- for _, copied := range extractCopiedFiles(ins.Args) {
+ for _, copied := range extractSourceFiles(ins.Args) {
copiedFiles[copied] = true
}
}
@@ -27,50 +33,62 @@ func (r ManifestWithoutLockfile) Check(file string, instructions []models.Instru
continue
}
- if isNpmCI(ins.Args) {
+ if strings.Contains(ins.Args, "npm ci") {
if copiedFiles["package.json"] && !copiedFiles["package-lock.json"] {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "high",
- File: file,
- Line: ins.Line,
- Title: "npm ci runs without package-lock.json",
- Message: "npm ci should run after both package.json and package-lock.json are copied for reproducible installs and better cache reuse.",
- Suggestion: "Copy package.json and package-lock.json before running npm ci.",
+ RuleID: r.ID(),
+ Severity: "high",
+ File: file,
+ Line: ins.Line,
+ Title: "npm ci runs without package-lock.json",
+ Message: "npm ci should run after both package.json and package-lock.json are copied for reproducible installs and better cache reuse.",
+ Suggestion: "Copy package.json and package-lock.json before running npm ci.",
})
}
}
- if isGoModDownload(ins.Args) {
+ if strings.Contains(ins.Args, "go mod download") {
if copiedFiles["go.mod"] && !copiedFiles["go.sum"] {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "medium",
- File: file,
- Line: ins.Line,
- Title: "go mod download runs without go.sum",
- Message: "go mod download should usually run after both go.mod and go.sum are copied to improve cache reuse and dependency correctness.",
- Suggestion: "Copy go.mod and go.sum before running go mod download.",
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: ins.Line,
+ Title: "go mod download runs without go.sum",
+ Message: "go mod download should usually run after both go.mod and go.sum are copied to improve cache reuse and dependency correctness.",
+ Suggestion: "Copy go.mod and go.sum before running go mod download.",
})
}
}
- }
- return findings
-}
-
-func isNpmCI(args string) bool {
- return strings.Contains(args, "npm ci")
-}
-
-func isGoModDownload(args string) bool {
- return strings.Contains(args, "go mod download")
-}
+ if strings.Contains(ins.Args, "yarn install") {
+ if copiedFiles["package.json"] && !copiedFiles["yarn.lock"] {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "high",
+ File: file,
+ Line: ins.Line,
+ Title: "yarn install runs without yarn.lock",
+ Message: "yarn install should run after both package.json and yarn.lock are copied for reproducible installs.",
+ Suggestion: "Copy package.json and yarn.lock before running yarn install.",
+ })
+ }
+ }
-func extractCopiedFiles(args string) []string {
- fields := strings.Fields(args)
- if len(fields) < 3 {
- return nil
+ if strings.Contains(ins.Args, "pnpm install") {
+ if copiedFiles["package.json"] && !copiedFiles["pnpm-lock.yaml"] {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "high",
+ File: file,
+ Line: ins.Line,
+ Title: "pnpm install runs without pnpm-lock.yaml",
+ Message: "pnpm install should run after both package.json and pnpm-lock.yaml are copied for reproducible installs.",
+ Suggestion: "Copy package.json and pnpm-lock.yaml before running pnpm install.",
+ })
+ }
+ }
}
- return fields[1 : len(fields)-1]
+
+ return findings
}
diff --git a/internal/rules/manifest_without_lockfile_test.go b/internal/rules/manifest_without_lockfile_test.go
new file mode 100644
index 0000000..2db08e2
--- /dev/null
+++ b/internal/rules/manifest_without_lockfile_test.go
@@ -0,0 +1,102 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestManifestWithoutLockfile(t *testing.T) {
+ rule := ManifestWithoutLockfile{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "npm ci without lock file",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN npm ci", Line: 3},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "npm ci with lock file is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json package-lock.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN npm ci", Line: 3},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "go mod download without go.sum",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM golang:1.22", Line: 1},
+ {Command: "COPY", Args: "COPY go.mod ./", Line: 2},
+ {Command: "RUN", Args: "RUN go mod download", Line: 3},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "go mod download with go.sum is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM golang:1.22", Line: 1},
+ {Command: "COPY", Args: "COPY go.mod go.sum ./", Line: 2},
+ {Command: "RUN", Args: "RUN go mod download", Line: 3},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "multi-stage resets copied files",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18 AS builder", Line: 1},
+ {Command: "COPY", Args: "COPY package.json package-lock.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN npm ci", Line: 3},
+ {Command: "FROM", Args: "FROM node:18-slim", Line: 5},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 6},
+ {Command: "RUN", Args: "RUN npm ci", Line: 7},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "copy with chown flag still detects file",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY --chown=node package.json package-lock.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN npm ci", Line: 3},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "yarn install without yarn.lock",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN yarn install", Line: 3},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "pnpm install without pnpm-lock.yaml",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 2},
+ {Command: "RUN", Args: "RUN pnpm install", Line: 3},
+ },
+ wantCount: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
diff --git a/internal/rules/missing-healthcheck.go b/internal/rules/missing-healthcheck.go
new file mode 100644
index 0000000..2759760
--- /dev/null
+++ b/internal/rules/missing-healthcheck.go
@@ -0,0 +1,47 @@
+package rules
+
+import (
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+type MissingHealthcheck struct{}
+
+func (r MissingHealthcheck) ID() string {
+ return "dockerfile/missing-healthcheck"
+}
+
+func (r MissingHealthcheck) Check(file string, instructions []models.Instruction) []models.Finding {
+ var findings []models.Finding
+ hasEntrypoint := false
+ hasHealthcheck := false
+ var lastEntrypointLine int
+
+ for _, ins := range instructions {
+ switch ins.Command {
+ case "FROM":
+
+ hasEntrypoint = false
+ hasHealthcheck = false
+ lastEntrypointLine = 0
+ case "CMD", "ENTRYPOINT":
+ hasEntrypoint = true
+ lastEntrypointLine = ins.Line
+ case "HEALTHCHECK":
+ hasHealthcheck = true
+ }
+ }
+
+ if hasEntrypoint && !hasHealthcheck {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: lastEntrypointLine,
+ Title: "Missing HEALTHCHECK instruction",
+ Message: "The container has a CMD/ENTRYPOINT but no HEALTHCHECK. Without it, orchestrators like Kubernetes and Docker cannot detect if the application is actually healthy or just running.",
+ Suggestion: "Add a HEALTHCHECK instruction, e.g., 'HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD curl -f http://localhost:8080/health || exit 1'",
+ })
+ }
+
+ return findings
+}
diff --git a/internal/rules/missing_docker_ignore_test.go b/internal/rules/missing_docker_ignore_test.go
new file mode 100644
index 0000000..e26b6ee
--- /dev/null
+++ b/internal/rules/missing_docker_ignore_test.go
@@ -0,0 +1,41 @@
+package rules
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestMissingDockerIgnore(t *testing.T) {
+ rule := MissingDockerIgnore{}
+
+ t.Run("missing dockerignore produces finding", func(t *testing.T) {
+
+ tmpDir := t.TempDir()
+ dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
+ os.WriteFile(dockerfilePath, []byte("FROM node:18\n"), 0644)
+
+ findings := rule.Check(dockerfilePath, []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ })
+ if len(findings) != 1 {
+ t.Errorf("got %d findings, want 1", len(findings))
+ }
+ })
+
+ t.Run("existing dockerignore is clean", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
+ os.WriteFile(dockerfilePath, []byte("FROM node:18\n"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, ".dockerignore"), []byte("node_modules\n"), 0644)
+
+ findings := rule.Check(dockerfilePath, []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ })
+ if len(findings) != 0 {
+ t.Errorf("got %d findings, want 0", len(findings))
+ }
+ })
+}
diff --git a/internal/rules/missing_healthcheck_test.go b/internal/rules/missing_healthcheck_test.go
new file mode 100644
index 0000000..abf67d3
--- /dev/null
+++ b/internal/rules/missing_healthcheck_test.go
@@ -0,0 +1,83 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestMissingHealthcheck(t *testing.T) {
+ rule := MissingHealthcheck{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "entrypoint without healthcheck flagged",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "CMD", Args: "CMD [\"node\", \"app.js\"]", Line: 10},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "entrypoint with healthcheck is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "HEALTHCHECK", Args: "HEALTHCHECK --interval=30s CMD curl -f http://localhost:3000/health || exit 1", Line: 8},
+ {Command: "CMD", Args: "CMD [\"node\", \"app.js\"]", Line: 10},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "no entrypoint is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "multi-stage: final stage without healthcheck flagged",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM golang:1.22 AS build", Line: 1},
+ {Command: "RUN", Args: "RUN go build -o app", Line: 5},
+ {Command: "FROM", Args: "FROM alpine:3.21", Line: 10},
+ {Command: "COPY", Args: "COPY --from=build /app /app", Line: 12},
+ {Command: "CMD", Args: "CMD [\"/app\"]", Line: 15},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "multi-stage: final stage with healthcheck is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM golang:1.22 AS build", Line: 1},
+ {Command: "RUN", Args: "RUN go build -o app", Line: 5},
+ {Command: "FROM", Args: "FROM alpine:3.21", Line: 10},
+ {Command: "COPY", Args: "COPY --from=build /app /app", Line: 12},
+ {Command: "HEALTHCHECK", Args: "HEALTHCHECK CMD wget -qO- http://localhost:8080/health || exit 1", Line: 14},
+ {Command: "CMD", Args: "CMD [\"/app\"]", Line: 15},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "ENTRYPOINT variant also flagged",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM python:3.12", Line: 1},
+ {Command: "ENTRYPOINT", Args: "ENTRYPOINT [\"python\", \"app.py\"]", Line: 20},
+ },
+ wantCount: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
diff --git a/internal/rules/multiple-broad-copies.go b/internal/rules/multiple-broad-copies.go
index 2241ac7..493f9f1 100644
--- a/internal/rules/multiple-broad-copies.go
+++ b/internal/rules/multiple-broad-copies.go
@@ -1,6 +1,8 @@
package rules
import (
+ "fmt"
+
"github.com/vviveksharma/layerLint/internal/models"
)
@@ -13,18 +15,25 @@ func (r MultipleBroadCopies) ID() string {
func (r MultipleBroadCopies) Check(file string, instructions []models.Instruction) []models.Finding {
var findings []models.Finding
check := 0
+
for _, ins := range instructions {
+
+ if ins.Command == "FROM" {
+ check = 0
+ continue
+ }
+
if isBroadCopy(ins.Args) {
check++
if check > 1 {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "medium",
- File: file,
- Line: ins.Line,
- Title: "Multiple broad source copies detected",
- Message: "This is the {N}th broad copy operation (COPY . . or ADD . .). Multiple broad copies create redundant layers and invalidate cache unnecessarily.",
- Suggestion: "Consolidate into a single COPY/ADD operation, or use specific paths instead of broad copies.",
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: ins.Line,
+ Title: "Multiple broad source copies detected",
+ Message: fmt.Sprintf("This is the %dth broad copy operation (COPY . . or ADD . .) in this stage. Multiple broad copies create redundant layers and invalidate cache unnecessarily.", check),
+ Suggestion: "Consolidate into a single COPY/ADD operation, or use specific paths instead of broad copies.",
})
}
}
diff --git a/internal/rules/multiple_broad_copies_test.go b/internal/rules/multiple_broad_copies_test.go
new file mode 100644
index 0000000..4e76b83
--- /dev/null
+++ b/internal/rules/multiple_broad_copies_test.go
@@ -0,0 +1,89 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestMultipleBroadCopies(t *testing.T) {
+ rule := MultipleBroadCopies{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "two broad copies in same stage",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "RUN", Args: "RUN npm install", Line: 3},
+ {Command: "COPY", Args: "COPY . .", Line: 4},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "single broad copy is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "one per stage in multi-stage is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18 AS builder", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "FROM", Args: "FROM node:18-slim", Line: 5},
+ {Command: "COPY", Args: "COPY . .", Line: 6},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "three broad copies in same stage",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY . .", Line: 2},
+ {Command: "COPY", Args: "COPY . /", Line: 3},
+ {Command: "ADD", Args: "ADD . .", Line: 4},
+ },
+ wantCount: 2,
+ },
+ {
+ name: "specific copies are clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "COPY", Args: "COPY package.json ./", Line: 2},
+ {Command: "COPY", Args: "COPY src/ ./src/", Line: 3},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+
+ for _, f := range findings {
+ if f.Message == "" || containsSubstr(f.Message, "{N}") {
+ t.Errorf("finding has bad message: %q", f.Message)
+ }
+ }
+ })
+ }
+}
+
+func containsSubstr(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/rules/redudent-dependency-install.go b/internal/rules/redudent-dependency-install.go
index 5fb8eb2..7662bcd 100644
--- a/internal/rules/redudent-dependency-install.go
+++ b/internal/rules/redudent-dependency-install.go
@@ -13,41 +13,48 @@ func (r RedundantDependencyInstall) ID() string {
}
func (r RedundantDependencyInstall) Check(file string, instructions []models.Instruction) []models.Finding {
- var findings []models.Finding
- checklist := map[string][]int{}
-
- patterns := []string{
- "npm install",
- "go mod download",
- "pip install -r requirements.txt",
- }
- for _, ins := range instructions {
- if ins.Command != "RUN" {
- continue
- }
-
- for _, pattern := range patterns {
- if strings.Contains(ins.Args, pattern) {
- checklist[pattern] = append(checklist[pattern], ins.Line)
- }
- }
- }
-
- for pattern, lines := range checklist {
- if len(lines) > 1 {
- for i := 1; i < len(lines); i++ {
- findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "medium",
- File: file,
- Line: lines[i],
- Title: "Redundant dependency install detected",
- Message: "Multiple '" + pattern + "' commands found. This indicates broken layer design.",
- Suggestion: "Consolidate dependency installs into a single RUN command.",
- })
- }
- }
- }
-
- return findings
-}
\ No newline at end of file
+ var findings []models.Finding
+ checklist := map[string][]int{}
+
+ patterns := []string{
+ "npm install",
+ "go mod download",
+ "pip install -r requirements.txt",
+ }
+
+ for _, ins := range instructions {
+
+ if ins.Command == "FROM" {
+ checklist = map[string][]int{}
+ continue
+ }
+
+ if ins.Command != "RUN" {
+ continue
+ }
+
+ for _, pattern := range patterns {
+ if strings.Contains(ins.Args, pattern) {
+ checklist[pattern] = append(checklist[pattern], ins.Line)
+ }
+ }
+ }
+
+ for pattern, lines := range checklist {
+ if len(lines) > 1 {
+ for i := 1; i < len(lines); i++ {
+ findings = append(findings, models.Finding{
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: lines[i],
+ Title: "Redundant dependency install detected",
+ Message: "Multiple '" + pattern + "' commands found in the same stage. This indicates broken layer design.",
+ Suggestion: "Consolidate dependency installs into a single RUN command.",
+ })
+ }
+ }
+ }
+
+ return findings
+}
diff --git a/internal/rules/redundant_dependency_install_test.go b/internal/rules/redundant_dependency_install_test.go
new file mode 100644
index 0000000..74a24ac
--- /dev/null
+++ b/internal/rules/redundant_dependency_install_test.go
@@ -0,0 +1,73 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestRedundantDependencyInstall(t *testing.T) {
+ rule := RedundantDependencyInstall{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "duplicate npm install in same stage",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "RUN", Args: "RUN npm install", Line: 2},
+ {Command: "RUN", Args: "RUN npm install", Line: 5},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "single install is clean",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "RUN", Args: "RUN npm install", Line: 2},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "different stages are independent",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18 AS builder", Line: 1},
+ {Command: "RUN", Args: "RUN npm install", Line: 2},
+ {Command: "FROM", Args: "FROM node:18 AS test", Line: 5},
+ {Command: "RUN", Args: "RUN npm install", Line: 6},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "different install commands are independent",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "RUN", Args: "RUN npm install", Line: 2},
+ {Command: "RUN", Args: "RUN pip install -r requirements.txt", Line: 3},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "triple install produces two findings",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18", Line: 1},
+ {Command: "RUN", Args: "RUN npm install", Line: 2},
+ {Command: "RUN", Args: "RUN npm install", Line: 3},
+ {Command: "RUN", Args: "RUN npm install", Line: 4},
+ },
+ wantCount: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
diff --git a/internal/rules/unpinned-base-image-tag.go b/internal/rules/unpinned-base-image-tag.go
index bbf69b2..131240b 100644
--- a/internal/rules/unpinned-base-image-tag.go
+++ b/internal/rules/unpinned-base-image-tag.go
@@ -21,28 +21,49 @@ func (r UnpinnedBaseImageTag) Check(file string, instructions []models.Instructi
imageName := extractImageName(ins.Args)
+ if isScratchImage(imageName) {
+ continue
+ }
+
+ if isDigestPinned(imageName) {
+ continue
+ }
+
+ if isVariableImage(imageName) {
+ continue
+ }
+
if isUnpinnedImage(imageName) {
findings = append(findings, models.Finding{
- RuleID: r.ID(),
- Severity: "medium",
- File: file,
- Line: ins.Line,
- Title: "Unpinned base image tag detected",
- Message: "Base image uses ':latest' or no tag (implicit :latest), which hurts reproducibility and can cause unexpected rebuilds when the upstream image changes.",
- Suggestion: "Pin to a specific version tag (e.g., 'node:18.20.0', 'ubuntu:22.04', 'golang:1.22.3').",
+ RuleID: r.ID(),
+ Severity: "medium",
+ File: file,
+ Line: ins.Line,
+ Title: "Unpinned base image tag detected",
+ Message: "Base image '" + imageName + "' uses ':latest' or no tag (implicit :latest), so builds are not reproducible — the exact image contents can change when the upstream image is updated.",
+ Suggestion: "Pin to a specific version tag (e.g., 'golang:1.23.0') for fully reproducible builds, or use a rolling tag like 'golang:latest' or 'golang:bookworm' if you prefer automatic security updates. For maximum supply-chain security, use a digest pin (e.g., 'node@sha256:...').",
})
}
}
return findings
}
+// extractImageName pulls the image reference from a FROM instruction's args.
+// Handles --platform= flag and AS alias.
func extractImageName(args string) string {
args = strings.TrimPrefix(args, "FROM ")
+ args = strings.TrimPrefix(args, "from ")
- if strings.HasPrefix(args, "--platform=") {
+ if strings.HasPrefix(args, "--platform") {
parts := strings.Fields(args)
if len(parts) > 1 {
- args = strings.Join(parts[1:], " ")
+
+ if strings.Contains(parts[0], "=") {
+ args = strings.Join(parts[1:], " ")
+ } else if len(parts) > 2 {
+
+ args = strings.Join(parts[2:], " ")
+ }
}
}
diff --git a/internal/rules/unpinned_base_image_tag_test.go b/internal/rules/unpinned_base_image_tag_test.go
new file mode 100644
index 0000000..ae29eb8
--- /dev/null
+++ b/internal/rules/unpinned_base_image_tag_test.go
@@ -0,0 +1,156 @@
+package rules
+
+import (
+ "testing"
+
+ "github.com/vviveksharma/layerLint/internal/models"
+)
+
+func TestUnpinnedBaseImageTag(t *testing.T) {
+ rule := UnpinnedBaseImageTag{}
+
+ tests := []struct {
+ name string
+ instructions []models.Instruction
+ wantCount int
+ }{
+ {
+ name: "no tag means unpinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "explicit latest is unpinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:latest", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "specific tag is pinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node:18.20.0", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "scratch is exempt",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM scratch", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "scratch with AS alias is exempt",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM scratch AS final", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "digest pinned is exempt",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM node@sha256:abc123def456", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "variable image is exempt",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM ${BASE_IMAGE}", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "platform flag with unpinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM --platform=$BUILDPLATFORM golang", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "platform flag with pinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM --platform=$BUILDPLATFORM golang:1.22", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "dumbproxy multi-stage",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM --platform=$BUILDPLATFORM golang AS build", Line: 1},
+ {Command: "RUN", Args: "RUN go build -a", Line: 5},
+ {Command: "FROM", Args: "FROM scratch AS scratch", Line: 9},
+ {Command: "COPY", Args: "COPY --from=build /go/src/app /", Line: 10},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "dumbproxy full with alpine",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM --platform=$BUILDPLATFORM golang AS build", Line: 1},
+ {Command: "RUN", Args: "RUN go build -a", Line: 5},
+ {Command: "FROM", Args: "FROM scratch AS scratch", Line: 9},
+ {Command: "COPY", Args: "COPY --from=build /go/src/app /", Line: 10},
+ {Command: "FROM", Args: "FROM alpine AS alpine", Line: 16},
+ {Command: "COPY", Args: "COPY --from=build /go/src/app /", Line: 17},
+ },
+ wantCount: 2,
+ },
+ {
+ name: "alpine with specific tag is pinned",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM alpine:3.21", Line: 1},
+ },
+ wantCount: 0,
+ },
+ {
+ name: "registry with path and no tag",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM registry.example.com/myapp/image", Line: 1},
+ },
+ wantCount: 1,
+ },
+ {
+ name: "registry with path and tag",
+ instructions: []models.Instruction{
+ {Command: "FROM", Args: "FROM registry.example.com/myapp/image:1.0", Line: 1},
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ findings := rule.Check("Dockerfile", tt.instructions)
+ if len(findings) != tt.wantCount {
+ t.Errorf("got %d findings, want %d. Findings: %+v", len(findings), tt.wantCount, findings)
+ }
+ })
+ }
+}
+
+func TestExtractImageName(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want string
+ }{
+ {"simple image", "FROM node", "node"},
+ {"image with tag", "FROM node:18", "node:18"},
+ {"image with AS", "FROM node:18 AS builder", "node:18"},
+ {"platform flag equals", "FROM --platform=$BUILDPLATFORM golang", "golang"},
+ {"platform flag space", "FROM --platform linux/amd64 golang:1.22", "golang:1.22"},
+ {"scratch", "FROM scratch", "scratch"},
+ {"digest", "FROM node@sha256:abc123", "node@sha256:abc123"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := extractImageName(tt.args); got != tt.want {
+ t.Errorf("extractImageName(%q) = %q, want %q", tt.args, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scanner/scaner.go b/internal/scanner/scaner.go
index 19ed75a..43a0940 100644
--- a/internal/scanner/scaner.go
+++ b/internal/scanner/scaner.go
@@ -19,6 +19,7 @@ func DefaultRules() []models.Rule {
rules.RunAsRoot{},
rules.WgetCurlWithoutChecksum{},
rules.AddInsteadOfCopy{},
+ rules.MissingHealthcheck{},
}
}