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 = () => { - +
Impact: {rule.impact}
@@ -95,4 +127,4 @@ const RulesShowcase = () => { ) } -export default RulesShowcase \ No newline at end of file +export default RulesShowcase diff --git a/frontend/src/pages/Docs.jsx b/frontend/src/pages/Docs.jsx index 0262f16..a2aba55 100644 --- a/frontend/src/pages/Docs.jsx +++ b/frontend/src/pages/Docs.jsx @@ -12,7 +12,7 @@ const rules = [ category: 'Layer Caching', description: 'Copying application code before installing dependencies breaks Docker layer caching. When you change any file in your app, Docker rebuilds all layers including dependency installation.', problem: 'Every code change forces complete dependency reinstallation, even if package.json/requirements.txt/go.mod hasn\'t changed. This multiplies build times by 10-50x for large projects.', - impact: '45 seconds wasted per build × 50 builds/day = 37.5 minutes of developer time lost daily', + impact: '45 seconds wasted per build x 50 builds/day = 37.5 minutes of developer time lost daily', badExample: `FROM node:20-alpine COPY . /app @@ -138,13 +138,13 @@ Context: 85 MB transferred, 2s upload time`, badExample: `RUN npm install RUN pip install -r requirements.txt RUN go mod download`, - goodExample: `RUN --mount=type=cache,target=/root/.npm \\ + goodExample: `RUN --mount=type=cache,target=/root/.npm \ npm install -RUN --mount=type=cache,target=/root/.cache/pip \\ +RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt -RUN --mount=type=cache,target=/go/pkg/mod \\ +RUN --mount=type=cache,target=/go/pkg/mod \ go mod download`, fix: 'Add --mount=type=cache,target= to RUN commands that download packages. Use correct cache paths for each package manager.' }, @@ -154,15 +154,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \\ severity: 'medium', category: 'Layer Caching', description: 'Running apt-get update in a separate layer from apt-get install causes caching issues. The update layer is cached, but package lists become stale.', - problem: 'Cached update layers mean subsequent builds use outdated package lists, potentially installing vulnerable or incorrect versions.', - impact: 'Stale package installations, potential security vulnerabilities, build failures', + problem: 'Cached update layers mean subsequent builds use outdated package lists, potentially installing vulnerable or incorrect versions. Also, omitting --no-install-recommends installs unnecessary packages, bloating the image and increasing attack surface.', + impact: 'Stale package installations, potential security vulnerabilities, build failures, 50-200MB larger images', badExample: `RUN apt-get update RUN apt-get install -y curl wget`, - goodExample: `RUN apt-get update && apt-get install -y \\ - curl \\ - wget \\ + goodExample: `RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + wget \ && rm -rf /var/lib/apt/lists/*`, - fix: 'Chain apt-get update && apt-get install in single RUN. Clean lists after install with rm -rf /var/lib/apt/lists/* to reduce image size.' + fix: 'Chain apt-get update && apt-get install in single RUN. Always use --no-install-recommends to skip unnecessary packages. Clean lists after install with rm -rf /var/lib/apt/lists/* to reduce image size.' }, { id: 'multiple-broad-copies', @@ -214,6 +214,22 @@ RUN npm ci --only=production COPY --from=builder /app/dist ./dist`, fix: 'Install dependencies once per stage. Use npm ci for production. In multi-stage builds, consider copying node_modules from builder if appropriate.' }, + { + id: 'missing-healthcheck', + title: 'Missing HEALTHCHECK Instruction', + severity: 'medium', + category: 'Production Readiness', + description: 'Production containers should tell orchestrators if they are healthy. Without HEALTHCHECK, Kubernetes and Docker cannot distinguish between a running process and a healthy, traffic-serving application.', + problem: 'Without HEALTHCHECK, container orchestrators have no way to know if your app is actually working. A stalled or deadlocked process appears "running" but serves no traffic. Rollouts become risky, auto-healing is impossible.', + impact: 'Unreliable rollouts, no auto-healing, silent failures in production, longer incident response times', + badExample: `FROM node:20-alpine +CMD ["node", "server.js"]`, + goodExample: `FROM node:20-alpine +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 +CMD ["node", "server.js"]`, + fix: 'Add a health endpoint to your application and configure HEALTHCHECK with appropriate intervals. Use --start-period to account for startup time. Keep the check lightweight and specific to actual app health, not just process existence.' + }, { id: 'add-instead-of-copy', title: 'ADD Instead of COPY', @@ -243,11 +259,11 @@ COPY . /app/`, impact: 'Security vulnerabilities, supply chain attacks, corrupted binaries in production', badExample: `RUN curl -L https://example.com/binary -o /usr/local/bin/tool RUN wget https://example.com/package.tar.gz && tar -xzf package.tar.gz`, - goodExample: `RUN curl -L https://example.com/binary -o /usr/local/bin/tool \\ + goodExample: `RUN curl -L https://example.com/binary -o /usr/local/bin/tool \ && echo "a3b5c7d9e1f2... /usr/local/bin/tool" | sha256sum -c - -RUN wget https://example.com/package.tar.gz \\ - && echo "f2e1d9c7b5a3... package.tar.gz" | sha256sum -c - \\ +RUN wget https://example.com/package.tar.gz \ + && echo "f2e1d9c7b5a3... package.tar.gz" | sha256sum -c - \ && tar -xzf package.tar.gz`, fix: 'Always verify downloads with sha256sum or sha512sum. Get checksums from official sources. Use && echo "CHECKSUM filename" | sha256sum -c - pattern.' } @@ -316,10 +332,10 @@ function Docs() { to="/" className="text-sm text-gray-600 hover:text-gray-900 transition-colors" > - ← Back to Home + ← Back to Home Code Examples - +
- ❌ Bad Practice + × Bad Practice
                         {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{},
 	}
 }