Skip to content

Commit 0f2a942

Browse files
author
Dev Optimizer Bot
committed
feat: Add detailed Docker optimization checks
New Docker findings: - docker-010: COPY . . copies unnecessary files (200-500 MB) - docker-011: pip without --no-cache-dir (30-50 MB) - docker-012: apt-get without --no-install-recommends (50-150 MB) - docker-013: RUN chown instead of COPY --chown (30-50 MB) - docker-014: npm ci without --omit=dev (50-150 MB) - docker-015: Prisma CLI not removed (70 MB) - docker-016: User created after COPY (30-50 MB) All checks include: - Estimated size savings - Concrete fix suggestions - Auto-fixable flags
1 parent 459388d commit 0f2a942

2 files changed

Lines changed: 388 additions & 0 deletions

File tree

src/analyzers/DockerAnalyzer.ts

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,48 @@ export class DockerAnalyzer implements Analyzer {
204204
findings.push(workdirFinding);
205205
}
206206

207+
// Finding: COPY . . copies everything
208+
const copyAllFinding = this.checkCopyAll(dockerfile, hasDockerignore);
209+
if (copyAllFinding) {
210+
findings.push(copyAllFinding);
211+
}
212+
213+
// Finding: pip install without --no-cache-dir
214+
const pipCacheFinding = this.checkPipCache(dockerfile);
215+
if (pipCacheFinding) {
216+
findings.push(pipCacheFinding);
217+
}
218+
219+
// Finding: apt-get without --no-install-recommends
220+
const aptRecommendsFinding = this.checkAptRecommends(dockerfile);
221+
if (aptRecommendsFinding) {
222+
findings.push(aptRecommendsFinding);
223+
}
224+
225+
// Finding: Using RUN chown instead of COPY --chown
226+
const chownFinding = this.checkChownUsage(dockerfile);
227+
if (chownFinding) {
228+
findings.push(chownFinding);
229+
}
230+
231+
// Finding: Dev dependencies in production
232+
const devDepsFinding = this.checkDevDependencies(dockerfile);
233+
if (devDepsFinding) {
234+
findings.push(devDepsFinding);
235+
}
236+
237+
// Finding: CLI tools that should be removed
238+
const cliToolsFinding = this.checkCliTools(dockerfile);
239+
if (cliToolsFinding) {
240+
findings.push(cliToolsFinding);
241+
}
242+
243+
// Finding: User creation timing
244+
const userTimingFinding = this.checkUserCreation(dockerfile);
245+
if (userTimingFinding) {
246+
findings.push(userTimingFinding);
247+
}
248+
207249
// Finding: Hadolint (full mode)
208250
const hadolintFindings = await this.runHadolint(projectPath);
209251
findings.push(...hadolintFindings);
@@ -452,6 +494,323 @@ export class DockerAnalyzer implements Analyzer {
452494
return null;
453495
}
454496

