@@ -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 ( / C O P Y \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 ( / p i p 3 ? \s + i n s t a l l \s + (? ! .* - - n o - c a c h e ) [ ^ \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 ( / a p t - g e t \s + i n s t a l l \s + - y \s + (? ! .* - - n o - i n s t a l l - r e c o m m e n d s ) [ ^ \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 ( / R U N \s + c h o w n \s + - R \s + \S + \s + \/ a p p / 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 ( / n p m \s + c i (? ! \s + - - o m i t = d e v | \s + - - p r o d u c t i o n ) / i) ;
666+
667+ if ( npmCiMatch ) {
668+ // Check if npm prune is used
669+ const npmPruneMatch = dockerfile . match ( / n p m \s + p r u n e \s + - - p r o d u c t i o n / 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 : / n p x \s + p r i s m a \s + g e n e r a t e / gi, tool : 'Prisma CLI' , removeable : 'npx prisma generate is needed during build, but Prisma CLI can be removed after' } ,
712+ { pattern : / n p m \s + i n s t a l l \s + - g \s + t y p e s c r i p t / gi, tool : 'TypeScript' , removeable : 'TypeScript is dev-only tool' } ,
713+ { pattern : / n p m \s + i n s t a l l \s + - g \s + e s l i n t / 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 = / n p x \s + p r i s m a \s + g e n e r a t e / i. test ( dockerfile ) ;
726+ const hasPrismaRemove = / r m \s + .* n o d e _ m o d u l e s \/ p r i s m a / 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