497+
/**
498+
* Check for COPY . . (copies everything)
499+
*/
500+
private checkCopyAll(dockerfile: string, hasDockerignore: boolean): Finding | null {
501+
const copyAllMatch = dockerfile.match(/COPY\s+\.\s+\.\s*$/m);
502+
503+
if (copyAllMatch) {
504+
return {
505+
id: 'docker-010',
506+
domain: 'docker',
507+
title: 'COPY . . copies unnecessary files',
508+
description: 'COPY . . copies entire build context including .git, node_modules, tests, etc.',
509+
evidence: {
510+
file: 'Dockerfile',
511+
snippet: copyAllMatch[0].trim(),
512+
metrics: {
513+
estimatedWasteMB: hasDockerignore ? 100 : 500
514+
}
515+
},
516+
severity: hasDockerignore ? 'medium' : 'high',
517+
confidence: 'high',
518+
impact: {
519+
type: 'size',
520+
estimate: hasDockerignore ? 'Reduce image by 50-100 MB' : 'Reduce image by 200-500 MB',
521+
confidence: 'high'
522+
},
523+
suggestedFix: {
524+
type: 'modify',
525+
file: 'Dockerfile',
526+
description: hasDockerignore ?
527+
'Copy only needed directories: COPY src ./src' :
528+
'Create .dockerignore and copy only needed files',
529+
diff: hasDockerignore ?
530+
'- COPY . .\n+ COPY src ./src' :
531+
'# .dockerignore:\nnode_modules/\n.git/\ntests/\ncoverage/\n\n# Dockerfile:\nCOPY src ./src',
532+
autoFixable: false
533+
},
534+
autoFixSafe: false
535+
};
536+
}
537+
538+
return null;
539+
}
540+
541+
/**
542+
* Check for pip install without --no-cache-dir
543+
*/
544+
private checkPipCache(dockerfile: string): Finding | null {
545+
const pipMatch = dockerfile.match(/pip3?\s+install\s+(?!.*--no-cache)[^\n]*/gi);
546+
547+
if (pipMatch) {
548+
return {
549+
id: 'docker-011',
550+
domain: 'docker',
551+
title: 'pip install without --no-cache-dir',
552+
description: 'pip stores cache in image, increasing size by 30-50 MB.',
553+
evidence: {
554+
file: 'Dockerfile',
555+
snippet: pipMatch[0],
556+
metrics: {
557+
estimatedWasteMB: 40
558+
}
559+
},
560+
severity: 'medium',
561+
confidence: 'high',
562+
impact: {
563+
type: 'size',
564+
estimate: 'Reduce image size by 30-50 MB',
565+
confidence: 'high'
566+
},
567+
suggestedFix: {
568+
type: 'modify',
569+
file: 'Dockerfile',
570+
description: 'Add --no-cache-dir to pip install',
571+
diff: `- ${pipMatch[0]}\n+ ${pipMatch[0]} --no-cache-dir`,
572+
autoFixable: false
573+
},
574+
autoFixSafe: true
575+
};
576+
}
577+
578+
return null;
579+
}
580+
581+
/**
582+
* Check for apt-get without --no-install-recommends
583+
*/
584+
private checkAptRecommends(dockerfile: string): Finding | null {
585+
const aptMatch = dockerfile.match(/apt-get\s+install\s+-y\s+(?!.*--no-install-recommends)[^\n]*/gi);
586+
587+
if (aptMatch) {
588+
return {
589+
id: 'docker-012',
590+
domain: 'docker',
591+
title: 'apt-get without --no-install-recommends',
592+
description: 'apt-get installs recommended packages by default, increasing size by 50-150 MB.',
593+
evidence: {
594+
file: 'Dockerfile',
595+
snippet: aptMatch[0],
596+
metrics: {
597+
estimatedWasteMB: 100
598+
}
599+
},
600+
severity: 'medium',
601+
confidence: 'high',
602+
impact: {
603+
type: 'size',
604+
estimate: 'Reduce image size by 50-150 MB',
605+
confidence: 'high'
606+
},
607+
suggestedFix: {
608+
type: 'modify',
609+
file: 'Dockerfile',
610+
description: 'Add --no-install-recommends to apt-get install',
611+
diff: `- apt-get install -y ${aptMatch[0].split(' ').slice(3).join(' ')}\n+ apt-get install -y --no-install-recommends ${aptMatch[0].split(' ').slice(3).join(' ')}`,
612+
autoFixable: false
613+
},
614+
autoFixSafe: true
615+
};
616+
}
617+
618+
return null;
619+
}
620+
621+
/**
622+
* Check for RUN chown instead of COPY --chown
623+
*/
624+
private checkChownUsage(dockerfile: string): Finding | null {
625+
const chownMatch = dockerfile.match(/RUN\s+chown\s+-R\s+\S+\s+\/app/i);
626+
627+
if (chownMatch) {
628+
return {
629+
id: 'docker-013',
630+
domain: 'docker',
631+
title: 'Using RUN chown instead of COPY --chown',
632+
description: 'RUN chown creates an additional layer (~50 MB). Use COPY --chown instead.',
633+
evidence: {
634+
file: 'Dockerfile',
635+
snippet: chownMatch[0],
636+
metrics: {
637+
estimatedWasteMB: 50
638+
}
639+
},
640+
severity: 'medium',
641+
confidence: 'high',
642+
impact: {
643+
type: 'size',
644+
estimate: 'Reduce image size by 30-50 MB',
645+
confidence: 'high'
646+
},
647+
suggestedFix: {
648+
type: 'modify',
649+
file: 'Dockerfile',
650+
description: 'Use COPY --chown=user:group instead of RUN chown',
651+
diff: `- COPY . .\n- RUN chown -R node:node /app\n+ COPY --chown=node:node . .`,
652+
autoFixable: false
653+
},
654+
autoFixSafe: false
655+
};
656+
}
657+
658+
return null;
659+
}
660+
661+
/**
662+
* Check for npm ci --omit=dev or npm prune
663+
*/
664+
private checkDevDependencies(dockerfile: string): Finding | null {
665+
const npmCiMatch = dockerfile.match(/npm\s+ci(?!\s+--omit=dev|\s+--production)/i);
666+
667+
if (npmCiMatch) {
668+
// Check if npm prune is used
669+
const npmPruneMatch = dockerfile.match(/npm\s+prune\s+--production/i);
670+
671+
if (!npmPruneMatch) {
672+
return {
673+
id: 'docker-014',
674+
domain: 'docker',
675+
title: 'npm ci installs devDependencies in production',
676+
description: 'npm ci installs all dependencies including devDependencies. Use --omit=dev or npm prune.',
677+
evidence: {
678+
file: 'Dockerfile',
679+
snippet: npmCiMatch[0],
680+
metrics: {
681+
estimatedWasteMB: 100
682+
}
683+
},
684+
severity: 'medium',
685+
confidence: 'medium',
686+
impact: {
687+
type: 'size',
688+
estimate: 'Reduce image size by 50-150 MB',
689+
confidence: 'medium'
690+
},
691+
suggestedFix: {
692+
type: 'modify',
693+
file: 'Dockerfile',
694+
description: 'Use npm ci --omit=dev or add npm prune --production',
695+
diff: `- npm ci\n+ npm ci --omit=dev`,
696+
autoFixable: false
697+
},
698+
autoFixSafe: true
699+
};
700+
}
701+
}
702+
703+
return null;
704+
}
705+
706+
/**
707+
* Check for CLI tools that should be removed in production
708+
*/
709+
private checkCliTools(dockerfile: string): Finding | null {
710+
const cliPatterns = [
711+
{ pattern: /npx\s+prisma\s+generate/gi, tool: 'Prisma CLI', removeable: 'npx prisma generate is needed during build, but Prisma CLI can be removed after' },
712+
{ pattern: /npm\s+install\s+-g\s+typescript/gi, tool: 'TypeScript', removeable: 'TypeScript is dev-only tool' },
713+
{ pattern: /npm\s+install\s+-g\s+eslint/gi, tool: 'ESLint', removeable: 'ESLint is dev-only tool' },
714+
];
715+
716+
const foundTools: Array<{ tool: string; removeable: string }> = [];
717+
718+
for (const { pattern, tool, removeable } of cliPatterns) {
719+
if (pattern.test(dockerfile)) {
720+
foundTools.push({ tool, removeable });
721+
}
722+
}
723+
724+
// Check for Prisma specifically
725+
const hasPrismaGenerate = /npx\s+prisma\s+generate/i.test(dockerfile);
726+
const hasPrismaRemove = /rm\s+.*node_modules\/prisma/i.test(dockerfile);
727+
728+
if (hasPrismaGenerate && !hasPrismaRemove) {
729+
return {
730+
id: 'docker-015',
731+
domain: 'docker',
732+
title: 'Prisma CLI not removed from production image',
733+
description: 'Prisma CLI (~40 MB) and .prisma cache (~30 MB) remain in production image.',
734+
evidence: {
735+
file: 'Dockerfile',
736+
snippet: 'npx prisma generate',
737+
metrics: {
738+
estimatedWasteMB: 70
739+
}
740+
},
741+
severity: 'medium',
742+
confidence: 'high',
743+
impact: {
744+
type: 'size',
745+
estimate: 'Reduce image size by 70 MB',
746+
confidence: 'high'
747+
},
748+
suggestedFix: {
749+
type: 'modify',
750+
file: 'Dockerfile',
751+
description: 'Remove Prisma CLI and cache after generating client',
752+
diff: `- RUN npx prisma generate\n+ RUN npx prisma generate && rm -rf node_modules/prisma node_modules/.prisma`,
753+
autoFixable: false
754+
},
755+
autoFixSafe: false
756+
};
757+
}
758+
759+
return null;
760+
}
761+
762+
/**
763+
* Check for user creation timing (should be early for COPY --chown)
764+
*/
765+
private checkUserCreation(dockerfile: string): Finding | null {
766+
const lines = dockerfile.split('\n');
767+
let copyIndex = -1;
768+
let userAddIndex = -1;
769+
770+
for (let i = 0; i < lines.length; i++) {
771+
const line = lines[i].trim();
772+
if (line.startsWith('COPY') && copyIndex === -1) {
773+
copyIndex = i;
774+
}
775+
if ((line.includes('useradd') || line.includes('adduser')) && userAddIndex === -1) {
776+
userAddIndex = i;
777+
}
778+
}
779+
780+
// If COPY comes before user creation, can't use COPY --chown
781+
if (copyIndex !== -1 && userAddIndex !== -1 && copyIndex < userAddIndex) {
782+
return {
783+
id: 'docker-016',
784+
domain: 'docker',
785+
title: 'User created after COPY (cannot use --chown)',
786+
description: 'Create user before COPY to use COPY --chown and avoid chown layer.',
787+
evidence: {
788+
file: 'Dockerfile',
789+
metrics: {
790+
estimatedWasteMB: 50
791+
}
792+
},
793+
severity: 'low',
794+
confidence: 'high',
795+
impact: {
796+
type: 'size',
797+
estimate: 'Reduce image size by 30-50 MB',
798+
confidence: 'medium'
799+
},
800+
suggestedFix: {
801+
type: 'modify',
802+
file: 'Dockerfile',
803+
description: 'Move user creation before COPY and use COPY --chown',
804+
diff: `- COPY src ./src\n- RUN useradd appuser && chown -R appuser:appuser /app\n+ RUN useradd appuser\n+ COPY --chown=appuser:appuser src ./src`,
805+
autoFixable: false
806+
},
807+
autoFixSafe: false
808+
};
809+
}
810+
811+
return null;
812+
}
813+
455814
/**
456815
* Run hadolint for advanced Dockerfile linting
457816
* Full mode - requires hadolint binary

0 commit comments

Comments
 (0)