hadolint 1.18.1 → 1.18.2
raw patch · 15 files changed
+2155/−2073 lines, 15 filesdep +asyncdep +parallel
Dependencies added: async, parallel
Files
- app/Main.hs +97/−71
- hadolint.cabal +8/−4
- src/Hadolint.hs +5/−4
- src/Hadolint/Config.hs +59/−52
- src/Hadolint/Formatter/Checkstyle.hs +44/−43
- src/Hadolint/Formatter/Codacy.hs +26/−25
- src/Hadolint/Formatter/Codeclimate.hs +54/−50
- src/Hadolint/Formatter/Format.hs +36/−33
- src/Hadolint/Formatter/Json.hs +29/−28
- src/Hadolint/Formatter/TTY.hs +7/−6
- src/Hadolint/Lint.hs +39/−38
- src/Hadolint/Rules.hs +328/−314
- src/Hadolint/Shell.hs +79/−76
- test/ConfigSpec.hs +37/−37
- test/Spec.hs +1307/−1292
app/Main.hs view
@@ -1,6 +1,6 @@-{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE TemplateHaskell #-} module Main where @@ -8,22 +8,44 @@ import qualified Data.List.NonEmpty as NonEmpty import Data.Semigroup ((<>)) import qualified Data.Set as Set-import Data.String+import Data.String (IsString (fromString)) import qualified Data.Version import qualified Development.GitRev-import Options.Applicative hiding (ParseError)-import qualified Paths_hadolint -- version from hadolint.cabal file-import System.Exit (exitFailure, exitSuccess)- import qualified Hadolint+import Options.Applicative+ ( Parser,+ action,+ argument,+ completeWith,+ execParser,+ fullDesc,+ header,+ help,+ helper,+ info,+ long,+ maybeReader,+ metavar,+ option,+ progDesc,+ short,+ showDefaultWith,+ str,+ strOption,+ switch,+ value,+ )+-- version from hadolint.cabal file+import qualified Paths_hadolint as Meta+import System.Exit (exitFailure, exitSuccess) data CommandOptions = CommandOptions- { showVersion :: Bool- , configFile :: Maybe FilePath- , format :: Hadolint.OutputFormat- , dockerfiles :: [String]- , lintingOptions :: Hadolint.LintOptions- }+ { showVersion :: Bool,+ configFile :: Maybe FilePath,+ format :: Hadolint.OutputFormat,+ dockerfiles :: [String],+ lintingOptions :: Hadolint.LintOptions+ } toOutputFormat :: String -> Maybe Hadolint.OutputFormat toOutputFormat "json" = Just Hadolint.Json@@ -42,81 +64,85 @@ parseOptions :: Parser CommandOptions parseOptions =- CommandOptions <$> -- CLI options parser definition- version <*>- configFile <*>- outputFormat <*>- files <*>- lintOptions+ CommandOptions+ <$> version -- CLI options parser definition+ <*> configFile+ <*> outputFormat+ <*> files+ <*> lintOptions where version = switch (long "version" <> short 'v' <> help "Show version")- --- -- | Parse the config filename to use+ configFile =- optional- (strOption- (long "config" <> short 'c' <> metavar "FILENAME" <>- help "Path to the configuration file"))- --- -- | Parse the output format option+ optional+ ( strOption+ ( long "config" <> short 'c' <> metavar "FILENAME"+ <> help "Path to the configuration file"+ )+ )+ outputFormat =- option- (maybeReader toOutputFormat)- (long "format" <> -- options for the output format- short 'f' <>- help- "The output format for the results [tty | json | checkstyle | codeclimate | codacy]" <>- value Hadolint.TTY <> -- The default value- showDefaultWith showFormat <>- completeWith ["tty", "json", "checkstyle", "codeclimate", "codacy"])- --- -- | Parse a list of ignored rules+ option+ (maybeReader toOutputFormat)+ ( long "format"+ <> short 'f' -- options for the output format+ <> help+ "The output format for the results [tty | json | checkstyle | codeclimate | codacy]"+ <> value Hadolint.TTY+ <> showDefaultWith showFormat -- The default value+ <> completeWith ["tty", "json", "checkstyle", "codeclimate", "codacy"]+ )+ ignoreList =- many- (strOption- (long "ignore" <>- help "A rule to ignore. If present, the ignore list in the config file is ignored" <>- metavar "RULECODE"))- --- -- | Parse a list of dockerfile names+ many+ ( strOption+ ( long "ignore"+ <> help "A rule to ignore. If present, the ignore list in the config file is ignored"+ <> metavar "RULECODE"+ )+ )+ files = many (argument str (metavar "DOCKERFILE..." <> action "file"))- --- -- | Parse the rule ignore list and the rules configuration into a LintOptions+ lintOptions = Hadolint.LintOptions <$> ignoreList <*> parseRulesConfig- --- -- | Parse all the optional rules configuration+ parseRulesConfig =- Hadolint.RulesConfig . Set.fromList . fmap fromString <$>- many- (strOption- (long "trusted-registry" <>- help "A docker registry to allow to appear in FROM instructions" <>- metavar "REGISTRY (e.g. docker.io)"))+ Hadolint.RulesConfig . Set.fromList . fmap fromString+ <$> many+ ( strOption+ ( long "trusted-registry"+ <> help "A docker registry to allow to appear in FROM instructions"+ <> metavar "REGISTRY (e.g. docker.io)"+ )+ ) main :: IO () main = do- cmd <- execParser opts- execute cmd+ cmd <- execParser opts+ execute cmd where execute CommandOptions {showVersion = True} = putStrLn getVersion >> exitSuccess execute CommandOptions {dockerfiles = []} =- putStrLn "Please provide a Dockerfile" >> exitFailure+ putStrLn "Please provide a Dockerfile" >> exitFailure execute cmd = do- lintConfig <- Hadolint.applyConfig (configFile cmd) (lintingOptions cmd)- let files = NonEmpty.fromList (dockerfiles cmd)- case lintConfig of- Left err -> error err- Right conf -> do- res <- Hadolint.lint conf files- Hadolint.printResultsAndExit (format cmd) res+ lintConfig <- Hadolint.applyConfig (configFile cmd) (lintingOptions cmd)+ let files = NonEmpty.fromList (dockerfiles cmd)+ case lintConfig of+ Left err -> error err+ Right conf -> do+ res <- Hadolint.lint conf files+ Hadolint.printResultsAndExit (format cmd) res opts =- info- (helper <*> parseOptions)- (fullDesc <> progDesc "Lint Dockerfile for errors and best practices" <>- header "hadolint - Dockerfile Linter written in Haskell")+ info+ (helper <*> parseOptions)+ ( fullDesc <> progDesc "Lint Dockerfile for errors and best practices"+ <> header "hadolint - Dockerfile Linter written in Haskell"+ ) getVersion :: String getVersion- | $(Development.GitRev.gitDescribe) == "UNKNOWN" =- "Haskell Dockerfile Linter " ++ Data.Version.showVersion Paths_hadolint.version ++ "-no-git"- | otherwise = "Haskell Dockerfile Linter " ++ $(Development.GitRev.gitDescribe)+ | version == "UNKNOWN" =+ "Haskell Dockerfile Linter " ++ Data.Version.showVersion Meta.version ++ "-no-git"+ | otherwise = "Haskell Dockerfile Linter " ++ version+ where+ version = $(Development.GitRev.gitDescribe)
hadolint.cabal view
@@ -1,13 +1,13 @@-cabal-version: 1.12+cabal-version: 2.0 -- This file has been generated from package.yaml by hpack version 0.33.0. -- -- see: https://github.com/sol/hpack ----- hash: f2454ac92e402e2007f598a079c8998996473f8fc6501b81e3eb1f9cd98d5f56+-- hash: 418683858197c94a7d90196b639708548b3c5966bc20506c43b2383a836a281b name: hadolint-version: 1.18.1+version: 1.18.2 synopsis: Dockerfile Linter JavaScript API description: A smarter Dockerfile linter that helps you build best practice Docker images. category: Development@@ -45,6 +45,8 @@ Hadolint.Shell other-modules: Paths_hadolint+ autogen-modules:+ Paths_hadolint hs-source-dirs: src ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path@@ -52,6 +54,7 @@ HsYAML , ShellCheck >=0.7.1 , aeson+ , async , base >=4.8 && <5 , bytestring , containers@@ -60,6 +63,7 @@ , language-docker >=9.1.2 && <10 , megaparsec >=7.0 , mtl+ , parallel , split >=0.2 , text , void@@ -71,7 +75,7 @@ Paths_hadolint hs-source-dirs: app- ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path+ ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path -O2 -threaded -rtsopts "-with-rtsopts=-N5 -A4m" build-depends: base >=4.8 && <5 , containers
src/Hadolint.hs view
@@ -1,8 +1,9 @@ module Hadolint- ( module Hadolint.Lint- , module Hadolint.Rules- , module Hadolint.Config- ) where+ ( module Hadolint.Lint,+ module Hadolint.Rules,+ module Hadolint.Config,+ )+where import Hadolint.Config import Hadolint.Lint
src/Hadolint/Config.hs view
@@ -1,35 +1,39 @@ {-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-} -module Hadolint.Config (applyConfig, ConfigFile(..)) where+module Hadolint.Config (applyConfig, ConfigFile (..)) where import Control.Monad (filterM)+import qualified Data.ByteString as Bytes import Data.Coerce (coerce) import Data.Maybe (fromMaybe, listToMaybe)-import qualified Data.ByteString as Bytes import qualified Data.Set as Set-import qualified Data.YAML as Yaml import Data.YAML ((.:?))-import GHC.Generics+import qualified Data.YAML as Yaml+import GHC.Generics (Generic)+import qualified Hadolint.Lint as Lint+import qualified Hadolint.Rules as Rules import qualified Language.Docker as Docker import System.Directory- (XdgDirectory(..), doesFileExist, getCurrentDirectory,- getXdgDirectory)+ ( XdgDirectory (..),+ doesFileExist,+ getCurrentDirectory,+ getXdgDirectory,+ ) import System.FilePath ((</>)) -import qualified Hadolint.Lint as Lint-import qualified Hadolint.Rules as Rules- data ConfigFile = ConfigFile- { ignoredRules :: Maybe [Lint.IgnoreRule]- , trustedRegistries :: Maybe [Lint.TrustedRegistry]- } deriving (Show, Eq, Generic)+ { ignoredRules :: Maybe [Lint.IgnoreRule],+ trustedRegistries :: Maybe [Lint.TrustedRegistry]+ }+ deriving (Show, Eq, Generic) instance Yaml.FromYAML ConfigFile where- parseYAML = Yaml.withMap "ConfigFile" $ \m -> ConfigFile- <$> m .:? "ignored"- <*> m .:? "trustedRegistries"+ parseYAML = Yaml.withMap "ConfigFile" $ \m ->+ ConfigFile+ <$> m .:? "ignored"+ <*> m .:? "trustedRegistries" -- | If both the ignoreRules and rulesConfig properties of Lint options are empty -- then this function will fill them with the default found in the passed config@@ -37,50 +41,53 @@ -- return the error string. applyConfig :: Maybe FilePath -> Lint.LintOptions -> IO (Either String Lint.LintOptions) applyConfig maybeConfig o- | not (null (Lint.ignoreRules o)) && Lint.rulesConfig o /= mempty = return (Right o)- | otherwise = do- theConfig <-- case maybeConfig of- Nothing -> findConfig- c -> return c- case theConfig of- Nothing -> return (Right o)- Just config -> parseAndApply config+ | not (null (Lint.ignoreRules o)) && Lint.rulesConfig o /= mempty = return (Right o)+ | otherwise = do+ theConfig <-+ case maybeConfig of+ Nothing -> findConfig+ c -> return c+ case theConfig of+ Nothing -> return (Right o)+ Just config -> parseAndApply config where findConfig = do- localConfigFile <- (</> ".hadolint.yaml") <$> getCurrentDirectory- configFile <- getXdgDirectory XdgConfig "hadolint.yaml"- listToMaybe <$> filterM doesFileExist [localConfigFile, configFile]+ localConfigFile <- (</> ".hadolint.yaml") <$> getCurrentDirectory+ configFile <- getXdgDirectory XdgConfig "hadolint.yaml"+ listToMaybe <$> filterM doesFileExist [localConfigFile, configFile]+ parseAndApply :: FilePath -> IO (Either String Lint.LintOptions) parseAndApply configFile = do- contents <- Bytes.readFile configFile- case Yaml.decode1Strict contents of- Left (_, err) -> return $ Left (formatError err configFile)- Right (ConfigFile ignore trusted) -> return (Right (override ignore trusted))- -- | Applies the configuration found in the file to the passed Lint.LintOptions+ contents <- Bytes.readFile configFile+ case Yaml.decode1Strict contents of+ Left (_, err) -> return $ Left (formatError err configFile)+ Right (ConfigFile ignore trusted) -> return (Right (override ignore trusted))+ override ignore trusted = applyTrusted trusted . applyIgnore ignore $ o applyIgnore ignore opts =- case Lint.ignoreRules opts of- [] -> opts {Lint.ignoreRules = fromMaybe [] ignore}- _ -> opts+ case Lint.ignoreRules opts of+ [] -> opts {Lint.ignoreRules = fromMaybe [] ignore}+ _ -> opts+ applyTrusted trusted opts- | null (Rules.allowedRegistries (Lint.rulesConfig opts)) =- opts {Lint.rulesConfig = toRules trusted <> Lint.rulesConfig opts}- | otherwise = opts- -- | Converts a list of TrustedRegistry to a RulesConfig record+ | null (Rules.allowedRegistries (Lint.rulesConfig opts)) =+ opts {Lint.rulesConfig = toRules trusted <> Lint.rulesConfig opts}+ | otherwise = opts+ toRules (Just trusted) = Rules.RulesConfig (Set.fromList . coerce $ trusted) toRules _ = mempty+ formatError err config = unlines- [ "Error parsing your config file in '" ++ config ++ "':"- , "It should contain one of the keys 'ignored' or 'trustedRegistries'. For example:\n"- , "ignored:"- , "\t- DL3000"- , "\t- SC1099\n\n"- , "The key 'trustedRegistries' should contain the names of the allowed docker registries:\n"- , "allowedRegistries:"- , "\t- docker.io"- , "\t- my-company.com"- , ""- , err- ]+ [ "Error parsing your config file in '" ++ config ++ "':",+ "It should contain one of the keys 'ignored' or 'trustedRegistries'. For example:\n",+ "ignored:",+ "\t- DL3000",+ "\t- SC1099\n\n",+ "The key 'trustedRegistries' should contain the names of the allowed docker registries:\n",+ "allowedRegistries:",+ "\t- docker.io",+ "\t- my-company.com",+ "",+ err+ ]
src/Hadolint/Formatter/Checkstyle.hs view
@@ -1,74 +1,75 @@-{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Hadolint.Formatter.Checkstyle- ( printResult- , formatResult- ) where+ ( printResult,+ formatResult,+ )+where import qualified Data.ByteString.Builder as Builder import qualified Data.ByteString.Lazy.Char8 as B import Data.Char import Data.Foldable (toList) import Data.List (groupBy)-import Data.Monoid ((<>), mconcat)+import Data.Monoid (mconcat, (<>)) import qualified Data.Text as Text import Hadolint.Formatter.Format-import Hadolint.Rules (Metadata(..), RuleCheck(..))+import Hadolint.Rules (Metadata (..), RuleCheck (..)) import ShellCheck.Interface import Text.Megaparsec (Stream) import Text.Megaparsec.Error import Text.Megaparsec.Pos (sourceColumn, sourceLine, sourceName, unPos) data CheckStyle = CheckStyle- { file :: String- , line :: Int- , column :: Int- , impact :: String- , msg :: String- , source :: String- }+ { file :: String,+ line :: Int,+ column :: Int,+ impact :: String,+ msg :: String,+ source :: String+ } errorToCheckStyle :: (Stream s, ShowErrorComponent e) => ParseErrorBundle s e -> CheckStyle errorToCheckStyle err =- CheckStyle- { file = sourceName pos- , line = unPos (sourceLine pos)- , column = unPos (sourceColumn pos)- , impact = severityText ErrorC- , msg = errorBundlePretty err- , source = "DL1000"- }+ CheckStyle+ { file = sourceName pos,+ line = unPos (sourceLine pos),+ column = unPos (sourceColumn pos),+ impact = severityText ErrorC,+ msg = errorBundlePretty err,+ source = "DL1000"+ } where pos = errorPosition err ruleToCheckStyle :: RuleCheck -> CheckStyle ruleToCheckStyle RuleCheck {..} =- CheckStyle- { file = Text.unpack filename- , line = linenumber- , column = 1- , impact = severityText (severity metadata)- , msg = Text.unpack (message metadata)- , source = Text.unpack (code metadata)- }+ CheckStyle+ { file = Text.unpack filename,+ line = linenumber,+ column = 1,+ impact = severityText (severity metadata),+ msg = Text.unpack (message metadata),+ source = Text.unpack (code metadata)+ } toXml :: [CheckStyle] -> Builder.Builder toXml checks = wrap fileName (foldMap convert checks) where wrap name innerNode = "<file " <> attr "name" name <> ">" <> innerNode <> "</file>" convert CheckStyle {..} =- "<error " <> -- Beging the node construction- attr "line" (show line) <>- attr "column" (show column) <>- attr "severity" impact <>- attr "message" msg <>- attr "source" source <>- "/>"+ "<error "+ <> attr "line" (show line) -- Beging the node construction+ <> attr "column" (show column)+ <> attr "severity" impact+ <> attr "message" msg+ <> attr "source" source+ <> "/>" fileName =- case checks of- [] -> ""- h:_ -> file h+ case checks of+ [] -> ""+ h : _ -> file h attr :: String -> String -> Builder.Builder attr name value = Builder.string8 name <> "='" <> Builder.string8 (escape value) <> "' "@@ -77,14 +78,14 @@ escape = concatMap doEscape where doEscape c =- if isOk c- then [c]- else "&#" ++ show (ord c) ++ ";"+ if isOk c+ then [c]+ else "&#" ++ show (ord c) ++ ";" isOk x = any (\check -> check x) [isAsciiUpper, isAsciiLower, isDigit, (`elem` [' ', '.', '/'])] formatResult :: (Stream s, ShowErrorComponent e) => Result s e -> Builder.Builder formatResult (Result errors checks) =- "<?xml version='1.0' encoding='UTF-8'?><checkstyle version='4.3'>" <> xmlBody <> "</checkstyle>"+ "<?xml version='1.0' encoding='UTF-8'?><checkstyle version='4.3'>" <> xmlBody <> "</checkstyle>" where xmlBody = mconcat xmlChunks xmlChunks = fmap toXml (groupBy sameFileName flatten)
src/Hadolint/Formatter/Codacy.hs view
@@ -1,53 +1,54 @@-{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Hadolint.Formatter.Codacy- ( printResult- , formatResult- ) where+ ( printResult,+ formatResult,+ )+where import Data.Aeson hiding (Result) import qualified Data.ByteString.Lazy.Char8 as B import Data.Monoid ((<>)) import Data.Sequence (Seq) import qualified Data.Text as Text-import Hadolint.Formatter.Format (Result(..), errorPosition)-import Hadolint.Rules (Metadata(..), RuleCheck(..))+import Hadolint.Formatter.Format (Result (..), errorPosition)+import Hadolint.Rules (Metadata (..), RuleCheck (..)) import Text.Megaparsec (Stream) import Text.Megaparsec.Error import Text.Megaparsec.Pos (sourceLine, sourceName, unPos) data Issue = Issue- { filename :: String- , msg :: String- , patternId :: String- , line :: Int- }+ { filename :: String,+ msg :: String,+ patternId :: String,+ line :: Int+ } instance ToJSON Issue where- toJSON Issue {..} =- object ["filename" .= filename, "patternId" .= patternId, "message" .= msg, "line" .= line]+ toJSON Issue {..} =+ object ["filename" .= filename, "patternId" .= patternId, "message" .= msg, "line" .= line] errorToIssue :: (Stream s, ShowErrorComponent e) => ParseErrorBundle s e -> Issue errorToIssue err =- Issue- { filename = sourceName pos- , patternId = "DL1000"- , msg = errorBundlePretty err- , line = linenumber- }+ Issue+ { filename = sourceName pos,+ patternId = "DL1000",+ msg = errorBundlePretty err,+ line = linenumber+ } where pos = errorPosition err linenumber = unPos (sourceLine pos) checkToIssue :: RuleCheck -> Issue checkToIssue RuleCheck {..} =- Issue- { filename = Text.unpack filename- , patternId = Text.unpack (code metadata)- , msg = Text.unpack (message metadata)- , line = linenumber- }+ Issue+ { filename = Text.unpack filename,+ patternId = Text.unpack (code metadata),+ msg = Text.unpack (message metadata),+ line = linenumber+ } formatResult :: (Stream s, ShowErrorComponent e) => Result s e -> Seq Issue formatResult (Result errors checks) = allIssues
src/Hadolint/Formatter/Codeclimate.hs view
@@ -1,11 +1,12 @@-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Hadolint.Formatter.Codeclimate- ( printResult- , formatResult- ) where+ ( printResult,+ formatResult,+ )+where import Data.Aeson hiding (Result) import qualified Data.ByteString.Lazy as B@@ -13,57 +14,60 @@ import Data.Sequence (Seq) import qualified Data.Text as Text import GHC.Generics-import Hadolint.Formatter.Format (Result(..), errorPosition)-import Hadolint.Rules (Metadata(..), RuleCheck(..))+import Hadolint.Formatter.Format (Result (..), errorPosition)+import Hadolint.Rules (Metadata (..), RuleCheck (..)) import ShellCheck.Interface import Text.Megaparsec (Stream) import Text.Megaparsec.Error import Text.Megaparsec.Pos (sourceColumn, sourceLine, sourceName, unPos) data Issue = Issue- { checkName :: String- , description :: String- , location :: Location- , impact :: String- }+ { checkName :: String,+ description :: String,+ location :: Location,+ impact :: String+ } data Location- = LocLine String- Int- | LocPos String- Pos+ = LocLine+ String+ Int+ | LocPos+ String+ Pos instance ToJSON Location where- toJSON (LocLine path l) = object ["path" .= path, "lines" .= object ["begin" .= l, "end" .= l]]- toJSON (LocPos path pos) =- object ["path" .= path, "positions" .= object ["begin" .= pos, "end" .= pos]]+ toJSON (LocLine path l) = object ["path" .= path, "lines" .= object ["begin" .= l, "end" .= l]]+ toJSON (LocPos path pos) =+ object ["path" .= path, "positions" .= object ["begin" .= pos, "end" .= pos]] data Pos = Pos- { line :: Int- , column :: Int- } deriving (Generic)+ { line :: Int,+ column :: Int+ }+ deriving (Generic) instance ToJSON Pos instance ToJSON Issue where- toJSON Issue {..} =- object- [ "type" .= ("issue" :: String)- , "check_name" .= checkName- , "description" .= description- , "categories" .= (["Bug Risk"] :: [String])- , "location" .= location- , "severity" .= impact- ]+ toJSON Issue {..} =+ object+ [ "type" .= ("issue" :: String),+ "check_name" .= checkName,+ "description" .= description,+ "categories" .= (["Bug Risk"] :: [String]),+ "location" .= location,+ "severity" .= impact+ ] errorToIssue :: (Stream s, ShowErrorComponent e) => ParseErrorBundle s e -> Issue errorToIssue err =- Issue- { checkName = "DL1000"- , description = errorBundlePretty err- , location = LocPos (sourceName pos) Pos {..}- , impact = severityText ErrorC- }+ Issue+ { checkName = "DL1000",+ description = errorBundlePretty err,+ location = LocPos (sourceName pos) Pos {..},+ impact = severityText ErrorC+ } where pos = errorPosition err line = unPos (sourceLine pos)@@ -71,20 +75,20 @@ checkToIssue :: RuleCheck -> Issue checkToIssue RuleCheck {..} =- Issue- { checkName = Text.unpack (code metadata)- , description = Text.unpack (message metadata)- , location = LocLine (Text.unpack filename) linenumber- , impact = severityText (severity metadata)- }+ Issue+ { checkName = Text.unpack (code metadata),+ description = Text.unpack (message metadata),+ location = LocLine (Text.unpack filename) linenumber,+ impact = severityText (severity metadata)+ } severityText :: Severity -> String severityText severity =- case severity of- ErrorC -> "blocker"- WarningC -> "major"- InfoC -> "info"- StyleC -> "minor"+ case severity of+ ErrorC -> "blocker"+ WarningC -> "major"+ InfoC -> "info"+ StyleC -> "minor" formatResult :: (Stream s, ShowErrorComponent e) => Result s e -> Seq Issue formatResult (Result errors checks) = allIssues@@ -97,5 +101,5 @@ printResult result = mapM_ output (formatResult result) where output value = do- B.putStr (encode value)- B.putStr (B.singleton 0x00)+ B.putStr (encode value)+ B.putStr (B.singleton 0x00)
src/Hadolint/Formatter/Format.hs view
@@ -1,38 +1,39 @@ module Hadolint.Formatter.Format- ( severityText- , stripNewlines- , errorMessageLine- , errorPosition- , errorPositionPretty- , Text.Megaparsec.Error.errorBundlePretty- , Result(..)- , isEmpty- , toResult- ) where+ ( severityText,+ stripNewlines,+ errorMessageLine,+ errorPosition,+ errorPositionPretty,+ Text.Megaparsec.Error.errorBundlePretty,+ Result (..),+ isEmpty,+ toResult,+ )+where import Data.List (sort) import qualified Data.List.NonEmpty as NE import Data.Monoid (Monoid) import Data.Semigroup-import qualified Data.Sequence as Seq import Data.Sequence (Seq)+import qualified Data.Sequence as Seq import Hadolint.Rules import ShellCheck.Interface-import Text.Megaparsec (Stream(..), pstateSourcePos)+import Text.Megaparsec (Stream (..), pstateSourcePos) import Text.Megaparsec.Error import Text.Megaparsec.Pos (SourcePos, sourcePosPretty) data Result s e = Result- { errors :: Seq (ParseErrorBundle s e)- , checks :: Seq RuleCheck- }+ { errors :: !(Seq (ParseErrorBundle s e)),+ checks :: !(Seq RuleCheck)+ } instance Semigroup (Result s e) where- (Result e1 c1) <> (Result e2 c2) = Result (e1 <> e2) (c1 <> c2)+ (Result e1 c1) <> (Result e2 c2) = Result (e1 <> e2) (c1 <> c2) instance Monoid (Result s e) where- mappend = (<>)- mempty = Result mempty mempty+ mappend = (<>)+ mempty = Result mempty mempty isEmpty :: Result s e -> Bool isEmpty (Result Seq.Empty Seq.Empty) = True@@ -40,33 +41,35 @@ toResult :: Either (ParseErrorBundle s e) [RuleCheck] -> Result s e toResult res =- case res of- Left err -> Result (Seq.singleton err) mempty- Right c -> Result mempty (Seq.fromList (sort c))+ case res of+ Left err -> Result (Seq.singleton err) mempty+ Right c -> Result mempty (Seq.fromList (sort c)) severityText :: Severity -> String severityText s =- case s of- ErrorC -> "error"- WarningC -> "warning"- InfoC -> "info"- StyleC -> "style"+ case s of+ ErrorC -> "error"+ WarningC -> "warning"+ InfoC -> "info"+ StyleC -> "style" stripNewlines :: String -> String stripNewlines =- map (\c ->- if c == '\n'- then ' '- else c)+ map+ ( \c ->+ if c == '\n'+ then ' '+ else c+ ) errorMessageLine :: (Stream s, ShowErrorComponent e) => ParseErrorBundle s e -> String errorMessageLine err@(ParseErrorBundle e _) =- errorPositionPretty err ++ " " ++ parseErrorTextPretty (NE.head e)+ errorPositionPretty err ++ " " ++ parseErrorTextPretty (NE.head e) errorPositionPretty :: Stream s => ParseErrorBundle s e -> String errorPositionPretty err = sourcePosPretty (errorPosition err) errorPosition :: Stream s => ParseErrorBundle s e -> Text.Megaparsec.Pos.SourcePos errorPosition (ParseErrorBundle e s) =- let (_, posState) = reachOffset (errorOffset (NE.head e)) s- in pstateSourcePos posState+ let (_, posState) = reachOffset (errorOffset (NE.head e)) s+ in pstateSourcePos posState
src/Hadolint/Formatter/Json.hs view
@@ -1,46 +1,47 @@-{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Hadolint.Formatter.Json- ( printResult- , formatResult- ) where+ ( printResult,+ formatResult,+ )+where import Data.Aeson hiding (Result) import qualified Data.ByteString.Lazy.Char8 as B import Data.Monoid ((<>))-import Hadolint.Formatter.Format (Result(..), errorPosition, severityText)-import Hadolint.Rules (Metadata(..), RuleCheck(..))+import Hadolint.Formatter.Format (Result (..), errorPosition, severityText)+import Hadolint.Rules (Metadata (..), RuleCheck (..)) import ShellCheck.Interface import Text.Megaparsec (Stream) import Text.Megaparsec.Error import Text.Megaparsec.Pos (sourceColumn, sourceLine, sourceName, unPos) data JsonFormat s e- = JsonCheck RuleCheck- | JsonParseError (ParseErrorBundle s e)+ = JsonCheck RuleCheck+ | JsonParseError (ParseErrorBundle s e) instance (Stream s, ShowErrorComponent e) => ToJSON (JsonFormat s e) where- toJSON (JsonCheck RuleCheck {..}) =- object- [ "file" .= filename- , "line" .= linenumber- , "column" .= (1 :: Int)- , "level" .= severityText (severity metadata)- , "code" .= code metadata- , "message" .= message metadata- ]- toJSON (JsonParseError err) =- object- [ "file" .= sourceName pos- , "line" .= unPos (sourceLine pos)- , "column" .= unPos (sourceColumn pos)- , "level" .= severityText ErrorC- , "code" .= ("DL1000" :: String)- , "message" .= errorBundlePretty err- ]- where- pos = errorPosition err+ toJSON (JsonCheck RuleCheck {..}) =+ object+ [ "file" .= filename,+ "line" .= linenumber,+ "column" .= (1 :: Int),+ "level" .= severityText (severity metadata),+ "code" .= code metadata,+ "message" .= message metadata+ ]+ toJSON (JsonParseError err) =+ object+ [ "file" .= sourceName pos,+ "line" .= unPos (sourceLine pos),+ "column" .= unPos (sourceColumn pos),+ "level" .= severityText ErrorC,+ "code" .= ("DL1000" :: String),+ "message" .= errorBundlePretty err+ ]+ where+ pos = errorPosition err formatResult :: (Stream s, ShowErrorComponent e) => Result s e -> Value formatResult (Result errors checks) = toJSON allMessages
src/Hadolint/Formatter/TTY.hs view
@@ -2,17 +2,18 @@ {-# LANGUAGE OverloadedStrings #-} module Hadolint.Formatter.TTY- ( printResult- , formatError- , formatChecks- ) where+ ( printResult,+ formatError,+ formatChecks,+ )+where import Data.Semigroup ((<>)) import qualified Data.Text as Text import Hadolint.Formatter.Format import Hadolint.Rules import Language.Docker.Syntax-import Text.Megaparsec (Stream(..))+import Text.Megaparsec (Stream (..)) import Text.Megaparsec.Error formatErrors :: (Stream s, ShowErrorComponent e, Functor f) => f (ParseErrorBundle s e) -> f String@@ -25,7 +26,7 @@ formatChecks = fmap formatCheck where formatCheck (RuleCheck meta source line _) =- formatPos source line <> code meta <> " " <> message meta+ formatPos source line <> code meta <> " " <> message meta formatPos :: Filename -> Linenumber -> Text.Text formatPos source line = source <> ":" <> Text.pack (show line) <> " "
src/Hadolint/Lint.hs view
@@ -2,13 +2,11 @@ module Hadolint.Lint where +import qualified Control.Concurrent.Async as Async+import Control.Parallel.Strategies (parListChunk, rseq, using) import qualified Data.List.NonEmpty as NonEmpty import Data.Text (Text)-import qualified Language.Docker as Docker-import Language.Docker.Parser (DockerfileError, Error)-import Language.Docker.Syntax (Dockerfile)-import System.Exit (exitFailure, exitSuccess)-+import GHC.Conc (numCapabilities) import qualified Hadolint.Formatter.Checkstyle as Checkstyle import qualified Hadolint.Formatter.Codacy as Codacy import qualified Hadolint.Formatter.Codeclimate as Codeclimate@@ -16,64 +14,67 @@ import qualified Hadolint.Formatter.Json as Json import qualified Hadolint.Formatter.TTY as TTY import qualified Hadolint.Rules as Rules+import qualified Language.Docker as Docker+import Language.Docker.Parser (DockerfileError, Error)+import Language.Docker.Syntax (Dockerfile)+import System.Exit (exitFailure, exitSuccess) type IgnoreRule = Text type TrustedRegistry = Text data LintOptions = LintOptions- { ignoreRules :: [IgnoreRule]- , rulesConfig :: Rules.RulesConfig- } deriving (Show)+ { ignoreRules :: [IgnoreRule],+ rulesConfig :: Rules.RulesConfig+ }+ deriving (Show) data OutputFormat- = Json- | TTY- | CodeclimateJson- | Checkstyle- | Codacy- deriving (Show, Eq)+ = Json+ | TTY+ | CodeclimateJson+ | Checkstyle+ | Codacy+ deriving (Show, Eq) printResultsAndExit :: OutputFormat -> Format.Result Text DockerfileError -> IO () printResultsAndExit format allResults = do- printResult allResults- if not . Format.isEmpty $ allResults- then exitFailure- else exitSuccess+ printResult allResults+ if not . Format.isEmpty $ allResults+ then exitFailure+ else exitSuccess where printResult res =- case format of- TTY -> TTY.printResult res- Json -> Json.printResult res- Checkstyle -> Checkstyle.printResult res- CodeclimateJson -> Codeclimate.printResult res >> exitSuccess- Codacy -> Codacy.printResult res >> exitSuccess+ case format of+ TTY -> TTY.printResult res+ Json -> Json.printResult res+ Checkstyle -> Checkstyle.printResult res+ CodeclimateJson -> Codeclimate.printResult res >> exitSuccess+ Codacy -> Codacy.printResult res >> exitSuccess -- | Performs the process of parsing the dockerfile and analyzing it with all the applicable -- rules, depending on the list of ignored rules. -- Depending on the preferred printing format, it will output the results to stdout lint :: LintOptions -> NonEmpty.NonEmpty String -> IO (Format.Result Text DockerfileError) lint LintOptions {ignoreRules = ignoreList, rulesConfig} dFiles = do- processedFiles <- mapM (lintDockerfile ignoreList) (NonEmpty.toList dFiles)- return (results processedFiles)+ parsedFiles <- Async.mapConcurrently parseFile (NonEmpty.toList dFiles)+ let results = lintAll parsedFiles `using` parListChunk (div numCapabilities 2) rseq+ return $ mconcat results where- results = foldMap Format.toResult -- Parse and check rules for each dockerfile,- -- then convert them to a Result and combine with- -- the result of the previous dockerfile results- lintDockerfile ignoreRules dockerFile = do- ast <- parseFilename dockerFile- return (processedFile ast)+ parseFile :: String -> IO (Either Error Dockerfile)+ parseFile "-" = Docker.parseStdin+ parseFile s = Docker.parseFile s++ lintAll = fmap (lintDockerfile ignoreList)++ lintDockerfile ignoreRules ast = processedFile ast where- processedFile = fmap processRules+ processedFile = Format.toResult . fmap processRules processRules fileLines = filter ignoredRules (analyzeAll rulesConfig fileLines) ignoredRules = ignoreFilter ignoreRules- -- | Returns true if the rule should be ignored+ ignoreFilter :: [IgnoreRule] -> Rules.RuleCheck -> Bool ignoreFilter rules (Rules.RuleCheck (Rules.Metadata code _ _) _ _ _) = code `notElem` rules- -- | Support UNIX convention of passing "-" instead of "/dev/stdin"- parseFilename :: String -> IO (Either Error Dockerfile)- parseFilename "-" = Docker.parseStdin- parseFilename s = Docker.parseFile s -- | Returns the result of applying all the rules to the given dockerfile analyzeAll :: Rules.RulesConfig -> Dockerfile -> [Rules.RuleCheck]
src/Hadolint/Rules.hs view
@@ -6,24 +6,24 @@ import Control.Arrow ((&&&)) import Data.List (foldl', isInfixOf, isPrefixOf, mapAccumL, nub) import Data.List.NonEmpty (toList)-import qualified Hadolint.Shell as Shell-import Language.Docker.Syntax- import qualified Data.Map as Map import Data.Semigroup (Semigroup, (<>)) import qualified Data.Set as Set import qualified Data.Text as Text import Data.Void (Void)+import qualified Hadolint.Shell as Shell+import Language.Docker.Syntax+import ShellCheck.Interface (Severity (..)) import qualified ShellCheck.Interface-import ShellCheck.Interface (Severity(..)) import qualified Text.Megaparsec as Megaparsec import qualified Text.Megaparsec.Char as Megaparsec data Metadata = Metadata- { code :: Text.Text- , severity :: Severity- , message :: Text.Text- } deriving (Eq)+ { code :: Text.Text,+ severity :: Severity,+ message :: Text.Text+ }+ deriving (Eq, Show) -- a check is the application of a rule on a specific part of code -- the enforced result and the affected position@@ -31,26 +31,29 @@ -- and simple to develop new rules -- line numbers in the negative range are meant for the global context data RuleCheck = RuleCheck- { metadata :: Metadata- , filename :: Filename- , linenumber :: Linenumber- , success :: Bool- } deriving (Eq)+ { metadata :: Metadata,+ filename :: Filename,+ linenumber :: Linenumber,+ success :: Bool+ }+ deriving (Eq, Show) -- | Contains the required parameters for optional rules newtype RulesConfig = RulesConfig- { allowedRegistries :: Set.Set Registry -- ^ The docker registries that are allowed in FROM- } deriving (Show, Eq)+ { -- | The docker registries that are allowed in FROM+ allowedRegistries :: Set.Set Registry+ }+ deriving (Show, Eq) instance Ord RuleCheck where- a `compare` b = linenumber a `compare` linenumber b+ a `compare` b = linenumber a `compare` linenumber b instance Semigroup RulesConfig where- RulesConfig a <> RulesConfig b = RulesConfig (a <> b)+ RulesConfig a <> RulesConfig b = RulesConfig (a <> b) instance Monoid RulesConfig where- mempty = RulesConfig mempty- mappend = (<>)+ mempty = RulesConfig mempty+ mappend = (<>) type IgnoreRuleParser = Megaparsec.Parsec Void Text.Text @@ -59,8 +62,8 @@ -- | A function to check individual dockerfile instructions. -- It gets the current state and a line number. -- It should return the new state and whether or not the check passes for the given instruction.-type SimpleCheckerWithState state- = state -> Linenumber -> Instruction Shell.ParsedShell -> (state, Bool)+type SimpleCheckerWithState state =+ state -> Linenumber -> Instruction Shell.ParsedShell -> (state, Bool) -- | A function to check individual dockerfile instructions. -- It gets the current line number.@@ -70,14 +73,14 @@ -- | A function to check individual dockerfile instructions. -- It should return the new state and a list of Metadata records. -- Each Metadata record signifies a failing check for the given instruction.-type CheckerWithState state- = state -> Linenumber -> Instruction Shell.ParsedShell -> (state, [Metadata])+type CheckerWithState state =+ state -> Linenumber -> Instruction Shell.ParsedShell -> (state, [Metadata]) link :: Metadata -> Text.Text link (Metadata code _ _)- | "SC" `Text.isPrefixOf` code = "https://github.com/koalaman/shellcheck/wiki/" <> code- | "DL" `Text.isPrefixOf` code = "https://github.com/hadolint/hadolint/wiki/" <> code- | otherwise = "https://github.com/hadolint/hadolint"+ | "SC" `Text.isPrefixOf` code = "https://github.com/koalaman/shellcheck/wiki/" <> code+ | "DL" `Text.isPrefixOf` code = "https://github.com/hadolint/hadolint/wiki/" <> code+ | otherwise = "https://github.com/hadolint/hadolint" -- a Rule takes a Dockerfile with parsed shell and returns the executed checks type Rule = ParsedFile -> [RuleCheck]@@ -86,58 +89,58 @@ -- for the according line number mapInstructions :: CheckerWithState state -> state -> Rule mapInstructions f initialState dockerfile =- let (_, results) = mapAccumL applyRule initialState dockerfile- in concat results+ let (_, results) = mapAccumL applyRule initialState dockerfile+ in concat results where applyRule state (InstructionPos (OnBuild i) source linenumber) =- applyWithState state source linenumber i -- All rules applying to instructions also apply to ONBUILD,- -- so we unwrap the OnBuild constructor and check directly the inner- -- instruction+ applyWithState state source linenumber i -- All rules applying to instructions also apply to ONBUILD,+ -- so we unwrap the OnBuild constructor and check directly the inner+ -- instruction applyRule state (InstructionPos i source linenumber) =- applyWithState state source linenumber i -- Otherwise, normal instructions are not unwrapped+ applyWithState state source linenumber i -- Otherwise, normal instructions are not unwrapped applyWithState state source linenumber instruction =- let (newState, res) = f state linenumber instruction- in (newState, [RuleCheck m source linenumber False | m <- res])+ let (newState, res) = f state linenumber instruction+ in (newState, [RuleCheck m source linenumber False | m <- res]) instructionRule ::- Text.Text -> Severity -> Text.Text -> (Instruction Shell.ParsedShell -> Bool) -> Rule+ Text.Text -> Severity -> Text.Text -> (Instruction Shell.ParsedShell -> Bool) -> Rule instructionRule code severity message check =- instructionRuleLine code severity message (const check)+ instructionRuleLine code severity message (const check) instructionRuleLine :: Text.Text -> Severity -> Text.Text -> SimpleCheckerWithLine -> Rule instructionRuleLine code severity message check =- instructionRuleState code severity message checkAndDropState ()+ instructionRuleState code severity message checkAndDropState () where checkAndDropState state line instr = (state, check line instr) instructionRuleState ::- Text.Text -> Severity -> Text.Text -> SimpleCheckerWithState state -> state -> Rule+ Text.Text -> Severity -> Text.Text -> SimpleCheckerWithState state -> state -> Rule instructionRuleState code severity message f = mapInstructions constMetadataCheck where meta = Metadata code severity message constMetadataCheck st ln instr =- let (newSt, success) = f st ln instr- in if not success- then (newSt, [meta])- else (newSt, [])+ let (newSt, success) = f st ln instr+ in if not success+ then (newSt, [meta])+ else (newSt, []) withState :: a -> b -> (a, b) withState st res = (st, res) argumentsRule :: (Shell.ParsedShell -> a) -> Arguments Shell.ParsedShell -> a argumentsRule applyRule args =- case args of- ArgumentsText as -> applyRule as- ArgumentsList as -> applyRule as+ case args of+ ArgumentsText as -> applyRule as+ ArgumentsList as -> applyRule as -- Enforce rules on a dockerfile and return failed checks analyze :: [Rule] -> Dockerfile -> [RuleCheck] analyze list dockerfile =- [ result -- Keep the result- | rule <- list -- for each rule in the list- , result <- rule parsedFile -- after applying the rule to the file- , notIgnored result -- and only keep failures that were not ignored- ]+ [ result -- Keep the result+ | rule <- list, -- for each rule in the list+ result <- rule parsedFile, -- after applying the rule to the file+ notIgnored result -- and only keep failures that were not ignored+ ] where notIgnored RuleCheck {metadata = Metadata {code}, linenumber} = not (wasIgnored code linenumber) wasIgnored c ln = not $ null [line | (line, codes) <- allIgnores, line == ln, c `elem` codes]@@ -146,22 +149,21 @@ ignored :: Dockerfile -> [(Linenumber, [Text.Text])] ignored dockerfile =- [(l + 1, ignores) | (l, Just ignores) <- map (lineNumber &&& extractIgnored) dockerfile]+ [(l + 1, ignores) | (l, Just ignores) <- map (lineNumber &&& extractIgnored) dockerfile] where extractIgnored = ignoreFromInstruction . instruction ignoreFromInstruction (Comment comment) = parseComment comment ignoreFromInstruction _ = Nothing- -- | Parses the comment text and extracts the ignored rule names parseComment :: Text.Text -> Maybe [Text.Text] parseComment = Megaparsec.parseMaybe commentParser commentParser :: IgnoreRuleParser [Text.Text] commentParser =- spaces >> -- The parser for the ignored rules- string "hadolint" >>- spaces1 >>- string "ignore=" >>- spaces >>- Megaparsec.sepBy1 ruleName (spaces >> string "," >> spaces)+ spaces+ >> string "hadolint" -- The parser for the ignored rules+ >> spaces1+ >> string "ignore="+ >> spaces+ >> Megaparsec.sepBy1 ruleName (spaces >> string "," >> spaces) string = Megaparsec.string spaces = Megaparsec.takeWhileP Nothing space spaces1 = Megaparsec.takeWhile1P Nothing space@@ -170,46 +172,46 @@ rules :: [Rule] rules =- [ absoluteWorkdir- , shellcheck- , invalidCmd- , copyInsteadAdd- , copyEndingSlash- , copyFromExists- , copyFromAnother- , fromAliasUnique- , noRootUser- , noCd- , noSudo- , noAptGetUpgrade- , noApkUpgrade- , noLatestTag- , noUntagged- , noPlatformFlag- , aptGetVersionPinned- , aptGetCleanup- , apkAddVersionPinned- , apkAddNoCache- , useAdd- , pipVersionPinned- , npmVersionPinned- , invalidPort- , aptGetNoRecommends- , aptGetYes- , wgetOrCurl- , hasNoMaintainer- , multipleCmds- , multipleEntrypoints- , useShell- , useJsonArgs- , usePipefail- , noApt- , gemVersionPinned- , yumYes- , noYumUpdate- , yumCleanup- , yumVersionPinned- ]+ [ absoluteWorkdir,+ shellcheck,+ invalidCmd,+ copyInsteadAdd,+ copyEndingSlash,+ copyFromExists,+ copyFromAnother,+ fromAliasUnique,+ noRootUser,+ noCd,+ noSudo,+ noAptGetUpgrade,+ noApkUpgrade,+ noLatestTag,+ noUntagged,+ noPlatformFlag,+ aptGetVersionPinned,+ aptGetCleanup,+ apkAddVersionPinned,+ apkAddNoCache,+ useAdd,+ pipVersionPinned,+ npmVersionPinned,+ invalidPort,+ aptGetNoRecommends,+ aptGetYes,+ wgetOrCurl,+ hasNoMaintainer,+ multipleCmds,+ multipleEntrypoints,+ useShell,+ useJsonArgs,+ usePipefail,+ noApt,+ gemVersionPinned,+ yumYes,+ noYumUpdate,+ yumCleanup,+ yumVersionPinned+ ] optionalRules :: RulesConfig -> [Rule] optionalRules RulesConfig {allowedRegistries} = [registryIsAllowed allowedRegistries]@@ -221,7 +223,7 @@ allAliasedImages :: ParsedFile -> [(Linenumber, ImageAlias)] allAliasedImages dockerfile =- [(l, alias) | (l, Just alias) <- map extractAlias (allFromImages dockerfile)]+ [(l, alias) | (l, Just alias) <- map extractAlias (allFromImages dockerfile)] where extractAlias (l, f) = (l, fromAlias f) @@ -232,16 +234,16 @@ -- are defined before the given line number. previouslyDefinedAliases :: Linenumber -> ParsedFile -> [Text.Text] previouslyDefinedAliases line dockerfile =- [i | (l, ImageAlias i) <- allAliasedImages dockerfile, l < line]+ [i | (l, ImageAlias i) <- allAliasedImages dockerfile, l < line] -- | Returns the result of running the check function on the image alias -- name, if the passed instruction is a FROM instruction with a stage alias. -- Otherwise, returns True. aliasMustBe :: (Text.Text -> Bool) -> Instruction a -> Bool aliasMustBe predicate fromInstr =- case fromInstr of- From BaseImage {alias = Just (ImageAlias as)} -> predicate as- _ -> True+ case fromInstr of+ From BaseImage {alias = Just (ImageAlias as)} -> predicate as+ _ -> True fromName :: BaseImage -> Text.Text fromName BaseImage {image = Image {imageName}} = imageName@@ -269,7 +271,7 @@ -- | Converts ShellCheck errors into our own errors type commentMetadata :: ShellCheck.Interface.PositionedComment -> Metadata commentMetadata c =- Metadata (Text.pack ("SC" ++ show (code c))) (severity c) (Text.pack (message c))+ Metadata (Text.pack ("SC" ++ show (code c))) (severity c) (Text.pack (message c)) where severity pc = ShellCheck.Interface.cSeverity $ ShellCheck.Interface.pcComment pc code pc = ShellCheck.Interface.cCode $ ShellCheck.Interface.pcComment pc@@ -282,9 +284,9 @@ severity = ErrorC message = "Use absolute WORKDIR" check (Workdir loc)- | "$" `Text.isPrefixOf` loc = True- | "/" `Text.isPrefixOf` loc = True- | otherwise = False+ | "$" `Text.isPrefixOf` loc = True+ | "/" `Text.isPrefixOf` loc = True+ | otherwise = False check _ = True hasNoMaintainer :: Rule@@ -306,8 +308,8 @@ code = "DL4003" severity = WarningC message =- "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last \- \`CMD` will take effect"+ "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last \+ \`CMD` will take effect" check _ _ From {} = withState False True -- Reset the state each time we find a FROM check st _ Cmd {} = withState True (not st) -- Remember we found a CMD, fail if we found a CMD before check st _ _ = withState st True@@ -318,11 +320,11 @@ code = "DL4004" severity = ErrorC message =- "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then \- \only the last `ENTRYPOINT` will take effect"+ "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then \+ \only the last `ENTRYPOINT` will take effect" check _ _ From {} = withState False True -- Reset the state each time we find a FROM check st _ Entrypoint {} = withState True (not st) -- Remember we found an ENTRYPOINT- -- and fail if we found another one before+ -- and fail if we found another one before check st _ _ = withState st True wgetOrCurl :: Rule@@ -335,11 +337,11 @@ check _ _ (From _) = withState Set.empty True -- Reset the state for each stage check state _ _ = withState state True detectDoubleUsage state args =- let newArgs = extractCommands args- newState = Set.union state newArgs- in withState newState (Set.null newArgs || Set.size newState < 2)+ let newArgs = extractCommands args+ newState = Set.union state newArgs+ in withState newState (Set.null newArgs || Set.size newState < 2) extractCommands args =- Set.fromList [w | w <- Shell.findCommandNames args, w == "curl" || w == "wget"]+ Set.fromList [w | w <- Shell.findCommandNames args, w == "curl" || w == "wget"] invalidCmd :: Rule invalidCmd = instructionRule code severity message check@@ -347,8 +349,8 @@ code = "DL3001" severity = InfoC message =- "For some bash commands it makes no sense running them in a Docker container like `ssh`, \- \`vim`, `shutdown`, `service`, `ps`, `free`, `top`, `kill`, `mount`, `ifconfig`"+ "For some bash commands it makes no sense running them in a Docker container like `ssh`, \+ \`vim`, `shutdown`, `service`, `ps`, `free`, `top`, `kill`, `mount`, `ifconfig`" check (Run (RunArgs args _)) = argumentsRule detectInvalid args check _ = True detectInvalid args = null [arg | arg <- Shell.findCommandNames args, arg `elem` invalidCmds]@@ -362,8 +364,8 @@ message = "Last USER should not be root" check _ _ (From from) = withState (Just from) True -- Remember the last FROM instruction found check st@(Just from) line (User user)- | isRoot user && lastUserIsRoot from line = withState st False- | otherwise = withState st True+ | isRoot user && lastUserIsRoot from line = withState st False+ | otherwise = withState st True check st _ _ = withState st True -- --@@ -372,20 +374,20 @@ -- rootStages :: Map.Map BaseImage Linenumber rootStages =- let indexedInstructions = map (instruction &&& lineNumber) dockerfile- (_, usersMap) = foldl' buildMap (Nothing, Map.empty) indexedInstructions- in usersMap+ let indexedInstructions = map (instruction &&& lineNumber) dockerfile+ (_, usersMap) = foldl' buildMap (Nothing, Map.empty) indexedInstructions+ in usersMap -- -- buildMap (_, st) (From from, _) = (Just from, st) -- Remember the FROM we are currently inspecting buildMap (Just from, st) (User user, line)- | isRoot user = (Just from, Map.insert from line st) -- Remember the line with a root user- | otherwise = (Just from, Map.delete from st) -- Forget there was a root used for this FROM+ | isRoot user = (Just from, Map.insert from line st) -- Remember the line with a root user+ | otherwise = (Just from, Map.delete from st) -- Forget there was a root used for this FROM buildMap st _ = st -- -- isRoot user =- Text.isPrefixOf "root:" user || Text.isPrefixOf "0:" user || user == "root" || user == "0"+ Text.isPrefixOf "root:" user || Text.isPrefixOf "0:" user || user == "root" || user == "0" noCd :: Rule noCd = instructionRule code severity message check@@ -402,8 +404,8 @@ code = "DL3004" severity = ErrorC message =- "Do not use sudo as it leads to unpredictable behavior. Use a tool like gosu to enforce \- \root"+ "Do not use sudo as it leads to unpredictable behavior. Use a tool like gosu to enforce \+ \root" check (Run (RunArgs args _)) = argumentsRule (not . usingProgram "sudo") args check _ = True @@ -414,7 +416,7 @@ severity = ErrorC message = "Do not use apt-get upgrade or dist-upgrade" check (Run (RunArgs args _)) =- argumentsRule (Shell.noCommands (Shell.cmdHasArgs "apt-get" ["upgrade"])) args+ argumentsRule (Shell.noCommands (Shell.cmdHasArgs "apt-get" ["upgrade"])) args check _ = True noUntagged :: Rule@@ -426,7 +428,7 @@ check _ (From BaseImage {image = (Image _ "scratch")}) = True check _ (From BaseImage {digest = Just _}) = True check line (From BaseImage {image = (Image _ i), tag = Nothing}) =- i `elem` previouslyDefinedAliases line dockerfile+ i `elem` previouslyDefinedAliases line dockerfile check _ _ = True noLatestTag :: Rule@@ -435,8 +437,8 @@ code = "DL3007" severity = WarningC message =- "Using latest is prone to errors if the image will ever update. Pin the version explicitly \- \to a release tag"+ "Using latest is prone to errors if the image will ever update. Pin the version explicitly \+ \to a release tag" check (From BaseImage {tag = Just t}) = t /= "latest" check _ = True @@ -446,20 +448,20 @@ code = "DL3008" severity = WarningC message =- "Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get \- \install <package>=<version>`"+ "Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get \+ \install <package>=<version>`" check (Run (RunArgs args _)) = argumentsRule (all versionFixed . aptGetPackages) args check _ = True versionFixed package = "=" `Text.isInfixOf` package || ("/" `Text.isInfixOf` package || ".deb" `Text.isSuffixOf` package) aptGetPackages :: Shell.ParsedShell -> [Text.Text] aptGetPackages args =- [ arg- | cmd <- Shell.presentCommands args- , Shell.cmdHasArgs "apt-get" ["install"] cmd- , arg <- Shell.getArgsNoFlags (dropTarget cmd)- , arg /= "install"- ]+ [ arg+ | cmd <- Shell.presentCommands args,+ Shell.cmdHasArgs "apt-get" ["install"] cmd,+ arg <- Shell.getArgsNoFlags (dropTarget cmd),+ arg /= "install"+ ] where dropTarget = Shell.dropFlagArg ["t", "target-release"] @@ -469,33 +471,29 @@ code = "DL3009" severity = InfoC message = "Delete the apt-get lists after installing something"- -- | 'check' returns a tuple (state, check_result)- -- The state in this case is the FROM instruction where the current instruction we are- -- inspecting is nested in.- -- We only care for users to delete the lists folder if the FROM clase we're is is the last one- -- or if it is used as the base image for another FROM clause.+ check _ line f@(From _) = withState (Just (line, f)) True -- Remember the last FROM instruction found check st@(Just (line, From baseimage)) _ (Run (RunArgs args _)) =- withState st (argumentsRule (didNotForgetToCleanup line baseimage) args)+ withState st (argumentsRule (didNotForgetToCleanup line baseimage) args) check st _ _ = withState st True -- Check all commands in the script for the presence of apt-get update -- If the command is there, then we need to verify that the user is also removing the lists folder didNotForgetToCleanup line baseimage args- | not (hasUpdate args) || not (imageIsUsed line baseimage) = True- | otherwise = hasCleanup args+ | not (hasUpdate args) || not (imageIsUsed line baseimage) = True+ | otherwise = hasCleanup args hasCleanup args =- any (Shell.cmdHasArgs "rm" ["-rf", "/var/lib/apt/lists/*"]) (Shell.presentCommands args)+ any (Shell.cmdHasArgs "rm" ["-rf", "/var/lib/apt/lists/*"]) (Shell.presentCommands args) hasUpdate args = any (Shell.cmdHasArgs "apt-get" ["update"]) (Shell.presentCommands args) imageIsUsed line baseimage = isLastImage line baseimage || imageIsUsedLater line baseimage isLastImage line baseimage =- case reverse (allFromImages dockerfile) of- lst:_ -> (line, baseimage) == lst- _ -> True+ case reverse (allFromImages dockerfile) of+ lst : _ -> (line, baseimage) == lst+ _ -> True imageIsUsedLater line baseimage =- case fromAlias baseimage of- Nothing -> True- Just (ImageAlias alias) ->- alias `elem` [i | (l, i) <- allImageNames dockerfile, l > line]+ case fromAlias baseimage of+ Nothing -> True+ Just (ImageAlias alias) ->+ alias `elem` [i | (l, i) <- allImageNames dockerfile, l > line] noApkUpgrade :: Rule noApkUpgrade = instructionRule code severity message check@@ -512,19 +510,19 @@ code = "DL3018" severity = WarningC message =- "Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`"+ "Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`" check (Run (RunArgs args _)) = argumentsRule (\as -> and [versionFixed p | p <- apkAddPackages as]) args check _ = True versionFixed package = "=" `Text.isInfixOf` package apkAddPackages :: Shell.ParsedShell -> [Text.Text] apkAddPackages args =- [ arg- | cmd <- Shell.presentCommands args- , Shell.cmdHasArgs "apk" ["add"] cmd- , arg <- Shell.getArgsNoFlags (dropTarget cmd)- , arg /= "add"- ]+ [ arg+ | cmd <- Shell.presentCommands args,+ Shell.cmdHasArgs "apk" ["add"] cmd,+ arg <- Shell.getArgsNoFlags (dropTarget cmd),+ arg /= "add"+ ] where dropTarget = Shell.dropFlagArg ["t", "virtual", "repository", "X"] @@ -534,8 +532,8 @@ code = "DL3019" severity = InfoC message =- "Use the `--no-cache` switch to avoid the need to use `--update` and remove \- \`/var/cache/apk/*` when done installing packages"+ "Use the `--no-cache` switch to avoid the need to use `--update` and remove \+ \`/var/cache/apk/*` when done installing packages" check (Run (RunArgs args _)) = argumentsRule (Shell.noCommands forgotCacheOption) args check _ = True forgotCacheOption cmd = Shell.cmdHasArgs "apk" ["add"] cmd && not (Shell.hasFlag "no-cache" cmd)@@ -547,29 +545,29 @@ severity = InfoC message = "Use ADD for extracting archives into an image" check (Copy (CopyArgs srcs _ _ _)) =- and- [ not (format `Text.isSuffixOf` src)- | SourcePath src <- toList srcs- , format <- archiveFormats- ]+ and+ [ not (format `Text.isSuffixOf` src)+ | SourcePath src <- toList srcs,+ format <- archiveFormats+ ] check _ = True archiveFormats =- [ ".tar"- , ".tar.bz2"- , ".tb2"- , ".tbz"- , ".tbz2"- , ".tar.gz"- , ".tgz"- , ".tpz"- , ".tar.lz"- , ".tar.lzma"- , ".tlz"- , ".tar.xz"- , ".txz"- , ".tar.Z"- , ".tZ"- ]+ [ ".tar",+ ".tar.bz2",+ ".tb2",+ ".tbz",+ ".tbz2",+ ".tar.gz",+ ".tgz",+ ".tpz",+ ".tar.lz",+ ".tar.lzma",+ ".tlz",+ ".tar.xz",+ ".txz",+ ".tar.Z",+ ".tZ"+ ] invalidPort :: Rule invalidPort = instructionRule code severity message check@@ -578,8 +576,8 @@ severity = ErrorC message = "Valid UNIX ports range from 0 to 65535" check (Expose (Ports ports)) =- and [p <= 65535 | Port p _ <- ports] &&- and [l <= 65535 && m <= 65535 | PortRange l m _ <- ports]+ and [p <= 65535 | Port p _ <- ports]+ && and [l <= 65535 && m <= 65535 | PortRange l m _ <- ports] check _ = True pipVersionPinned :: Rule@@ -588,46 +586,55 @@ code = "DL3013" severity = WarningC message =- "Pin versions in pip. Instead of `pip install <package>` use `pip install \- \<package>==<version>`"+ "Pin versions in pip. Instead of `pip install <package>` use `pip install \+ \<package>==<version>`" check (Run (RunArgs args _)) = argumentsRule (Shell.noCommands forgotToPinVersion) args check _ = True forgotToPinVersion cmd =- isPipInstall cmd && not (hasBuildConstraint cmd) && not (all versionFixed (packages cmd))+ isPipInstall cmd && not (hasBuildConstraint cmd) && not (all versionFixed (packages cmd)) -- Check if the command is a pip* install command, and that specific packages are being listed isPipInstall cmd = (isStdPipInstall cmd || isPythonPipInstall cmd) && not (requirementInstall cmd) isStdPipInstall cmd@(Shell.Command name _ _) = "pip" `Text.isPrefixOf` name && ["install"] `isInfixOf` Shell.getArgs cmd- isPythonPipInstall cmd@(Shell.Command name _ _) = "python" `Text.isPrefixOf` name &&- ["-m", "pip", "install"] `isInfixOf` Shell.getArgs cmd+ isPythonPipInstall cmd@(Shell.Command name _ _) =+ "python" `Text.isPrefixOf` name+ && ["-m", "pip", "install"] `isInfixOf` Shell.getArgs cmd -- If the user is installing requirements from a file or just the local module, then we are not interested -- in running this rule- requirementInstall cmd = ["--requirement"] `isInfixOf` Shell.getArgs cmd ||- ["-r"] `isInfixOf` Shell.getArgs cmd ||- ["."] `isInfixOf` Shell.getArgs cmd+ requirementInstall cmd =+ ["--requirement"] `isInfixOf` Shell.getArgs cmd+ || ["-r"] `isInfixOf` Shell.getArgs cmd+ || ["."] `isInfixOf` Shell.getArgs cmd hasBuildConstraint cmd = Shell.hasFlag "constraint" cmd || Shell.hasFlag "c" cmd packages cmd =- stripInstallPrefix $- Shell.getArgsNoFlags $ Shell.dropFlagArg- [ "abi"- , "b", "build"- , "e", "editable"- , "extra-index-url"- , "f", "find-links"- , "i", "index-url"- , "implementation"- , "no-binary"- , "only-binary"- , "platform"- , "prefix"- , "progress-bar"- , "proxy"- , "python-version"- , "root"- , "src"- , "t", "target"- , "trusted-host"- , "upgrade-strategy"- ] cmd+ stripInstallPrefix $+ Shell.getArgsNoFlags $+ Shell.dropFlagArg+ [ "abi",+ "b",+ "build",+ "e",+ "editable",+ "extra-index-url",+ "f",+ "find-links",+ "i",+ "index-url",+ "implementation",+ "no-binary",+ "only-binary",+ "platform",+ "prefix",+ "progress-bar",+ "proxy",+ "python-version",+ "root",+ "src",+ "t",+ "target",+ "trusted-host",+ "upgrade-strategy"+ ]+ cmd versionFixed package = hasVersionSymbol package || isVersionedGit package isVersionedGit package = "git+http" `Text.isInfixOf` package && "@" `Text.isInfixOf` package versionSymbols = ["==", ">=", "<=", ">", "<", "!=", "~=", "==="]@@ -636,36 +643,35 @@ stripInstallPrefix :: [Text.Text] -> [Text.Text] stripInstallPrefix cmd = dropWhile (== "install") (dropWhile (/= "install") cmd) -{-|- Rule for pinning NPM packages to version, tag, or commit- supported formats by Hadolint- npm install (with no args, in package dir)- npm install [<@scope>/]<name>- npm install [<@scope>/]<name>@<tag>- npm install [<@scope>/]<name>@<version>- npm install git[+http|+https]://<git-host>/<git-user>/<repo-name>[#<commit>|#semver:<semver>]- npm install git+ssh://<git-host>:<git-user>/<repo-name>[#<commit>|#semver:<semver>]--}+-- |+-- Rule for pinning NPM packages to version, tag, or commit+-- supported formats by Hadolint+-- npm install (with no args, in package dir)+-- npm install [<@scope>/]<name>+-- npm install [<@scope>/]<name>@<tag>+-- npm install [<@scope>/]<name>@<version>+-- npm install git[+http|+https]://<git-host>/<git-user>/<repo-name>[#<commit>|#semver:<semver>]+-- npm install git+ssh://<git-host>:<git-user>/<repo-name>[#<commit>|#semver:<semver>] npmVersionPinned :: Rule npmVersionPinned = instructionRule code severity message check where code = "DL3016" severity = WarningC message =- "Pin versions in npm. Instead of `npm install <package>` use `npm install \- \<package>@<version>`"+ "Pin versions in npm. Instead of `npm install <package>` use `npm install \+ \<package>@<version>`" check (Run (RunArgs args _)) = argumentsRule (Shell.noCommands forgotToPinVersion) args check _ = True forgotToPinVersion cmd =- isNpmInstall cmd && installIsFirst cmd && not (all versionFixed (packages cmd))+ isNpmInstall cmd && installIsFirst cmd && not (all versionFixed (packages cmd)) isNpmInstall = Shell.cmdHasArgs "npm" ["install"] installIsFirst cmd = ["install"] `isPrefixOf` Shell.getArgsNoFlags cmd packages cmd = stripInstallPrefix (Shell.getArgsNoFlags cmd) versionFixed package- | hasGitPrefix package = isVersionedGit package- | hasTarballSuffix package = True- | isFolder package = True- | otherwise = hasVersionSymbol package+ | hasGitPrefix package = isVersionedGit package+ | hasTarballSuffix package = True+ | isFolder package = True+ | otherwise = hasVersionSymbol package gitPrefixes = ["git://", "git+ssh://", "git+http://", "git+https://"] hasGitPrefix package = or [p `Text.isPrefixOf` package | p <- gitPrefixes] tarballSuffixes = [".tar", ".tar.gz", ".tgz"]@@ -676,9 +682,9 @@ hasVersionSymbol package = "@" `Text.isInfixOf` dropScope package where dropScope pkg =- if "@" `Text.isPrefixOf` pkg- then Text.dropWhile ('/' <) pkg- else pkg+ if "@" `Text.isPrefixOf` pkg+ then Text.dropWhile ('/' <) pkg+ else pkg aptGetYes :: Rule aptGetYes = instructionRule code severity message check@@ -703,31 +709,32 @@ forgotNoInstallRecommends cmd = isAptGetInstall cmd && not (disablesRecommendOption cmd) isAptGetInstall = Shell.cmdHasArgs "apt-get" ["install"] disablesRecommendOption cmd =- Shell.hasFlag "no-install-recommends" cmd ||- Shell.hasArg "APT::Install-Recommends=false" cmd+ Shell.hasFlag "no-install-recommends" cmd+ || Shell.hasArg "APT::Install-Recommends=false" cmd isArchive :: Text.Text -> Bool isArchive path =- or- ([ ftype `Text.isSuffixOf` path- | ftype <-- [ ".tar"- , ".gz"- , ".bz2"- , ".xz"- , ".zip"- , ".tgz"- , ".tb2"- , ".tbz"- , ".tbz2"- , ".lz"- , ".lzma"- , ".tlz"- , ".txz"- , ".Z"- , ".tZ"- ]- ])+ or+ ( [ ftype `Text.isSuffixOf` path+ | ftype <-+ [ ".tar",+ ".gz",+ ".bz2",+ ".xz",+ ".zip",+ ".tgz",+ ".tb2",+ ".tbz",+ ".tbz2",+ ".lz",+ ".lzma",+ ".tlz",+ ".txz",+ ".Z",+ ".tZ"+ ]+ ]+ ) isUrl :: Text.Text -> Bool isUrl path = or ([proto `Text.isPrefixOf` path | proto <- ["https://", "http://"]])@@ -739,7 +746,7 @@ severity = ErrorC message = "Use COPY instead of ADD for files and folders" check (Add (AddArgs srcs _ _)) =- and [isArchive src || isUrl src | SourcePath src <- toList srcs]+ and [isArchive src || isUrl src | SourcePath src <- toList srcs] check _ = True copyEndingSlash :: Rule@@ -749,8 +756,8 @@ severity = ErrorC message = "COPY with more than 2 arguments requires the last argument to end with /" check (Copy (CopyArgs sources t _ _))- | length sources > 1 = endsWithSlash t- | otherwise = True+ | length sources > 1 = endsWithSlash t+ | otherwise = True check _ = True endsWithSlash (TargetPath t) = not (Text.null t) && Text.last t == '/' @@ -769,12 +776,10 @@ code = "DL3023" severity = ErrorC message = "COPY --from should reference a previously defined FROM alias"- -- | 'check' returns a tuple (state, check_result)- -- The state in this case is the FROM instruction where the current instruction we are- -- inspecting is nested in.+ check _ _ f@(From _) = withState (Just f) True -- Remember the last FROM instruction found check st@(Just fromInstr) _ (Copy (CopyArgs _ _ _ (CopySource stageName))) =- withState st (aliasMustBe (/= stageName) fromInstr) -- Cannot copy from itself!+ withState st (aliasMustBe (/= stageName) fromInstr) -- Cannot copy from itself! check state _ _ = withState state True fromAliasUnique :: Rule@@ -811,7 +816,7 @@ code = "DL3027" severity = WarningC message =- "Do not use apt as it is meant to be a end-user tool, use apt-get or apt-cache instead"+ "Do not use apt as it is meant to be a end-user tool, use apt-get or apt-cache instead" check (Run (RunArgs args _)) = argumentsRule (not . usingProgram "apt") args check _ = True @@ -820,28 +825,29 @@ where code = "DL4006" severity = WarningC- message = "Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using \- \/bin/sh in an alpine image or if your shell is symlinked to busybox then consider \- \explicitly setting your SHELL to /bin/ash, or disable this check"+ message =+ "Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using \+ \/bin/sh in an alpine image or if your shell is symlinked to busybox then consider \+ \explicitly setting your SHELL to /bin/ash, or disable this check" check _ _ From {} = (False, True) -- Reset the state each time we find a new FROM check _ _ (Shell args)- | argumentsRule isPowerShell args = (True, True)- | otherwise = (argumentsRule hasPipefailOption args, True)+ | argumentsRule isPowerShell args = (True, True)+ | otherwise = (argumentsRule hasPipefailOption args, True) check False _ (Run (RunArgs args _)) = (False, argumentsRule notHasPipes args) check st _ _ = (st, True) isPowerShell (Shell.ParsedShell orig _ _) = "pwsh" `Text.isPrefixOf` orig notHasPipes script = not (Shell.hasPipes script) hasPipefailOption script =- not $+ not $ null- [ True- | cmd@(Shell.Command name arguments _) <- Shell.presentCommands script- , validShell <- ["/bin/bash", "/bin/zsh", "/bin/ash", "bash", "zsh", "ash"]- , name == validShell- , Shell.hasFlag "o" cmd- , arg <- Shell.arg <$> arguments- , arg == "pipefail"- ]+ [ True+ | cmd@(Shell.Command name arguments _) <- Shell.presentCommands script,+ validShell <- ["/bin/bash", "/bin/zsh", "/bin/ash", "bash", "zsh", "ash"],+ name == validShell,+ Shell.hasFlag "o" cmd,+ arg <- Shell.arg <$> arguments,+ arg == "pipefail"+ ] registryIsAllowed :: Set.Set Registry -> Rule registryIsAllowed allowed = instructionRuleState code severity message check Set.empty@@ -851,15 +857,14 @@ message = "Use only an allowed registry in the FROM image" check st _ (From BaseImage {image, alias}) = withState (Set.insert alias st) (doCheck st image) check st _ _ = (st, True)- -- |Transforms an Image into a Maybe ImageAlias by using the Image name toImageAlias = Just . ImageAlias . imageName- -- | Returns True if the image being used is a previous aliased image- -- or if the image registry is in the set of allowed registries+ doCheck st img = Set.member (toImageAlias img) st || Set.null allowed || isAllowed img isAllowed Image {registryName = Just registry} = Set.member registry allowed isAllowed Image {registryName = Nothing, imageName} =- imageName == "scratch" ||- Set.member "docker.io" allowed || Set.member "hub.docker.com" allowed+ imageName == "scratch"+ || Set.member "docker.io" allowed+ || Set.member "hub.docker.com" allowed gemVersionPinned :: Rule gemVersionPinned = instructionRule code severity message check@@ -867,8 +872,8 @@ code = "DL3028" severity = WarningC message =- "Pin versions in gem install. Instead of `gem install <gem>` use `gem \- \install <gem>:<version>`"+ "Pin versions in gem install. Instead of `gem install <gem>` use `gem \+ \install <gem>:<version>`" check (Run (RunArgs args _)) = argumentsRule (all versionFixed . gems) args check _ = True versionFixed package = ":" `Text.isInfixOf` package@@ -901,11 +906,18 @@ severity = ErrorC message = "Do not use yum update." check (Run (RunArgs args _)) =- argumentsRule (Shell.noCommands (- Shell.cmdHasArgs "yum" ["update",- "update-to",- "upgrade",- "upgrade-to"])) args+ argumentsRule+ ( Shell.noCommands+ ( Shell.cmdHasArgs+ "yum"+ [ "update",+ "update-to",+ "upgrade",+ "upgrade-to"+ ]+ )+ )+ args check _ = True yumCleanup :: Rule@@ -914,9 +926,11 @@ code = "DL3032" severity = WarningC message = "`yum clean all` missing after yum command."- check (Run (RunArgs args _)) = argumentsRule (Shell.noCommands yumInstall) args ||- (argumentsRule (Shell.anyCommands yumInstall) args &&- argumentsRule (Shell.anyCommands yumClean) args)+ check (Run (RunArgs args _)) =+ argumentsRule (Shell.noCommands yumInstall) args+ || ( argumentsRule (Shell.anyCommands yumInstall) args+ && argumentsRule (Shell.anyCommands yumClean) args+ ) check _ = True yumInstall = Shell.cmdHasArgs "yum" ["install"] yumClean = Shell.cmdHasArgs "yum" ["clean", "all"]@@ -929,25 +943,25 @@ message = "Specify version with `yum install -y <package>-<version>`." check (Run (RunArgs args _)) = argumentsRule (all versionFixed . yumPackages) args check _ = True- versionFixed package = "-" `Text.isInfixOf` package- || ".rpm" `Text.isSuffixOf` package+ versionFixed package =+ "-" `Text.isInfixOf` package+ || ".rpm" `Text.isSuffixOf` package yumPackages :: Shell.ParsedShell -> [Text.Text]-yumPackages args = [arg | cmd <- Shell.presentCommands args,- Shell.cmdHasArgs "yum" ["install"] cmd,- arg <- Shell.getArgsNoFlags cmd,- arg /= "install"]+yumPackages args =+ [ arg | cmd <- Shell.presentCommands args, Shell.cmdHasArgs "yum" ["install"] cmd, arg <- Shell.getArgsNoFlags cmd, arg /= "install"+ ] gems :: Shell.ParsedShell -> [Text.Text] gems shell =- [ arg- | cmd <- Shell.presentCommands shell- , Shell.cmdHasArgs "gem" ["install", "i"] cmd- , not (Shell.cmdHasArgs "gem" ["-v"] cmd)- , not (Shell.cmdHasArgs "gem" ["--version"] cmd)- , not (Shell.cmdHasPrefixArg "gem" "--version=" cmd)- , arg <- Shell.getArgsNoFlags cmd- , arg /= "install"- , arg /= "i"- , arg /= "--"- ]+ [ arg+ | cmd <- Shell.presentCommands shell,+ Shell.cmdHasArgs "gem" ["install", "i"] cmd,+ not (Shell.cmdHasArgs "gem" ["-v"] cmd),+ not (Shell.cmdHasArgs "gem" ["--version"] cmd),+ not (Shell.cmdHasPrefixArg "gem" "--version=" cmd),+ arg <- Shell.getArgsNoFlags cmd,+ arg /= "install",+ arg /= "i",+ arg /= "--"+ ]
src/Hadolint/Shell.hs view
@@ -1,7 +1,7 @@-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE ScopedTypeVariables #-} module Hadolint.Shell where @@ -10,51 +10,53 @@ import Data.Maybe (listToMaybe, mapMaybe) import Data.Semigroup ((<>)) import qualified Data.Set as Set-import qualified Data.Text as Text import Data.Text (Text)+import qualified Data.Text as Text+import ShellCheck.AST (Id (..), Token (..), pattern T_Pipe, pattern T_SimpleCommand) import qualified ShellCheck.AST-import ShellCheck.AST (Id(..), Token(..), pattern T_SimpleCommand, pattern T_Pipe) import qualified ShellCheck.ASTLib-import ShellCheck.Checker+import ShellCheck.Checker (checkScript) import ShellCheck.Interface import qualified ShellCheck.Parser data CmdPart = CmdPart- { arg :: !Text- , partId :: !Int- } deriving (Show)+ { arg :: !Text,+ partId :: !Int+ }+ deriving (Show) data Command = Command- { name :: !Text.Text- , arguments :: [CmdPart]- , flags :: [CmdPart]- } deriving (Show)+ { name :: !Text.Text,+ arguments :: [CmdPart],+ flags :: [CmdPart]+ }+ deriving (Show) data ParsedShell = ParsedShell- { original :: !Text.Text- , parsed :: !ParseResult- , presentCommands :: ![Command]- }+ { original :: !Text.Text,+ parsed :: !ParseResult,+ presentCommands :: ![Command]+ } data ShellOpts = ShellOpts- { shellName :: Text.Text- , envVars :: Set.Set Text.Text- }+ { shellName :: Text.Text,+ envVars :: Set.Set Text.Text+ } defaultShellOpts :: ShellOpts defaultShellOpts = ShellOpts "/bin/sh -c" defaultVars where defaultVars =- Set.fromList- [ "HTTP_PROXY"- , "http_proxy"- , "HTTPS_PROXY"- , "https_proxy"- , "FTP_PROXY"- , "ftp_proxy"- , "NO_PROXY"- , "no_proxy"- ]+ Set.fromList+ [ "HTTP_PROXY",+ "http_proxy",+ "HTTPS_PROXY",+ "https_proxy",+ "FTP_PROXY",+ "ftp_proxy",+ "NO_PROXY",+ "no_proxy"+ ] addVars :: [Text.Text] -> ShellOpts -> ShellOpts addVars vars (ShellOpts n v) = ShellOpts n (v <> Set.fromList vars)@@ -64,50 +66,49 @@ shellcheck :: ShellOpts -> ParsedShell -> [PositionedComment] shellcheck (ShellOpts sh env) (ParsedShell txt _ _) =- if "pwsh" `Text.isPrefixOf` sh- then [] -- Do no run for powershell- else runShellCheck+ if "pwsh" `Text.isPrefixOf` sh+ then [] -- Do no run for powershell+ else runShellCheck where runShellCheck = crComments $ runIdentity $ checkScript si spec si = mockedSystemInterface [("", "")] spec =- emptyCheckSpec- { csFilename = "" -- filename can be ommited because we only want the parse results back- , csScript = script- , csCheckSourced = False- , csExcludedWarnings = exclusions- , csShellTypeOverride = Nothing- , csMinSeverity = StyleC- }+ emptyCheckSpec+ { csFilename = "", -- filename can be ommited because we only want the parse results back+ csScript = script,+ csCheckSourced = False,+ csExcludedWarnings = exclusions,+ csShellTypeOverride = Nothing,+ csMinSeverity = StyleC+ } script = "#!" ++ extractShell sh ++ "\n" ++ printVars ++ Text.unpack txt exclusions =- [ 2187 -- exclude the warning about the ash shell not being supported- , 1090 -- requires a directive (shell comment) that can't be expressed in a Dockerfile- ]- -- | Shellcheck complains when the shebang has more than one argument, so we only take the first+ [ 2187, -- exclude the warning about the ash shell not being supported+ 1090 -- requires a directive (shell comment) that can't be expressed in a Dockerfile+ ]+ extractShell s =- maybe "" Text.unpack (listToMaybe . Text.words $ s)- -- | Inject all the collected env vars as exported variables so they can be used+ maybe "" Text.unpack (listToMaybe . Text.words $ s) printVars = Text.unpack . Text.unlines . Set.toList $ Set.map (\v -> "export " <> v <> "=1") env parseShell :: Text.Text -> ParsedShell parseShell txt = ParsedShell {original = txt, parsed = parsedResult, presentCommands = commands} where parsedResult =- runIdentity $+ runIdentity $ ShellCheck.Parser.parseScript- (mockedSystemInterface [("", "")])- newParseSpec- { psFilename = "" -- There is no filename- , psScript = "#!/bin/bash\n" ++ Text.unpack txt- , psCheckSourced = False- }- -- | Extract all commands with their name+ (mockedSystemInterface [("", "")])+ newParseSpec+ { psFilename = "", -- There is no filename+ psScript = "#!/bin/bash\n" ++ Text.unpack txt,+ psCheckSourced = False+ }+ commands = mapMaybe extractNames (findCommandsInResult parsedResult) extractNames token =- case ShellCheck.ASTLib.getCommandName token of- Nothing -> Nothing- Just n -> Just $ Command (Text.pack n) allArgs (getAllFlags allArgs)+ case ShellCheck.ASTLib.getCommandName token of+ Nothing -> Nothing+ Just n -> Just $ Command (Text.pack n) allArgs (getAllFlags allArgs) where allArgs = extractAllArgs token @@ -118,15 +119,15 @@ extractTokensWith :: forall a. (Token -> Maybe a) -> ParseResult -> [a] extractTokensWith extractor ast =- case prRoot ast of- Nothing -> []- Just script -> execWriter $ ShellCheck.AST.doAnalysis extract script+ case prRoot ast of+ Nothing -> []+ Just script -> execWriter $ ShellCheck.AST.doAnalysis extract script where extract :: Token -> Writer [a] () extract token =- case extractor token of- Nothing -> return ()- Just t -> tell [t]+ case extractor token of+ Nothing -> return ()+ Just t -> tell [t] findPipes :: ParsedShell -> [Token] findPipes (ParsedShell _ ast _) = extractTokensWith pipesExtractor ast@@ -151,21 +152,21 @@ cmdHasArgs :: Text.Text -> [Text.Text] -> Command -> Bool cmdHasArgs expectedName expectedArgs (Command n args _)- | expectedName /= n = False- | otherwise = not $ null [arg | CmdPart arg _ <- args, arg `elem` expectedArgs]+ | expectedName /= n = False+ | otherwise = not $ null [arg | CmdPart arg _ <- args, arg `elem` expectedArgs] cmdHasPrefixArg :: Text.Text -> Text.Text -> Command -> Bool cmdHasPrefixArg expectedName expectedArg (Command n args _)- | expectedName /= n = False- | otherwise = not $ null [arg | CmdPart arg _ <- args, expectedArg `Text.isPrefixOf` arg]+ | expectedName /= n = False+ | otherwise = not $ null [arg | CmdPart arg _ <- args, expectedArg `Text.isPrefixOf` arg] extractAllArgs :: Token -> [CmdPart]-extractAllArgs (T_SimpleCommand _ _ (_:allArgs)) = map mkPart allArgs+extractAllArgs (T_SimpleCommand _ _ (_ : allArgs)) = map mkPart allArgs where mkPart token =- CmdPart- (Text.pack . concat $ ShellCheck.ASTLib.oversimplify token)- (mkId (ShellCheck.AST.getId token))+ CmdPart+ (Text.pack . concat $ ShellCheck.ASTLib.oversimplify token)+ (mkId (ShellCheck.AST.getId token)) mkId (Id i) = i extractAllArgs _ = [] @@ -176,10 +177,10 @@ getAllFlags = concatMap flag where flag (CmdPart arg pId)- | arg == "--" || arg == "-" = []- | "--" `Text.isPrefixOf` arg = [CmdPart (Text.drop 2 . Text.takeWhile (/= '=') $ arg) pId]- | "-" `Text.isPrefixOf` arg = map (`CmdPart` pId) (Text.chunksOf 1 (Text.tail arg))- | otherwise = []+ | arg == "--" || arg == "-" = []+ | "--" `Text.isPrefixOf` arg = [CmdPart (Text.drop 2 . Text.takeWhile (/= '=') $ arg) pId]+ | "-" `Text.isPrefixOf` arg = map (`CmdPart` pId) (Text.chunksOf 1 (Text.tail arg))+ | otherwise = [] getArgsNoFlags :: Command -> [Text.Text] getArgsNoFlags args = map arg $ filter (notAFlagId . partId) (arguments args)@@ -200,4 +201,6 @@ where idsToDrop = Set.fromList [getValueId fId arguments | CmdPart f fId <- flags, f `elem` flagsToDrop] filterdArgs = [arg | arg@(CmdPart _ aId) <- arguments, not (aId `Set.member` idsToDrop)]-getValueId fId flags = foldl min (maxBound :: Int) $ filter (>fId) $ map partId flags ++getValueId :: Int -> [CmdPart] -> Int+getValueId fId flags = foldl min (maxBound :: Int) $ filter (> fId) $ map partId flags
test/ConfigSpec.hs view
@@ -1,54 +1,54 @@ {-# LANGUAGE OverloadedStrings #-}+ module ConfigSpec where -import Test.HUnit-import Test.Hspec import Control.Monad (unless) import qualified Data.ByteString.Char8 as Bytes import qualified Data.YAML as Yaml- import Hadolint.Config+import Test.HUnit+import Test.Hspec tests :: SpecWith () tests =- describe "Config" $ do- it "Parses config with only ignores" $- let configFile =- [ "ignored:"- , "- DL3000"- , "- SC1010"- ]- expected = ConfigFile (Just ["DL3000", "SC1010"]) Nothing- in assertConfig expected (Bytes.unlines configFile)+ describe "Config" $ do+ it "Parses config with only ignores" $+ let configFile =+ [ "ignored:",+ "- DL3000",+ "- SC1010"+ ]+ expected = ConfigFile (Just ["DL3000", "SC1010"]) Nothing+ in assertConfig expected (Bytes.unlines configFile) - it "Parses config with only trustedRegistries" $- let configFile =- [ "trustedRegistries:"- , "- hub.docker.com"- , "- my.shady.xyz"- ]- expected = ConfigFile Nothing (Just ["hub.docker.com", "my.shady.xyz"])- in assertConfig expected (Bytes.unlines configFile)+ it "Parses config with only trustedRegistries" $+ let configFile =+ [ "trustedRegistries:",+ "- hub.docker.com",+ "- my.shady.xyz"+ ]+ expected = ConfigFile Nothing (Just ["hub.docker.com", "my.shady.xyz"])+ in assertConfig expected (Bytes.unlines configFile) - it "Parses full file" $- let configFile =- [ "trustedRegistries:"- , "- hub.docker.com"- , ""- , "ignored:"- , "- DL3000"- ]- expected = ConfigFile (Just ["DL3000"]) (Just ["hub.docker.com"])- in assertConfig expected (Bytes.unlines configFile)+ it "Parses full file" $+ let configFile =+ [ "trustedRegistries:",+ "- hub.docker.com",+ "",+ "ignored:",+ "- DL3000"+ ]+ expected = ConfigFile (Just ["DL3000"]) (Just ["hub.docker.com"])+ in assertConfig expected (Bytes.unlines configFile) assertConfig :: HasCallStack => ConfigFile -> Bytes.ByteString -> Assertion assertConfig config s =- case Yaml.decode1Strict s of- Left (_, err) ->- assertFailure err- Right result ->- checkResult result+ case Yaml.decode1Strict s of+ Left (_, err) ->+ assertFailure err+ Right result ->+ checkResult result where checkResult result =- unless (result == config) $- assertFailure ("Config \n\n" ++ show config ++ "\n\n is not \n\n" ++ show result)+ unless (result == config) $+ assertFailure ("Config \n\n" ++ show config ++ "\n\n is not \n\n" ++ show result)
test/Spec.hs view
@@ -1,1292 +1,1307 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE OverloadedLists #-}-import Test.HUnit hiding (Label)-import Test.Hspec-import Control.Monad (when, unless)--import Hadolint.Formatter.TTY (formatError, formatChecks)-import Hadolint.Rules--import Language.Docker.Parser-import Language.Docker.Syntax-import Data.Semigroup ((<>))-import qualified Data.Text as Text--import qualified ConfigSpec--main :: IO ()-main =- hspec $ do- describe "FROM rules" $ do- it "no untagged" $ ruleCatches noUntagged "FROM debian"- it "no untagged with name" $ ruleCatches noUntagged "FROM debian AS builder"- it "explicit latest" $ ruleCatches noLatestTag "FROM debian:latest"- it "explicit latest with name" $ ruleCatches noLatestTag "FROM debian:latest AS builder"- it "explicit tagged" $ ruleCatchesNot noLatestTag "FROM debian:jessie"- it "explicit platform flag" $ ruleCatches noPlatformFlag "FROM --platform=linux debian:jessie"- it "no platform flag" $ ruleCatchesNot noPlatformFlag "FROM debian:jessie"- it "explicit SHA" $- ruleCatchesNot noLatestTag- "FROM hub.docker.io/debian@sha256:\- \7959ed6f7e35f8b1aaa06d1d8259d4ee25aa85a086d5c125480c333183f9deeb"- it "explicit tagged with name" $- ruleCatchesNot noLatestTag "FROM debian:jessie AS builder"- it "untagged digest is not an error" $- ruleCatchesNot noUntagged "FROM ruby@sha256:f1dbca0f5dbc9"- it "untagged digest is not an error" $- ruleCatchesNot noUntagged "FROM ruby:2"- it "local aliases are OK to be untagged" $- let dockerFile =- [ "FROM golang:1.9.3-alpine3.7 AS build"- , "RUN foo"- , "FROM build as unit-test"- , "RUN bar"- , "FROM alpine:3.7"- , "RUN baz"- ]- in do- ruleCatchesNot noUntagged $ Text.unlines dockerFile- onBuildRuleCatchesNot noUntagged $ Text.unlines dockerFile- it "other untagged cases are not ok" $- let dockerFile =- [ "FROM golang:1.9.3-alpine3.7 AS build"- , "RUN foo"- , "FROM node as unit-test"- , "RUN bar"- , "FROM alpine:3.7"- , "RUN baz"- ]- in do- ruleCatches noUntagged $ Text.unlines dockerFile- onBuildRuleCatches noUntagged $ Text.unlines dockerFile- --- describe "no root or sudo rules" $ do- it "sudo" $ do- ruleCatches noSudo "RUN sudo apt-get update"- onBuildRuleCatches noSudo "RUN sudo apt-get update"-- it "last user should not be root" $- let dockerFile =- [ "FROM scratch"- , "USER root"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "no root" $- let dockerFile =- [ "FROM scratch"- , "USER foo"- ]- in ruleCatchesNot noRootUser $ Text.unlines dockerFile-- it "no root UID" $- let dockerFile =- [ "FROM scratch"- , "USER 0"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "no root:root" $- let dockerFile =- [ "FROM scratch"- , "USER root:root"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "no UID:GID" $- let dockerFile =- [ "FROM scratch"- , "USER 0:0"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "can switch back to non root" $- let dockerFile =- [ "FROM scratch"- , "USER root"- , "RUN something"- , "USER foo"- ]- in ruleCatchesNot noRootUser $ Text.unlines dockerFile-- it "warns on transitive root user" $- let dockerFile =- [ "FROM debian as base"- , "USER root"- , "RUN something"- , "FROM base"- , "RUN something else"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "warns on multiple stages" $- let dockerFile =- [ "FROM debian as base"- , "USER root"- , "RUN something"- , "FROM scratch"- , "USER foo"- , "RUN something else"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile-- it "does not warn when switching in multiple stages" $- let dockerFile =- [ "FROM debian as base"- , "USER root"- , "RUN something"- , "USER foo"- , "FROM scratch"- , "RUN something else"- ]- in ruleCatchesNot noRootUser $ Text.unlines dockerFile-- it "install sudo" $ do- ruleCatchesNot noSudo "RUN apt-get install sudo"- onBuildRuleCatchesNot noSudo "RUN apt-get install sudo"- it "sudo chained programs" $ do- ruleCatches noSudo "RUN apt-get update && sudo apt-get install"- onBuildRuleCatches noSudo "RUN apt-get update && sudo apt-get install"- --- describe "invalid CMD rules" $ do- it "invalid cmd" $ do- ruleCatches invalidCmd "RUN top"- onBuildRuleCatches invalidCmd "RUN top"- it "install ssh" $ do- ruleCatchesNot invalidCmd "RUN apt-get install ssh"- onBuildRuleCatchesNot invalidCmd "RUN apt-get install ssh"- --- describe "gem" $- describe "version pinning" $ do- describe "i" $ do- it "unpinned" $ do- ruleCatches gemVersionPinned "RUN gem i bundler"- onBuildRuleCatches gemVersionPinned "RUN gem i bundler"- it "pinned" $ do- ruleCatchesNot gemVersionPinned "RUN gem i bundler:1"- onBuildRuleCatchesNot gemVersionPinned "RUN gem i bundler:1"- it "multi" $ do- ruleCatches gemVersionPinned "RUN gem i bunlder:1 nokogiri"- onBuildRuleCatches gemVersionPinned "RUN gem i bunlder:1 nokogiri"- ruleCatchesNot gemVersionPinned "RUN gem i bunlder:1 nokogirii:1"- onBuildRuleCatchesNot gemVersionPinned "RUN gem i bunlder:1 nokogiri:1"- describe "install" $ do- it "unpinned" $ do- ruleCatches gemVersionPinned "RUN gem install bundler"- onBuildRuleCatches gemVersionPinned "RUN gem install bundler"- it "pinned" $ do- ruleCatchesNot gemVersionPinned "RUN gem install bundler:1"- onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler:1"- it "does not warn on -v" $ do- ruleCatchesNot gemVersionPinned "RUN gem install bundler -v '2.0.1'"- onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler -v '2.0.1'"- it "does not warn on --version without =" $ do- ruleCatchesNot gemVersionPinned "RUN gem install bundler --version '2.0.1'"- onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler --version '2.0.1'"- it "does not warn on --version with =" $ do- ruleCatchesNot gemVersionPinned "RUN gem install bundler --version='2.0.1'"- onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler --version='2.0.1'"- it "does not warn on extra flags" $ do- ruleCatchesNot gemVersionPinned "RUN gem install bundler:2.0.1 -- --use-system-libraries=true"- onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler:2.0.1 -- --use-system-libraries=true"- --- describe "yum rules" $ do- it "yum update" $ do- ruleCatches noYumUpdate "RUN yum update"- onBuildRuleCatches noYumUpdate "RUN yum update"- it "yum version pinning" $ do- ruleCatches yumVersionPinned "RUN yum install -y tomcat && yum clean all"- onBuildRuleCatches yumVersionPinned "RUN yum install -y tomcat && yum clean all"- it "yum no clean all" $ do- ruleCatches yumCleanup "RUN yum install -y mariadb-10.4"- onBuildRuleCatches yumCleanup "RUN yum install -y mariadb-10.4"- it "yum non-interactive" $ do- ruleCatches yumYes "RUN yum install httpd-2.4.24 && yum clean all"- onBuildRuleCatches yumYes "RUN yum install httpd-2.4.24 && yum clean all"- --- describe "apt-get rules" $ do- it "apt" $- let dockerFile =- [ "FROM ubuntu"- , "RUN apt install python"- ]- in do- ruleCatches noApt $ Text.unlines dockerFile- onBuildRuleCatches noApt $ Text.unlines dockerFile- it "apt-get upgrade" $ do- ruleCatches noAptGetUpgrade "RUN apt-get update && apt-get upgrade"- onBuildRuleCatches noAptGetUpgrade "RUN apt-get update && apt-get upgrade"- it "apt-get version pinning" $ do- ruleCatches aptGetVersionPinned "RUN apt-get update && apt-get install python"- onBuildRuleCatches aptGetVersionPinned "RUN apt-get update && apt-get install python"- it "apt-get no cleanup" $- let dockerFile =- [ "FROM scratch"- , "RUN apt-get update && apt-get install python"- ]- in do- ruleCatches aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile- it "apt-get cleanup in stage image" $- let dockerFile =- [ "FROM ubuntu as foo"- , "RUN apt-get update && apt-get install python"- , "FROM scratch"- , "RUN echo hey!"- ]- in do- ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile- it "apt-get no cleanup in last stage" $- let dockerFile =- [ "FROM ubuntu as foo"- , "RUN hey!"- , "FROM scratch"- , "RUN apt-get update && apt-get install python"- ]- in do- ruleCatches aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile- it "apt-get no cleanup in intermediate stage" $- let dockerFile =- [ "FROM ubuntu as foo"- , "RUN apt-get update && apt-get install python"- , "FROM foo"- , "RUN hey!"- ]- in do- ruleCatches aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile- it "no warn apt-get cleanup in intermediate stage that cleans lists" $- let dockerFile =- [ "FROM ubuntu as foo"- , "RUN apt-get update && apt-get install python && rm -rf /var/lib/apt/lists/*"- , "FROM foo"- , "RUN hey!"- ]- in do- ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile- it "no warn apt-get cleanup in intermediate stage when stage not used later" $- let dockerFile =- [ "FROM ubuntu as foo"- , "RUN apt-get update && apt-get install python"- , "FROM scratch"- , "RUN hey!"- ]- in do- ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile- it "apt-get cleanup" $- let dockerFile =- [ "FROM scratch"- , "RUN apt-get update && apt-get install python && rm -rf /var/lib/apt/lists/*"- ]- in do- ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile-- it "apt-get pinned chained" $- let dockerFile =- [ "RUN apt-get update \\"- , " && apt-get -yqq --no-install-recommends install nodejs=0.10 \\"- , " && rm -rf /var/lib/apt/lists/*"- ]- in do- ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile-- it "apt-get pinned regression" $- let dockerFile =- [ "RUN apt-get update && apt-get install --no-install-recommends -y \\"- , "python-demjson=2.2.2* \\"- , "wget=1.16.1* \\"- , "git=1:2.5.0* \\"- , "ruby=1:2.1.*"- ]- in do- ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile-- it "has deprecated maintainer" $- ruleCatches hasNoMaintainer "FROM busybox\nMAINTAINER hudu@mail.com"- --- describe "apk add rules" $ do- it "apk upgrade" $ do- ruleCatches noApkUpgrade "RUN apk update && apk upgrade"- onBuildRuleCatches noApkUpgrade "RUN apk update && apk upgrade"- it "apk add version pinning single" $ do- ruleCatches apkAddVersionPinned "RUN apk add flex"- onBuildRuleCatches apkAddVersionPinned "RUN apk add flex"- it "apk add no version pinning single" $ do- ruleCatchesNot apkAddVersionPinned "RUN apk add flex=2.6.4-r1"- onBuildRuleCatchesNot apkAddVersionPinned "RUN apk add flex=2.6.4-r1"- it "apk add version pinned chained" $- let dockerFile =- [ "RUN apk add --no-cache flex=2.6.4-r1 \\"- , " && pip install -r requirements.txt"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- it "apk add version pinned regression" $- let dockerFile =- [ "RUN apk add --no-cache \\"- , "flex=2.6.4-r1 \\"- , "libffi=3.2.1-r3 \\"- , "python2=2.7.13-r1 \\"- , "libbz2=1.0.6-r5"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- it "apk add version pinned regression - one missed" $- let dockerFile =- [ "RUN apk add --no-cache \\"- , "flex=2.6.4-r1 \\"- , "libffi \\"- , "python2=2.7.13-r1 \\"- , "libbz2=1.0.6-r5"- ]- in do- ruleCatches apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatches apkAddVersionPinned $ Text.unlines dockerFile- it "apk add with --no-cache" $ do- ruleCatches apkAddNoCache "RUN apk add flex=2.6.4-r1"- onBuildRuleCatches apkAddNoCache "RUN apk add flex=2.6.4-r1"- it "apk add without --no-cache" $ do- ruleCatchesNot apkAddNoCache "RUN apk add --no-cache flex=2.6.4-r1"- onBuildRuleCatchesNot apkAddNoCache "RUN apk add --no-cache flex=2.6.4-r1"- it "apk add virtual package" $- let dockerFile =- [ "RUN apk add \\"- , "--virtual build-dependencies \\"- , "python-dev=1.1.1 build-base=2.2.2 wget=3.3.3 \\"- , "&& pip install -r requirements.txt \\"- , "&& python setup.py install \\"- , "&& apk del build-dependencies"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- it "apk add with repository without equal sign" $- let dockerFile =- [ "RUN apk add --no-cache \\"- , "--repository https://nl.alpinelinux.org/alpine/edge/testing \\"- , "flow=0.78.0-r0"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- it "apk add with repository with equal sign" $- let dockerFile =- [ "RUN apk add --no-cache \\"- , "--repository=https://nl.alpinelinux.org/alpine/edge/testing \\"- , "flow=0.78.0-r0"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- it "apk add with repository (-X) without equal sign" $- let dockerFile =- [ "RUN apk add --no-cache \\"- , "-X https://nl.alpinelinux.org/alpine/edge/testing \\"- , "flow=0.78.0-r0"- ]- in do- ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile- --- describe "EXPOSE rules" $ do- it "invalid port" $ ruleCatches invalidPort "EXPOSE 80000"- it "valid port" $ ruleCatchesNot invalidPort "EXPOSE 60000"- --- describe "pip pinning" $ do- it "pip2 version not pinned" $ do- ruleCatches pipVersionPinned "RUN pip2 install MySQL_python"- onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"- it "pip3 version not pinned" $ do- ruleCatches pipVersionPinned "RUN pip3 install MySQL_python"- onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"- it "pip3 version pinned" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"- it "pip install requirements" $ do- ruleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"- it "pip install requirements with long flag" $ do- ruleCatchesNot pipVersionPinned "RUN pip install --requirement requirements.txt"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install --requirement requirements.txt"- it "pip install use setup.py" $ do- ruleCatchesNot pipVersionPinned "RUN pip install ."- onBuildRuleCatchesNot pipVersionPinned "RUN pip install ."- it "pip version not pinned" $ do- ruleCatches pipVersionPinned "RUN pip install MySQL_python"- onBuildRuleCatches pipVersionPinned "RUN pip install MySQL_python"- it "pip version pinned" $ do- ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2"- it "pip version pinned with ~= operator" $ do- ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python~=1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python~=1.2.2"- it "pip version pinned with === operator" $ do- ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python===1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python===1.2.2"- it "pip version pinned with flag --ignore-installed" $ do- ruleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"- it "pip version pinned with flag --build" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install --build /opt/yamllint yamllint==1.20.0"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --build /opt/yamllint yamllint==1.20.0"- it "pip version pinned with flag --prefix" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install --prefix /opt/yamllint yamllint==1.20.0"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --prefix /opt/yamllint yamllint==1.20.0"- it "pip version pinned with flag --root" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install --root /opt/yamllint yamllint==1.20.0"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --root /opt/yamllint yamllint==1.20.0"- it "pip version pinned with flag --target" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install --target /opt/yamllint yamllint==1.20.0"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --target /opt/yamllint yamllint==1.20.0"- it "pip version pinned with flag --trusted-host" $ do- ruleCatchesNot pipVersionPinned "RUN pip3 install --trusted-host host example==1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --trusted-host host example==1.2.2"- it "pip version pinned with python -m" $ do- ruleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"- onBuildRuleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"- it "pip version not pinned with python -m" $ do- ruleCatches pipVersionPinned "RUN python -m pip install example"- onBuildRuleCatches pipVersionPinned "RUN python -m pip install --index-url url example"- it "pip install git" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install git+https://github.com/rtfd/r-ext.git@0.6-alpha#egg=r-ext"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install git+https://github.com/rtfd/r-ext.git@0.6-alpha#egg=r-ext"- it "pip install unversioned git" $ do- ruleCatches- pipVersionPinned- "RUN pip install git+https://github.com/rtfd/read-ext.git#egg=read-ext"- onBuildRuleCatches- pipVersionPinned- "RUN pip install git+https://github.com/rtfd/read-ext.git#egg=read-ext"- it "pip install upper bound" $ do- ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster>=0.7'"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster>=0.7'"- it "pip install lower bound" $ do- ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"- it "pip install excluded version" $ do- ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster!=0.7'"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster!=0.7'"- it "pip install user directory" $ do- ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --user"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --user"- it "pip install no pip version check" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install MySQL_python==1.2.2 --disable-pip-version-check"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install MySQL_python==1.2.2 --disable-pip-version-check"- it "pip install --index-url" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"- it "pip install index-url with -i flag" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"- it "pip install --index-url with --extra-index-url" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo --extra-index-url https://ex-eg.io/foo foobar==1.0.0"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://eg.com/foo --extra-index-url https://ex-eg.io/foo foobar==1.0.0"- it "pip install no cache dir" $ do- ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --no-cache-dir"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --no-cache-dir"- it "pip install constraints file - long version argument" $ do- ruleCatchesNot pipVersionPinned "RUN pip install pykafka --constraint http://foo.bar.baz"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install pykafka --constraint http://foo.bar.baz"- it "pip install constraints file - short version argument" $ do- ruleCatchesNot pipVersionPinned "RUN pip install pykafka -c http://foo.bar.baz"- onBuildRuleCatchesNot pipVersionPinned "RUN pip install pykafka -c http://foo.bar.baz"- it "pip install --index-url with --extra-index-url with basic auth" $ do- ruleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://user:pass@eg.com/foo --extra-index-url https://user:pass@ex-eg.io/foo foobar==1.0.0"- onBuildRuleCatchesNot- pipVersionPinned- "RUN pip install --index-url https://user:pass@eg.com/foo --extra-index-url https://user:pass@ex-eg.io/foo foobar==1.0.0"- --- describe "npm pinning" $ do- it "version pinned in package.json" $ do- ruleCatchesNot npmVersionPinned "RUN npm install"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install"- it "version pinned in package.json with arguments" $ do- ruleCatchesNot npmVersionPinned "RUN npm install --progress=false"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install --progress=false"- it "version pinned" $ do- ruleCatchesNot npmVersionPinned "RUN npm install express@4.1.1"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install express@4.1.1"- it "version pinned with scope" $ do- ruleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0\""- onBuildRuleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0\""- it "version pinned multiple packages" $ do- ruleCatchesNot npmVersionPinned "RUN npm install express@\"4.1.1\" sax@0.1.1"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install express@\"4.1.1\" sax@0.1.1"- it "version pinned with --global" $ do- ruleCatchesNot npmVersionPinned "RUN npm install --global express@\"4.1.1\""- onBuildRuleCatchesNot npmVersionPinned "RUN npm install --global express@\"4.1.1\""- it "version pinned with -g" $ do- ruleCatchesNot npmVersionPinned "RUN npm install -g express@\"4.1.1\""- onBuildRuleCatchesNot npmVersionPinned "RUN npm install -g express@\"4.1.1\""- it "version does not have to be pinned for tarball suffix .tar" $ do- ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar"- it "version does not have to be pinned for tarball suffix .tar.gz" $ do- ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar.gz"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar.gz"- it "version does not have to be pinned for tarball suffix .tgz" $ do- ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tgz"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tgz"- it "version does not have to be pinned for folder - absolute path" $ do- ruleCatchesNot npmVersionPinned "RUN npm install /folder"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install /folder"- it "version does not have to be pinned for folder - relative path from current folder" $ do- ruleCatchesNot npmVersionPinned "RUN npm install ./folder"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install ./folder"- it "version does not have to be pinned for folder - relative path to parent folder" $ do- ruleCatchesNot npmVersionPinned "RUN npm install ../folder"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install ../folder"- it "version does not have to be pinned for folder - relative path from home" $ do- ruleCatchesNot npmVersionPinned "RUN npm install ~/folder"- onBuildRuleCatchesNot npmVersionPinned "RUN npm install ~/folder"- it "commit pinned for git+ssh" $ do- ruleCatchesNot- npmVersionPinned- "RUN npm install git+ssh://git@github.com:npm/npm.git#v1.0.27"- onBuildRuleCatchesNot- npmVersionPinned- "RUN npm install git+ssh://git@github.com:npm/npm.git#v1.0.27"- it "commit pinned for git+http" $ do- ruleCatchesNot- npmVersionPinned- "RUN npm install git+http://isaacs@github.com/npm/npm#semver:^5.0"- onBuildRuleCatchesNot- npmVersionPinned- "RUN npm install git+http://isaacs@github.com/npm/npm#semver:^5.0"- it "commit pinned for git+https" $ do- ruleCatchesNot- npmVersionPinned- "RUN npm install git+https://isaacs@github.com/npm/npm.git#v1.0.27"- onBuildRuleCatchesNot- npmVersionPinned- "RUN npm install git+https://isaacs@github.com/npm/npm.git#v1.0.27"- it "commit pinned for git" $ do- ruleCatchesNot- npmVersionPinned- "RUN npm install git://github.com/npm/npm.git#v1.0.27"- onBuildRuleCatchesNot- npmVersionPinned- "RUN npm install git://github.com/npm/npm.git#v1.0.27"- it "npm run install is fine" $ do- ruleCatchesNot- npmVersionPinned- "RUN npm run --crazy install"- onBuildRuleCatchesNot- npmVersionPinned- "RUN npm run --crazy install"-- --version range is not supported- it "version pinned with scope" $ do- ruleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0 <0.2.0\""- onBuildRuleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0 <0.2.0\""- it "version not pinned" $ do- ruleCatches npmVersionPinned "RUN npm install express"- onBuildRuleCatches npmVersionPinned "RUN npm install express"- it "version not pinned with scope" $ do- ruleCatches npmVersionPinned "RUN npm install @myorg/privatepackage"- onBuildRuleCatches npmVersionPinned "RUN npm install @myorg/privatepackage"- it "version not pinned multiple packages" $ do- ruleCatches npmVersionPinned "RUN npm install express sax@0.1.1"- onBuildRuleCatches npmVersionPinned "RUN npm install express sax@0.1.1"- it "version not pinned with --global" $ do- ruleCatches npmVersionPinned "RUN npm install --global express"- onBuildRuleCatches npmVersionPinned "RUN npm install --global express"- it "commit not pinned for git+ssh" $ do- ruleCatches npmVersionPinned "RUN npm install git+ssh://git@github.com:npm/npm.git"- onBuildRuleCatches npmVersionPinned "RUN npm install git+ssh://git@github.com:npm/npm.git"- it "commit not pinned for git+http" $ do- ruleCatches npmVersionPinned "RUN npm install git+http://isaacs@github.com/npm/npm"- onBuildRuleCatches npmVersionPinned "RUN npm install git+http://isaacs@github.com/npm/npm"- it "commit not pinned for git+https" $ do- ruleCatches- npmVersionPinned- "RUN npm install git+https://isaacs@github.com/npm/npm.git"- onBuildRuleCatches- npmVersionPinned- "RUN npm install git+https://isaacs@github.com/npm/npm.git"- it "commit not pinned for git" $ do- ruleCatches npmVersionPinned "RUN npm install git://github.com/npm/npm.git"- onBuildRuleCatches npmVersionPinned "RUN npm install git://github.com/npm/npm.git"- --- describe "use SHELL" $ do- it "RUN ln" $ do- ruleCatches useShell "RUN ln -sfv /bin/bash /bin/sh"- onBuildRuleCatches useShell "RUN ln -sfv /bin/bash /bin/sh"- it "RUN ln with unrelated symlinks" $ do- ruleCatchesNot useShell "RUN ln -sf /bin/true /sbin/initctl"- onBuildRuleCatchesNot useShell "RUN ln -sf /bin/true /sbin/initctl"- it "RUN ln with multiple acceptable commands" $ do- ruleCatchesNot useShell "RUN ln -s foo bar && unrelated && something_with /bin/sh"- onBuildRuleCatchesNot useShell "RUN ln -s foo bar && unrelated && something_with /bin/sh"- --- --- describe "Shellcheck" $ do- it "runs shellchek on RUN instructions" $ do- ruleCatches shellcheck "RUN echo $MISSING_QUOTES"- onBuildRuleCatches shellcheck "RUN echo $MISSING_QUOTES"- it "not warns on valid scripts" $ do- ruleCatchesNot shellcheck "RUN echo foo"- onBuildRuleCatchesNot shellcheck "RUN echo foo"-- it "Does not complain on default env vars" $- let dockerFile = Text.unlines- [ "RUN echo \"$HTTP_PROXY\""- , "RUN echo \"$http_proxy\""- , "RUN echo \"$HTTPS_PROXY\""- , "RUN echo \"$https_proxy\""- , "RUN echo \"$FTP_PROXY\""- , "RUN echo \"$ftp_proxy\""- , "RUN echo \"$NO_PROXY\""- , "RUN echo \"$no_proxy\""- ]- in do- ruleCatchesNot shellcheck dockerFile- onBuildRuleCatchesNot shellcheck dockerFile-- it "Complain on missing env vars" $- let dockerFile = Text.unlines- [ "RUN echo \"$RTTP_PROXY\""- ]- in do- ruleCatches shellcheck dockerFile- onBuildRuleCatches shellcheck dockerFile-- it "Is aware of ARGS and ENV" $- let dockerFile = Text.unlines- [ "ARG foo=bar"- , "ARG another_foo"- , "ENV bar=10 baz=20"- , "RUN echo \"$foo\""- , "RUN echo \"$another_foo\""- , "RUN echo \"$bar\""- , "RUN echo \"$baz\""- ]- in do- ruleCatchesNot shellcheck dockerFile- onBuildRuleCatchesNot shellcheck dockerFile-- it "Resets env vars after a FROM" $- let dockerFile = Text.unlines- [ "ARG foo=bar"- , "ARG another_foo"- , "ENV bar=10 baz=20"- , "FROM debian"- , "RUN echo \"$foo\""- ]- in do- ruleCatches shellcheck dockerFile- onBuildRuleCatches shellcheck dockerFile-- it "Defaults the shell to sh" $- let dockerFile = Text.unlines- [ "RUN echo $RANDOM"- ]- in do- ruleCatches shellcheck dockerFile- onBuildRuleCatches shellcheck dockerFile-- it "Can change the shell check to bash" $- let dockerFile = Text.unlines- [ "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]"- , "RUN echo $RANDOM"- ]- in do- ruleCatchesNot shellcheck dockerFile- onBuildRuleCatchesNot shellcheck dockerFile-- it "Resets the SHELL to sh after a FROM" $- let dockerFile = Text.unlines- [ "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]"- , "FROM debian"- , "RUN echo $RANDOM"- ]- in do- ruleCatches shellcheck dockerFile- onBuildRuleCatches shellcheck dockerFile-- it "Does not complain on ash shell" $- let dockerFile = Text.unlines- [ "SHELL [\"/bin/ash\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN echo hello"- ]- in do- ruleCatchesNot shellcheck dockerFile- onBuildRuleCatchesNot shellcheck dockerFile-- it "Does not complain on powershell" $- let dockerFile = Text.unlines- [ "SHELL [\"pwsh\", \"-c\"]"- , "RUN Get-Variable PSVersionTable | Select-Object -ExpandProperty Value"- ]- in do- ruleCatchesNot shellcheck dockerFile- onBuildRuleCatchesNot shellcheck dockerFile- --- --- describe "COPY rules" $ do- it "use add" $ ruleCatches useAdd "COPY packaged-app.tar /usr/src/app"- it "use not add" $ ruleCatchesNot useAdd "COPY package.json /usr/src/app"- --- describe "other rules" $ do- it "apt-get auto yes" $ do- ruleCatches aptGetYes "RUN apt-get install python"- onBuildRuleCatches aptGetYes "RUN apt-get install python"- it "apt-get yes shortflag" $ do- ruleCatchesNot aptGetYes "RUN apt-get install -yq python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get install -yq python"- it "apt-get yes quiet level 2 implies -y" $ do- ruleCatchesNot aptGetYes "RUN apt-get install -qq python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get install -qq python"- it "apt-get yes different pos" $ do- ruleCatchesNot aptGetYes "RUN apt-get install -y python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get install -y python"- it "apt-get with auto yes" $ do- ruleCatchesNot aptGetYes "RUN apt-get -y install python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get -y install python"- it "apt-get with auto expanded yes" $ do- ruleCatchesNot aptGetYes "RUN apt-get --yes install python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get --yes install python"- it "apt-get with assume-yes" $ do- ruleCatchesNot aptGetYes "RUN apt-get --assume-yes install python"- onBuildRuleCatchesNot aptGetYes "RUN apt-get --assume-yes install python"- it "apt-get install recommends" $ do- ruleCatchesNot- aptGetNoRecommends- "RUN apt-get install --no-install-recommends python"- onBuildRuleCatchesNot- aptGetNoRecommends- "RUN apt-get install --no-install-recommends python"- it "apt-get no install recommends" $ do- ruleCatches aptGetNoRecommends "RUN apt-get install python"- onBuildRuleCatches aptGetNoRecommends "RUN apt-get install python"- it "apt-get no install recommends" $ do- ruleCatches aptGetNoRecommends "RUN apt-get -y install python"- onBuildRuleCatches aptGetNoRecommends "RUN apt-get -y install python"- it "apt-get no install recommends via option" $ do- ruleCatchesNot aptGetNoRecommends "RUN apt-get -o APT::Install-Recommends=false install python"- onBuildRuleCatchesNot aptGetNoRecommends "RUN apt-get -o APT::Install-Recommends=false install python"- it "apt-get version" $ do- ruleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"- onBuildRuleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"- it "apt-get version" $ do- ruleCatchesNot aptGetVersionPinned "RUN apt-get install ./wkhtmltox_0.12.5-1.bionic_amd64.deb"- onBuildRuleCatchesNot aptGetVersionPinned "RUN apt-get install ./wkhtmltox_0.12.5-1.bionic_amd64.deb"- it "apt-get pinned" $ do- ruleCatchesNot- aptGetVersionPinned- "RUN apt-get -y --no-install-recommends install nodejs=0.10"- onBuildRuleCatchesNot- aptGetVersionPinned- "RUN apt-get -y --no-install-recommends install nodejs=0.10"- it "apt-get tolerate target-release" $- let dockerFile =- [ "RUN set -e &&\\"- , " echo \"deb http://http.debian.net/debian jessie-backports main\" \- \> /etc/apt/sources.list.d/jessie-backports.list &&\\"- , " apt-get update &&\\"- , " apt-get install -y --no-install-recommends -t jessie-backports \- \openjdk-8-jdk=8u131-b11-1~bpo8+1 &&\\"- , " rm -rf /var/lib/apt/lists/*"- ]- in do- ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile- onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile-- it "has maintainer" $ ruleCatches hasNoMaintainer "FROM debian\nMAINTAINER Lukas"- it "has maintainer first" $ ruleCatches hasNoMaintainer "MAINTAINER Lukas\nFROM DEBIAN"- it "has no maintainer" $ ruleCatchesNot hasNoMaintainer "FROM debian"- it "using add" $ ruleCatches copyInsteadAdd "ADD file /usr/src/app/"-- it "many cmds" $- let dockerFile =- [ "FROM debian"- , "CMD bash"- , "RUN foo"- , "CMD another"- ]- in ruleCatches multipleCmds $ Text.unlines dockerFile-- it "single cmds, different stages" $- let dockerFile =- [ "FROM debian as distro1"- , "CMD bash"- , "RUN foo"- , "FROM debian as distro2"- , "CMD another"- ]- in ruleCatchesNot multipleCmds $ Text.unlines dockerFile-- it "many cmds, different stages" $- let dockerFile =- [ "FROM debian as distro1"- , "CMD bash"- , "RUN foo"- , "CMD another"- , "FROM debian as distro2"- , "CMD another"- ]- in ruleCatches multipleCmds $ Text.unlines dockerFile-- it "single cmd" $ ruleCatchesNot multipleCmds "CMD /bin/true"- it "no cmd" $ ruleCatchesNot multipleEntrypoints "FROM busybox"-- it "many entrypoints" $- let dockerFile =- [ "FROM debian"- , "ENTRYPOINT bash"- , "RUN foo"- , "ENTRYPOINT another"- ]- in ruleCatches multipleEntrypoints $ Text.unlines dockerFile-- it "single entrypoint, different stages" $- let dockerFile =- [ "FROM debian as distro1"- , "ENTRYPOINT bash"- , "RUN foo"- , "FROM debian as distro2"- , "ENTRYPOINT another"- ]- in ruleCatchesNot multipleEntrypoints $ Text.unlines dockerFile-- it "many entrypoints, different stages" $- let dockerFile =- [ "FROM debian as distro1"- , "ENTRYPOINT bash"- , "RUN foo"- , "ENTRYPOINT another"- , "FROM debian as distro2"- , "ENTRYPOINT another"- ]- in ruleCatches multipleEntrypoints $ Text.unlines dockerFile- it "single entry" $ ruleCatchesNot multipleEntrypoints "ENTRYPOINT /bin/true"- it "no entry" $ ruleCatchesNot multipleEntrypoints "FROM busybox"- it "workdir variable" $ ruleCatchesNot absoluteWorkdir "WORKDIR ${work}"- it "scratch" $ ruleCatchesNot noUntagged "FROM scratch"- --- describe "add files and archives" $ do- it "add for tar" $ ruleCatchesNot copyInsteadAdd "ADD file.tar /usr/src/app/"- it "add for zip" $ ruleCatchesNot copyInsteadAdd "ADD file.zip /usr/src/app/"- it "add for gzip" $ ruleCatchesNot copyInsteadAdd "ADD file.gz /usr/src/app/"- it "add for bz2" $ ruleCatchesNot copyInsteadAdd "ADD file.bz2 /usr/src/app/"- it "add for xz" $ ruleCatchesNot copyInsteadAdd "ADD file.xz /usr/src/app/"- it "add for tgz" $ ruleCatchesNot copyInsteadAdd "ADD file.tgz /usr/src/app/"- it "add for url" $ ruleCatchesNot copyInsteadAdd "ADD http://file.com /usr/src/app/"- --- describe "copy last argument" $ do- it "no warn on 2 args" $ ruleCatchesNot copyEndingSlash "COPY foo bar"- it "warn on 3 args" $ ruleCatches copyEndingSlash "COPY foo bar baz"- it "no warn on 3 args" $ ruleCatchesNot copyEndingSlash "COPY foo bar baz/"- --- describe "copy from existing alias" $ do- it "warn on missing alias" $ ruleCatches copyFromExists "COPY --from=foo bar ."- it "warn on alias defined after" $- let dockerFile =- [ "FROM scratch"- , "COPY --from=build foo ."- , "FROM node as build"- , "RUN baz"- ]- in ruleCatches copyFromExists $ Text.unlines dockerFile- it "don't warn on correctly defined aliases" $- let dockerFile =- [ "FROM scratch as build"- , "RUN foo"- , "FROM node"- , "COPY --from=build foo ."- , "RUN baz"- ]- in ruleCatchesNot copyFromExists $ Text.unlines dockerFile- --- describe "copy from own FROM" $ do- it "warn on copying from your the same FROM" $- let dockerFile =- [ "FROM node as foo"- , "COPY --from=foo bar ."- ]- in ruleCatches copyFromAnother $ Text.unlines dockerFile- it "don't warn on copying form other sources" $- let dockerFile =- [ "FROM scratch as build"- , "RUN foo"- , "FROM node as run"- , "COPY --from=build foo ."- , "RUN baz"- ]- in ruleCatchesNot copyFromAnother $ Text.unlines dockerFile- --- describe "Duplicate aliases" $ do- it "warn on duplicate aliases" $- let dockerFile =- [ "FROM node as foo"- , "RUN something"- , "FROM scratch as foo"- , "RUN something"- ]- in ruleCatches fromAliasUnique $ Text.unlines dockerFile- it "don't warn on unique aliases" $- let dockerFile =- [ "FROM scratch as build"- , "RUN foo"- , "FROM node as run"- , "RUN baz"- ]- in ruleCatchesNot fromAliasUnique $ Text.unlines dockerFile- --- describe "format error" $- it "display error after line pos" $ do- let ast = parseText "FOM debian:jessie"- expectedMsg = "<string>:1:1 unexpected 'F' expecting '#', ADD, ARG, CMD, COPY, ENTRYPOINT, " <>- "ENV, EXPOSE, FROM, HEALTHCHECK, LABEL, MAINTAINER, ONBUILD, RUN, SHELL, STOPSIGNAL, " <>- "USER, VOLUME, WORKDIR, or end of input "- case ast of- Left err -> assertEqual "Unexpected error msg" expectedMsg (formatError err)- Right _ -> assertFailure "AST should fail parsing"- --- describe "Rules can be ignored with inline comments" $ do- it "ignores single rule" $- let dockerFile =- [ "FROM ubuntu"- , "# hadolint ignore=DL3002"- , "USER root"- ]- in ruleCatchesNot noRootUser $ Text.unlines dockerFile- it "ignores only the given rule" $- let dockerFile =- [ "FROM scratch"- , "# hadolint ignore=DL3001"- , "USER root"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile- it "ignores only the given rule, when multiple passed" $- let dockerFile =- [ "FROM scratch"- , "# hadolint ignore=DL3001,DL3002"- , "USER root"- ]- in ruleCatchesNot noRootUser $ Text.unlines dockerFile- it "ignores the rule only if directly above the instruction" $- let dockerFile =- [ "# hadolint ignore=DL3001,DL3002"- , "FROM ubuntu"- , "USER root"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile- it "won't ignore the rule if passed invalid rule names" $- let dockerFile =- [ "FROM scratch"- , "# hadolint ignore=crazy,DL3002"- , "USER root"- ]- in ruleCatches noRootUser $ Text.unlines dockerFile- it "ignores multiple rules correctly, even with some extra whitespace" $- let dockerFile =- [ "FROM node as foo"- , "# hadolint ignore=DL3023, DL3021"- , "COPY --from=foo bar baz ."- ]- in do- ruleCatchesNot copyFromAnother $ Text.unlines dockerFile- ruleCatchesNot copyEndingSlash $ Text.unlines dockerFile- --- describe "JSON notation in ENTRYPOINT and CMD" $ do- it "warn on ENTRYPOINT" $- let dockerFile =- [ "FROM node as foo"- , "ENTRYPOINT something"- ]- in ruleCatches useJsonArgs $ Text.unlines dockerFile- it "don't warn on ENTRYPOINT json notation" $- let dockerFile =- [ "FROM scratch as build"- , "ENTRYPOINT [\"foo\", \"bar\"]"- ]- in ruleCatchesNot useJsonArgs $ Text.unlines dockerFile- it "warn on CMD" $- let dockerFile =- [ "FROM node as foo"- , "CMD something"- ]- in ruleCatches useJsonArgs $ Text.unlines dockerFile- it "don't warn on CMD json notation" $- let dockerFile =- [ "FROM scratch as build"- , "CMD [\"foo\", \"bar\"]"- , "CMD [ \"foo\", \"bar\" ]"- ]- in ruleCatchesNot useJsonArgs $ Text.unlines dockerFile-- --- describe "Detects missing pipefail option" $ do- it "warn on missing pipefail" $- let dockerFile =- [ "FROM scratch"- , "RUN wget -O - https://some.site | wc -l > /number"- ]- in ruleCatches usePipefail $ Text.unlines dockerFile- it "don't warn on commands with no pipes" $- let dockerFile =- [ "FROM scratch as build"- , "RUN wget -O - https://some.site && wc -l file > /number"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "don't warn on commands with pipes and the pipefail option" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "don't warn on commands with pipes and the pipefail option 2" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/bash\", \"-e\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "don't warn on commands with pipes and the pipefail option 3" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/bash\", \"-o\", \"errexit\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "don't warn on commands with pipes and the pipefail zsh" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/zsh\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "don't warn on powershell" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"pwsh\", \"-c\"]"- , "RUN Get-Variable PSVersionTable | Select-Object -ExpandProperty Value"- ]- in ruleCatchesNot usePipefail $ Text.unlines dockerFile- it "warns when using plain sh" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/sh\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatches usePipefail $ Text.unlines dockerFile- it "warn on missing pipefail in the next image" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- , "FROM scratch as build2"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatches usePipefail $ Text.unlines dockerFile- it "warn on missing pipefail if next SHELL is not using it" $- let dockerFile =- [ "FROM scratch as build"- , "SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- , "SHELL [\"/bin/sh\", \"-c\"]"- , "RUN wget -O - https://some.site | wc -l file > /number"- ]- in ruleCatches usePipefail $ Text.unlines dockerFile- --- describe "Allowed docker registries" $ do- it "warn on non-allowed registry" $- let dockerFile =- [ "FROM random.com/debian"- ]- in ruleCatches (registryIsAllowed ["docker.io"]) $ Text.unlines dockerFile- it "don't warn on empty allowed registries" $- let dockerFile =- [ "FROM random.com/debian"- ]- in ruleCatchesNot (registryIsAllowed []) $ Text.unlines dockerFile- it "don't warn on allowed registries" $- let dockerFile =- [ "FROM random.com/debian"- ]- in ruleCatchesNot (registryIsAllowed ["x.com", "random.com"]) $ Text.unlines dockerFile- it "doesn't warn on scratch image" $- let dockerFile =- [ "FROM scratch"- ]- in ruleCatchesNot (registryIsAllowed ["x.com", "random.com"]) $ Text.unlines dockerFile- it "allows boths all forms of docker.io" $- let dockerFile =- [ "FROM ubuntu:18.04 AS builder1"- , "FROM zemanlx/ubuntu:18.04 AS builder2"- , "FROM docker.io/zemanlx/ubuntu:18.04 AS builder3"- ]- in ruleCatchesNot (registryIsAllowed ["docker.io"]) $ Text.unlines dockerFile-- it "allows using previous stages" $- let dockerFile =- [ "FROM random.com/foo AS builder1"- , "FROM builder1 AS builder2"- ]- in ruleCatchesNot (registryIsAllowed ["random.com"]) $ Text.unlines dockerFile- --- describe "Wget or Curl" $ do- it "warns when using both wget and curl" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- , "RUN curl localhost"- ]- in ruleCatches wgetOrCurl $ Text.unlines dockerFile- it "warns when using both wget and curl in same instruction" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz && curl localhost"- ]- in ruleCatches wgetOrCurl $ Text.unlines dockerFile- it "does not warn when using only wget" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- ]- in ruleCatchesNot wgetOrCurl $ Text.unlines dockerFile- it "does not warn when using both curl and wget in different stages" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- , "FROM scratch"- , "RUN curl localhost"- ]- in ruleCatchesNot wgetOrCurl $ Text.unlines dockerFile- it "does not warns when using both, on a single stage" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- , "RUN curl localhost"- , "FROM scratch"- , "RUN curl localhost"- ]- in ruleCatches wgetOrCurl $ Text.unlines dockerFile- it "only warns on the relevant RUN instruction" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- , "RUN curl my.xyz"- , "RUN echo hello"- ]- in assertChecks wgetOrCurl- (Text.unlines dockerFile)- (\checks -> assertBool- "Expecting warnings only in 1 RUN instruction"- (length checks == 1)- )- it "only warns on many relevant RUN instructions" $- let dockerFile =- [ "FROM node as foo"- , "RUN wget my.xyz"- , "RUN curl my.xyz"- , "RUN echo hello"- , "RUN wget foo.com"- ]- in assertChecks wgetOrCurl- (Text.unlines dockerFile)- (\checks -> assertBool- "Expecting warnings only in 2 RUN instructions"- (length checks == 2)- )- --- describe "Regression Tests" $- it "Comments with backslashes at the end are just comments" $- let dockerFile =- [ "FROM alpine:3.6"- , "# The following comment makes hadolint still complain about DL4006"- , "# \\"- , "# should solve DL4006"- , "SHELL [\"/bin/sh\", \"-o\", \"pipefail\", \"-c\"]"- , "# RUN with pipe. causes DL4006, but should be fixed by above SHELL"- , "RUN echo \"kaka\" | sed 's/a/o/g' >> /root/afile"- ]- in ruleCatches usePipefail $ Text.unlines dockerFile-- -- Run tests for the Config module- ConfigSpec.tests--assertChecks :: HasCallStack => Rule -> Text.Text -> ([RuleCheck] -> IO a) -> IO a-assertChecks rule s makeAssertions =- case parseText (s <> "\n") of- Left err -> assertFailure $ show err- Right dockerFile -> makeAssertions $ analyze [rule] dockerFile--assertOnBuildChecks :: HasCallStack => Rule -> Text.Text -> ([RuleCheck] -> IO a) -> IO a-assertOnBuildChecks rule s makeAssertions =- case parseText (s <> "\n") of- Left err -> assertFailure $ show err- Right dockerFile -> checkOnBuild dockerFile- where- checkOnBuild dockerFile = makeAssertions $ analyze [rule] (fmap wrapInOnBuild dockerFile)- wrapInOnBuild (InstructionPos (Run args) so li) = InstructionPos (OnBuild (Run args)) so li- wrapInOnBuild i = i--selectChecksWithLines :: [RuleCheck] -> [RuleCheck]-selectChecksWithLines checks = [c | c <- checks, linenumber c <= 0]---- Assert a failed check exists for rule-ruleCatches :: HasCallStack => Rule -> Text.Text -> Assertion-ruleCatches rule s = assertChecks rule s f- where- f checks = do- when (null checks) $- assertFailure "I was expecting to catch at least one error"- assertBool "Incorrect line number for result" $ null $ selectChecksWithLines checks--onBuildRuleCatches :: HasCallStack => Rule -> Text.Text -> Assertion-onBuildRuleCatches rule s = assertOnBuildChecks rule s f- where- f checks = do- when (length checks /= 1) $- assertFailure (Text.unpack . Text.unlines . formatChecks $ checks)- assertBool "Incorrect line number for result" $ null $ selectChecksWithLines checks--ruleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion-ruleCatchesNot rule s = assertChecks rule s f- where- f checks =- unless (null checks) $- assertFailure $ "Not expecting the following errors: \n" ++- (Text.unpack . Text.unlines . formatChecks $ checks)--onBuildRuleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion-onBuildRuleCatchesNot rule s = assertOnBuildChecks rule s f- where- f checks =- unless (null checks) $- assertFailure $ "Not expecting the following errors: \n" ++- (Text.unpack . Text.unlines . formatChecks $ checks)+{-# LANGUAGE OverloadedLists #-}+{-# LANGUAGE OverloadedStrings #-}++import qualified ConfigSpec+import Control.Monad (unless, when)+import Data.Semigroup ((<>))+import qualified Data.Text as Text+import Hadolint.Formatter.TTY (formatChecks, formatError)+import Hadolint.Rules+import Language.Docker.Parser+import Language.Docker.Syntax+import Test.HUnit hiding (Label)+import Test.Hspec++main :: IO ()+main =+ hspec $ do+ describe "FROM rules" $ do+ it "no untagged" $ ruleCatches noUntagged "FROM debian"+ it "no untagged with name" $ ruleCatches noUntagged "FROM debian AS builder"+ it "explicit latest" $ ruleCatches noLatestTag "FROM debian:latest"+ it "explicit latest with name" $ ruleCatches noLatestTag "FROM debian:latest AS builder"+ it "explicit tagged" $ ruleCatchesNot noLatestTag "FROM debian:jessie"+ it "explicit platform flag" $ ruleCatches noPlatformFlag "FROM --platform=linux debian:jessie"+ it "no platform flag" $ ruleCatchesNot noPlatformFlag "FROM debian:jessie"+ it "explicit SHA" $+ ruleCatchesNot+ noLatestTag+ "FROM hub.docker.io/debian@sha256:\+ \7959ed6f7e35f8b1aaa06d1d8259d4ee25aa85a086d5c125480c333183f9deeb"+ it "explicit tagged with name" $+ ruleCatchesNot noLatestTag "FROM debian:jessie AS builder"+ it "untagged digest is not an error" $+ ruleCatchesNot noUntagged "FROM ruby@sha256:f1dbca0f5dbc9"+ it "untagged digest is not an error" $+ ruleCatchesNot noUntagged "FROM ruby:2"+ it "local aliases are OK to be untagged" $+ let dockerFile =+ [ "FROM golang:1.9.3-alpine3.7 AS build",+ "RUN foo",+ "FROM build as unit-test",+ "RUN bar",+ "FROM alpine:3.7",+ "RUN baz"+ ]+ in do+ ruleCatchesNot noUntagged $ Text.unlines dockerFile+ onBuildRuleCatchesNot noUntagged $ Text.unlines dockerFile+ it "other untagged cases are not ok" $+ let dockerFile =+ [ "FROM golang:1.9.3-alpine3.7 AS build",+ "RUN foo",+ "FROM node as unit-test",+ "RUN bar",+ "FROM alpine:3.7",+ "RUN baz"+ ]+ in do+ ruleCatches noUntagged $ Text.unlines dockerFile+ onBuildRuleCatches noUntagged $ Text.unlines dockerFile+ --+ describe "no root or sudo rules" $ do+ it "sudo" $ do+ ruleCatches noSudo "RUN sudo apt-get update"+ onBuildRuleCatches noSudo "RUN sudo apt-get update"++ it "last user should not be root" $+ let dockerFile =+ [ "FROM scratch",+ "USER root"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "no root" $+ let dockerFile =+ [ "FROM scratch",+ "USER foo"+ ]+ in ruleCatchesNot noRootUser $ Text.unlines dockerFile++ it "no root UID" $+ let dockerFile =+ [ "FROM scratch",+ "USER 0"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "no root:root" $+ let dockerFile =+ [ "FROM scratch",+ "USER root:root"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "no UID:GID" $+ let dockerFile =+ [ "FROM scratch",+ "USER 0:0"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "can switch back to non root" $+ let dockerFile =+ [ "FROM scratch",+ "USER root",+ "RUN something",+ "USER foo"+ ]+ in ruleCatchesNot noRootUser $ Text.unlines dockerFile++ it "warns on transitive root user" $+ let dockerFile =+ [ "FROM debian as base",+ "USER root",+ "RUN something",+ "FROM base",+ "RUN something else"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "warns on multiple stages" $+ let dockerFile =+ [ "FROM debian as base",+ "USER root",+ "RUN something",+ "FROM scratch",+ "USER foo",+ "RUN something else"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile++ it "does not warn when switching in multiple stages" $+ let dockerFile =+ [ "FROM debian as base",+ "USER root",+ "RUN something",+ "USER foo",+ "FROM scratch",+ "RUN something else"+ ]+ in ruleCatchesNot noRootUser $ Text.unlines dockerFile++ it "install sudo" $ do+ ruleCatchesNot noSudo "RUN apt-get install sudo"+ onBuildRuleCatchesNot noSudo "RUN apt-get install sudo"+ it "sudo chained programs" $ do+ ruleCatches noSudo "RUN apt-get update && sudo apt-get install"+ onBuildRuleCatches noSudo "RUN apt-get update && sudo apt-get install"+ --+ describe "invalid CMD rules" $ do+ it "invalid cmd" $ do+ ruleCatches invalidCmd "RUN top"+ onBuildRuleCatches invalidCmd "RUN top"+ it "install ssh" $ do+ ruleCatchesNot invalidCmd "RUN apt-get install ssh"+ onBuildRuleCatchesNot invalidCmd "RUN apt-get install ssh"+ --+ describe "gem" $+ describe "version pinning" $ do+ describe "i" $ do+ it "unpinned" $ do+ ruleCatches gemVersionPinned "RUN gem i bundler"+ onBuildRuleCatches gemVersionPinned "RUN gem i bundler"+ it "pinned" $ do+ ruleCatchesNot gemVersionPinned "RUN gem i bundler:1"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem i bundler:1"+ it "multi" $ do+ ruleCatches gemVersionPinned "RUN gem i bunlder:1 nokogiri"+ onBuildRuleCatches gemVersionPinned "RUN gem i bunlder:1 nokogiri"+ ruleCatchesNot gemVersionPinned "RUN gem i bunlder:1 nokogirii:1"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem i bunlder:1 nokogiri:1"+ describe "install" $ do+ it "unpinned" $ do+ ruleCatches gemVersionPinned "RUN gem install bundler"+ onBuildRuleCatches gemVersionPinned "RUN gem install bundler"+ it "pinned" $ do+ ruleCatchesNot gemVersionPinned "RUN gem install bundler:1"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler:1"+ it "does not warn on -v" $ do+ ruleCatchesNot gemVersionPinned "RUN gem install bundler -v '2.0.1'"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler -v '2.0.1'"+ it "does not warn on --version without =" $ do+ ruleCatchesNot gemVersionPinned "RUN gem install bundler --version '2.0.1'"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler --version '2.0.1'"+ it "does not warn on --version with =" $ do+ ruleCatchesNot gemVersionPinned "RUN gem install bundler --version='2.0.1'"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler --version='2.0.1'"+ it "does not warn on extra flags" $ do+ ruleCatchesNot gemVersionPinned "RUN gem install bundler:2.0.1 -- --use-system-libraries=true"+ onBuildRuleCatchesNot gemVersionPinned "RUN gem install bundler:2.0.1 -- --use-system-libraries=true"+ --+ describe "yum rules" $ do+ it "yum update" $ do+ ruleCatches noYumUpdate "RUN yum update"+ onBuildRuleCatches noYumUpdate "RUN yum update"+ it "yum version pinning" $ do+ ruleCatches yumVersionPinned "RUN yum install -y tomcat && yum clean all"+ onBuildRuleCatches yumVersionPinned "RUN yum install -y tomcat && yum clean all"+ it "yum no clean all" $ do+ ruleCatches yumCleanup "RUN yum install -y mariadb-10.4"+ onBuildRuleCatches yumCleanup "RUN yum install -y mariadb-10.4"+ it "yum non-interactive" $ do+ ruleCatches yumYes "RUN yum install httpd-2.4.24 && yum clean all"+ onBuildRuleCatches yumYes "RUN yum install httpd-2.4.24 && yum clean all"+ --+ describe "apt-get rules" $ do+ it "apt" $+ let dockerFile =+ [ "FROM ubuntu",+ "RUN apt install python"+ ]+ in do+ ruleCatches noApt $ Text.unlines dockerFile+ onBuildRuleCatches noApt $ Text.unlines dockerFile+ it "apt-get upgrade" $ do+ ruleCatches noAptGetUpgrade "RUN apt-get update && apt-get upgrade"+ onBuildRuleCatches noAptGetUpgrade "RUN apt-get update && apt-get upgrade"+ it "apt-get version pinning" $ do+ ruleCatches aptGetVersionPinned "RUN apt-get update && apt-get install python"+ onBuildRuleCatches aptGetVersionPinned "RUN apt-get update && apt-get install python"+ it "apt-get no cleanup" $+ let dockerFile =+ [ "FROM scratch",+ "RUN apt-get update && apt-get install python"+ ]+ in do+ ruleCatches aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile+ it "apt-get cleanup in stage image" $+ let dockerFile =+ [ "FROM ubuntu as foo",+ "RUN apt-get update && apt-get install python",+ "FROM scratch",+ "RUN echo hey!"+ ]+ in do+ ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ it "apt-get no cleanup in last stage" $+ let dockerFile =+ [ "FROM ubuntu as foo",+ "RUN hey!",+ "FROM scratch",+ "RUN apt-get update && apt-get install python"+ ]+ in do+ ruleCatches aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile+ it "apt-get no cleanup in intermediate stage" $+ let dockerFile =+ [ "FROM ubuntu as foo",+ "RUN apt-get update && apt-get install python",+ "FROM foo",+ "RUN hey!"+ ]+ in do+ ruleCatches aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatches aptGetCleanup $ Text.unlines dockerFile+ it "no warn apt-get cleanup in intermediate stage that cleans lists" $+ let dockerFile =+ [ "FROM ubuntu as foo",+ "RUN apt-get update && apt-get install python && rm -rf /var/lib/apt/lists/*",+ "FROM foo",+ "RUN hey!"+ ]+ in do+ ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ it "no warn apt-get cleanup in intermediate stage when stage not used later" $+ let dockerFile =+ [ "FROM ubuntu as foo",+ "RUN apt-get update && apt-get install python",+ "FROM scratch",+ "RUN hey!"+ ]+ in do+ ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ it "apt-get cleanup" $+ let dockerFile =+ [ "FROM scratch",+ "RUN apt-get update && apt-get install python && rm -rf /var/lib/apt/lists/*"+ ]+ in do+ ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile++ it "apt-get pinned chained" $+ let dockerFile =+ [ "RUN apt-get update \\",+ " && apt-get -yqq --no-install-recommends install nodejs=0.10 \\",+ " && rm -rf /var/lib/apt/lists/*"+ ]+ in do+ ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile++ it "apt-get pinned regression" $+ let dockerFile =+ [ "RUN apt-get update && apt-get install --no-install-recommends -y \\",+ "python-demjson=2.2.2* \\",+ "wget=1.16.1* \\",+ "git=1:2.5.0* \\",+ "ruby=1:2.1.*"+ ]+ in do+ ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile++ it "has deprecated maintainer" $+ ruleCatches hasNoMaintainer "FROM busybox\nMAINTAINER hudu@mail.com"+ --+ describe "apk add rules" $ do+ it "apk upgrade" $ do+ ruleCatches noApkUpgrade "RUN apk update && apk upgrade"+ onBuildRuleCatches noApkUpgrade "RUN apk update && apk upgrade"+ it "apk add version pinning single" $ do+ ruleCatches apkAddVersionPinned "RUN apk add flex"+ onBuildRuleCatches apkAddVersionPinned "RUN apk add flex"+ it "apk add no version pinning single" $ do+ ruleCatchesNot apkAddVersionPinned "RUN apk add flex=2.6.4-r1"+ onBuildRuleCatchesNot apkAddVersionPinned "RUN apk add flex=2.6.4-r1"+ it "apk add version pinned chained" $+ let dockerFile =+ [ "RUN apk add --no-cache flex=2.6.4-r1 \\",+ " && pip install -r requirements.txt"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add version pinned regression" $+ let dockerFile =+ [ "RUN apk add --no-cache \\",+ "flex=2.6.4-r1 \\",+ "libffi=3.2.1-r3 \\",+ "python2=2.7.13-r1 \\",+ "libbz2=1.0.6-r5"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add version pinned regression - one missed" $+ let dockerFile =+ [ "RUN apk add --no-cache \\",+ "flex=2.6.4-r1 \\",+ "libffi \\",+ "python2=2.7.13-r1 \\",+ "libbz2=1.0.6-r5"+ ]+ in do+ ruleCatches apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatches apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add with --no-cache" $ do+ ruleCatches apkAddNoCache "RUN apk add flex=2.6.4-r1"+ onBuildRuleCatches apkAddNoCache "RUN apk add flex=2.6.4-r1"+ it "apk add without --no-cache" $ do+ ruleCatchesNot apkAddNoCache "RUN apk add --no-cache flex=2.6.4-r1"+ onBuildRuleCatchesNot apkAddNoCache "RUN apk add --no-cache flex=2.6.4-r1"+ it "apk add virtual package" $+ let dockerFile =+ [ "RUN apk add \\",+ "--virtual build-dependencies \\",+ "python-dev=1.1.1 build-base=2.2.2 wget=3.3.3 \\",+ "&& pip install -r requirements.txt \\",+ "&& python setup.py install \\",+ "&& apk del build-dependencies"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add with repository without equal sign" $+ let dockerFile =+ [ "RUN apk add --no-cache \\",+ "--repository https://nl.alpinelinux.org/alpine/edge/testing \\",+ "flow=0.78.0-r0"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add with repository with equal sign" $+ let dockerFile =+ [ "RUN apk add --no-cache \\",+ "--repository=https://nl.alpinelinux.org/alpine/edge/testing \\",+ "flow=0.78.0-r0"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ it "apk add with repository (-X) without equal sign" $+ let dockerFile =+ [ "RUN apk add --no-cache \\",+ "-X https://nl.alpinelinux.org/alpine/edge/testing \\",+ "flow=0.78.0-r0"+ ]+ in do+ ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+ --+ describe "EXPOSE rules" $ do+ it "invalid port" $ ruleCatches invalidPort "EXPOSE 80000"+ it "valid port" $ ruleCatchesNot invalidPort "EXPOSE 60000"+ --+ describe "pip pinning" $ do+ it "pip2 version not pinned" $ do+ ruleCatches pipVersionPinned "RUN pip2 install MySQL_python"+ onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"+ it "pip3 version not pinned" $ do+ ruleCatches pipVersionPinned "RUN pip3 install MySQL_python"+ onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"+ it "pip3 version pinned" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"+ it "pip install requirements" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"+ it "pip install requirements with long flag" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install --requirement requirements.txt"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install --requirement requirements.txt"+ it "pip install use setup.py" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install ."+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install ."+ it "pip version not pinned" $ do+ ruleCatches pipVersionPinned "RUN pip install MySQL_python"+ onBuildRuleCatches pipVersionPinned "RUN pip install MySQL_python"+ it "pip version pinned" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2"+ it "pip version pinned with ~= operator" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python~=1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python~=1.2.2"+ it "pip version pinned with === operator" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python===1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python===1.2.2"+ it "pip version pinned with flag --ignore-installed" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"+ it "pip version pinned with flag --build" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install --build /opt/yamllint yamllint==1.20.0"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --build /opt/yamllint yamllint==1.20.0"+ it "pip version pinned with flag --prefix" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install --prefix /opt/yamllint yamllint==1.20.0"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --prefix /opt/yamllint yamllint==1.20.0"+ it "pip version pinned with flag --root" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install --root /opt/yamllint yamllint==1.20.0"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --root /opt/yamllint yamllint==1.20.0"+ it "pip version pinned with flag --target" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install --target /opt/yamllint yamllint==1.20.0"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --target /opt/yamllint yamllint==1.20.0"+ it "pip version pinned with flag --trusted-host" $ do+ ruleCatchesNot pipVersionPinned "RUN pip3 install --trusted-host host example==1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install --trusted-host host example==1.2.2"+ it "pip version pinned with python -m" $ do+ ruleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"+ onBuildRuleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"+ it "pip version not pinned with python -m" $ do+ ruleCatches pipVersionPinned "RUN python -m pip install example"+ onBuildRuleCatches pipVersionPinned "RUN python -m pip install --index-url url example"+ it "pip install git" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install git+https://github.com/rtfd/r-ext.git@0.6-alpha#egg=r-ext"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install git+https://github.com/rtfd/r-ext.git@0.6-alpha#egg=r-ext"+ it "pip install unversioned git" $ do+ ruleCatches+ pipVersionPinned+ "RUN pip install git+https://github.com/rtfd/read-ext.git#egg=read-ext"+ onBuildRuleCatches+ pipVersionPinned+ "RUN pip install git+https://github.com/rtfd/read-ext.git#egg=read-ext"+ it "pip install upper bound" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster>=0.7'"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster>=0.7'"+ it "pip install lower bound" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"+ it "pip install excluded version" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster!=0.7'"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster!=0.7'"+ it "pip install user directory" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --user"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --user"+ it "pip install no pip version check" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install MySQL_python==1.2.2 --disable-pip-version-check"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install MySQL_python==1.2.2 --disable-pip-version-check"+ it "pip install --index-url" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"+ it "pip install index-url with -i flag" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo foobar==1.0.0"+ it "pip install --index-url with --extra-index-url" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo --extra-index-url https://ex-eg.io/foo foobar==1.0.0"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://eg.com/foo --extra-index-url https://ex-eg.io/foo foobar==1.0.0"+ it "pip install no cache dir" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --no-cache-dir"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2 --no-cache-dir"+ it "pip install constraints file - long version argument" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install pykafka --constraint http://foo.bar.baz"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install pykafka --constraint http://foo.bar.baz"+ it "pip install constraints file - short version argument" $ do+ ruleCatchesNot pipVersionPinned "RUN pip install pykafka -c http://foo.bar.baz"+ onBuildRuleCatchesNot pipVersionPinned "RUN pip install pykafka -c http://foo.bar.baz"+ it "pip install --index-url with --extra-index-url with basic auth" $ do+ ruleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://user:pass@eg.com/foo --extra-index-url https://user:pass@ex-eg.io/foo foobar==1.0.0"+ onBuildRuleCatchesNot+ pipVersionPinned+ "RUN pip install --index-url https://user:pass@eg.com/foo --extra-index-url https://user:pass@ex-eg.io/foo foobar==1.0.0"+ --+ describe "npm pinning" $ do+ it "version pinned in package.json" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install"+ it "version pinned in package.json with arguments" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install --progress=false"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install --progress=false"+ it "version pinned" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install express@4.1.1"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install express@4.1.1"+ it "version pinned with scope" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0\""+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0\""+ it "version pinned multiple packages" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install express@\"4.1.1\" sax@0.1.1"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install express@\"4.1.1\" sax@0.1.1"+ it "version pinned with --global" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install --global express@\"4.1.1\""+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install --global express@\"4.1.1\""+ it "version pinned with -g" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install -g express@\"4.1.1\""+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install -g express@\"4.1.1\""+ it "version does not have to be pinned for tarball suffix .tar" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar"+ it "version does not have to be pinned for tarball suffix .tar.gz" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar.gz"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tar.gz"+ it "version does not have to be pinned for tarball suffix .tgz" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tgz"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install package-v1.2.3.tgz"+ it "version does not have to be pinned for folder - absolute path" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install /folder"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install /folder"+ it "version does not have to be pinned for folder - relative path from current folder" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install ./folder"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install ./folder"+ it "version does not have to be pinned for folder - relative path to parent folder" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install ../folder"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install ../folder"+ it "version does not have to be pinned for folder - relative path from home" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install ~/folder"+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install ~/folder"+ it "commit pinned for git+ssh" $ do+ ruleCatchesNot+ npmVersionPinned+ "RUN npm install git+ssh://git@github.com:npm/npm.git#v1.0.27"+ onBuildRuleCatchesNot+ npmVersionPinned+ "RUN npm install git+ssh://git@github.com:npm/npm.git#v1.0.27"+ it "commit pinned for git+http" $ do+ ruleCatchesNot+ npmVersionPinned+ "RUN npm install git+http://isaacs@github.com/npm/npm#semver:^5.0"+ onBuildRuleCatchesNot+ npmVersionPinned+ "RUN npm install git+http://isaacs@github.com/npm/npm#semver:^5.0"+ it "commit pinned for git+https" $ do+ ruleCatchesNot+ npmVersionPinned+ "RUN npm install git+https://isaacs@github.com/npm/npm.git#v1.0.27"+ onBuildRuleCatchesNot+ npmVersionPinned+ "RUN npm install git+https://isaacs@github.com/npm/npm.git#v1.0.27"+ it "commit pinned for git" $ do+ ruleCatchesNot+ npmVersionPinned+ "RUN npm install git://github.com/npm/npm.git#v1.0.27"+ onBuildRuleCatchesNot+ npmVersionPinned+ "RUN npm install git://github.com/npm/npm.git#v1.0.27"+ it "npm run install is fine" $ do+ ruleCatchesNot+ npmVersionPinned+ "RUN npm run --crazy install"+ onBuildRuleCatchesNot+ npmVersionPinned+ "RUN npm run --crazy install"++ --version range is not supported+ it "version pinned with scope" $ do+ ruleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0 <0.2.0\""+ onBuildRuleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0 <0.2.0\""+ it "version not pinned" $ do+ ruleCatches npmVersionPinned "RUN npm install express"+ onBuildRuleCatches npmVersionPinned "RUN npm install express"+ it "version not pinned with scope" $ do+ ruleCatches npmVersionPinned "RUN npm install @myorg/privatepackage"+ onBuildRuleCatches npmVersionPinned "RUN npm install @myorg/privatepackage"+ it "version not pinned multiple packages" $ do+ ruleCatches npmVersionPinned "RUN npm install express sax@0.1.1"+ onBuildRuleCatches npmVersionPinned "RUN npm install express sax@0.1.1"+ it "version not pinned with --global" $ do+ ruleCatches npmVersionPinned "RUN npm install --global express"+ onBuildRuleCatches npmVersionPinned "RUN npm install --global express"+ it "commit not pinned for git+ssh" $ do+ ruleCatches npmVersionPinned "RUN npm install git+ssh://git@github.com:npm/npm.git"+ onBuildRuleCatches npmVersionPinned "RUN npm install git+ssh://git@github.com:npm/npm.git"+ it "commit not pinned for git+http" $ do+ ruleCatches npmVersionPinned "RUN npm install git+http://isaacs@github.com/npm/npm"+ onBuildRuleCatches npmVersionPinned "RUN npm install git+http://isaacs@github.com/npm/npm"+ it "commit not pinned for git+https" $ do+ ruleCatches+ npmVersionPinned+ "RUN npm install git+https://isaacs@github.com/npm/npm.git"+ onBuildRuleCatches+ npmVersionPinned+ "RUN npm install git+https://isaacs@github.com/npm/npm.git"+ it "commit not pinned for git" $ do+ ruleCatches npmVersionPinned "RUN npm install git://github.com/npm/npm.git"+ onBuildRuleCatches npmVersionPinned "RUN npm install git://github.com/npm/npm.git"+ --+ describe "use SHELL" $ do+ it "RUN ln" $ do+ ruleCatches useShell "RUN ln -sfv /bin/bash /bin/sh"+ onBuildRuleCatches useShell "RUN ln -sfv /bin/bash /bin/sh"+ it "RUN ln with unrelated symlinks" $ do+ ruleCatchesNot useShell "RUN ln -sf /bin/true /sbin/initctl"+ onBuildRuleCatchesNot useShell "RUN ln -sf /bin/true /sbin/initctl"+ it "RUN ln with multiple acceptable commands" $ do+ ruleCatchesNot useShell "RUN ln -s foo bar && unrelated && something_with /bin/sh"+ onBuildRuleCatchesNot useShell "RUN ln -s foo bar && unrelated && something_with /bin/sh"+ --+ --+ describe "Shellcheck" $ do+ it "runs shellchek on RUN instructions" $ do+ ruleCatches shellcheck "RUN echo $MISSING_QUOTES"+ onBuildRuleCatches shellcheck "RUN echo $MISSING_QUOTES"+ it "not warns on valid scripts" $ do+ ruleCatchesNot shellcheck "RUN echo foo"+ onBuildRuleCatchesNot shellcheck "RUN echo foo"++ it "Does not complain on default env vars" $+ let dockerFile =+ Text.unlines+ [ "RUN echo \"$HTTP_PROXY\"",+ "RUN echo \"$http_proxy\"",+ "RUN echo \"$HTTPS_PROXY\"",+ "RUN echo \"$https_proxy\"",+ "RUN echo \"$FTP_PROXY\"",+ "RUN echo \"$ftp_proxy\"",+ "RUN echo \"$NO_PROXY\"",+ "RUN echo \"$no_proxy\""+ ]+ in do+ ruleCatchesNot shellcheck dockerFile+ onBuildRuleCatchesNot shellcheck dockerFile++ it "Complain on missing env vars" $+ let dockerFile =+ Text.unlines+ [ "RUN echo \"$RTTP_PROXY\""+ ]+ in do+ ruleCatches shellcheck dockerFile+ onBuildRuleCatches shellcheck dockerFile++ it "Is aware of ARGS and ENV" $+ let dockerFile =+ Text.unlines+ [ "ARG foo=bar",+ "ARG another_foo",+ "ENV bar=10 baz=20",+ "RUN echo \"$foo\"",+ "RUN echo \"$another_foo\"",+ "RUN echo \"$bar\"",+ "RUN echo \"$baz\""+ ]+ in do+ ruleCatchesNot shellcheck dockerFile+ onBuildRuleCatchesNot shellcheck dockerFile++ it "Resets env vars after a FROM" $+ let dockerFile =+ Text.unlines+ [ "ARG foo=bar",+ "ARG another_foo",+ "ENV bar=10 baz=20",+ "FROM debian",+ "RUN echo \"$foo\""+ ]+ in do+ ruleCatches shellcheck dockerFile+ onBuildRuleCatches shellcheck dockerFile++ it "Defaults the shell to sh" $+ let dockerFile =+ Text.unlines+ [ "RUN echo $RANDOM"+ ]+ in do+ ruleCatches shellcheck dockerFile+ onBuildRuleCatches shellcheck dockerFile++ it "Can change the shell check to bash" $+ let dockerFile =+ Text.unlines+ [ "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]",+ "RUN echo $RANDOM"+ ]+ in do+ ruleCatchesNot shellcheck dockerFile+ onBuildRuleCatchesNot shellcheck dockerFile++ it "Resets the SHELL to sh after a FROM" $+ let dockerFile =+ Text.unlines+ [ "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]",+ "FROM debian",+ "RUN echo $RANDOM"+ ]+ in do+ ruleCatches shellcheck dockerFile+ onBuildRuleCatches shellcheck dockerFile++ it "Does not complain on ash shell" $+ let dockerFile =+ Text.unlines+ [ "SHELL [\"/bin/ash\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN echo hello"+ ]+ in do+ ruleCatchesNot shellcheck dockerFile+ onBuildRuleCatchesNot shellcheck dockerFile++ it "Does not complain on powershell" $+ let dockerFile =+ Text.unlines+ [ "SHELL [\"pwsh\", \"-c\"]",+ "RUN Get-Variable PSVersionTable | Select-Object -ExpandProperty Value"+ ]+ in do+ ruleCatchesNot shellcheck dockerFile+ onBuildRuleCatchesNot shellcheck dockerFile+ --+ --+ describe "COPY rules" $ do+ it "use add" $ ruleCatches useAdd "COPY packaged-app.tar /usr/src/app"+ it "use not add" $ ruleCatchesNot useAdd "COPY package.json /usr/src/app"+ --+ describe "other rules" $ do+ it "apt-get auto yes" $ do+ ruleCatches aptGetYes "RUN apt-get install python"+ onBuildRuleCatches aptGetYes "RUN apt-get install python"+ it "apt-get yes shortflag" $ do+ ruleCatchesNot aptGetYes "RUN apt-get install -yq python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get install -yq python"+ it "apt-get yes quiet level 2 implies -y" $ do+ ruleCatchesNot aptGetYes "RUN apt-get install -qq python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get install -qq python"+ it "apt-get yes different pos" $ do+ ruleCatchesNot aptGetYes "RUN apt-get install -y python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get install -y python"+ it "apt-get with auto yes" $ do+ ruleCatchesNot aptGetYes "RUN apt-get -y install python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get -y install python"+ it "apt-get with auto expanded yes" $ do+ ruleCatchesNot aptGetYes "RUN apt-get --yes install python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get --yes install python"+ it "apt-get with assume-yes" $ do+ ruleCatchesNot aptGetYes "RUN apt-get --assume-yes install python"+ onBuildRuleCatchesNot aptGetYes "RUN apt-get --assume-yes install python"+ it "apt-get install recommends" $ do+ ruleCatchesNot+ aptGetNoRecommends+ "RUN apt-get install --no-install-recommends python"+ onBuildRuleCatchesNot+ aptGetNoRecommends+ "RUN apt-get install --no-install-recommends python"+ it "apt-get no install recommends" $ do+ ruleCatches aptGetNoRecommends "RUN apt-get install python"+ onBuildRuleCatches aptGetNoRecommends "RUN apt-get install python"+ it "apt-get no install recommends" $ do+ ruleCatches aptGetNoRecommends "RUN apt-get -y install python"+ onBuildRuleCatches aptGetNoRecommends "RUN apt-get -y install python"+ it "apt-get no install recommends via option" $ do+ ruleCatchesNot aptGetNoRecommends "RUN apt-get -o APT::Install-Recommends=false install python"+ onBuildRuleCatchesNot aptGetNoRecommends "RUN apt-get -o APT::Install-Recommends=false install python"+ it "apt-get version" $ do+ ruleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"+ onBuildRuleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"+ it "apt-get version" $ do+ ruleCatchesNot aptGetVersionPinned "RUN apt-get install ./wkhtmltox_0.12.5-1.bionic_amd64.deb"+ onBuildRuleCatchesNot aptGetVersionPinned "RUN apt-get install ./wkhtmltox_0.12.5-1.bionic_amd64.deb"+ it "apt-get pinned" $ do+ ruleCatchesNot+ aptGetVersionPinned+ "RUN apt-get -y --no-install-recommends install nodejs=0.10"+ onBuildRuleCatchesNot+ aptGetVersionPinned+ "RUN apt-get -y --no-install-recommends install nodejs=0.10"+ it "apt-get tolerate target-release" $+ let dockerFile =+ [ "RUN set -e &&\\",+ " echo \"deb http://http.debian.net/debian jessie-backports main\" \+ \> /etc/apt/sources.list.d/jessie-backports.list &&\\",+ " apt-get update &&\\",+ " apt-get install -y --no-install-recommends -t jessie-backports \+ \openjdk-8-jdk=8u131-b11-1~bpo8+1 &&\\",+ " rm -rf /var/lib/apt/lists/*"+ ]+ in do+ ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+ onBuildRuleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile++ it "has maintainer" $ ruleCatches hasNoMaintainer "FROM debian\nMAINTAINER Lukas"+ it "has maintainer first" $ ruleCatches hasNoMaintainer "MAINTAINER Lukas\nFROM DEBIAN"+ it "has no maintainer" $ ruleCatchesNot hasNoMaintainer "FROM debian"+ it "using add" $ ruleCatches copyInsteadAdd "ADD file /usr/src/app/"++ it "many cmds" $+ let dockerFile =+ [ "FROM debian",+ "CMD bash",+ "RUN foo",+ "CMD another"+ ]+ in ruleCatches multipleCmds $ Text.unlines dockerFile++ it "single cmds, different stages" $+ let dockerFile =+ [ "FROM debian as distro1",+ "CMD bash",+ "RUN foo",+ "FROM debian as distro2",+ "CMD another"+ ]+ in ruleCatchesNot multipleCmds $ Text.unlines dockerFile++ it "many cmds, different stages" $+ let dockerFile =+ [ "FROM debian as distro1",+ "CMD bash",+ "RUN foo",+ "CMD another",+ "FROM debian as distro2",+ "CMD another"+ ]+ in ruleCatches multipleCmds $ Text.unlines dockerFile++ it "single cmd" $ ruleCatchesNot multipleCmds "CMD /bin/true"+ it "no cmd" $ ruleCatchesNot multipleEntrypoints "FROM busybox"++ it "many entrypoints" $+ let dockerFile =+ [ "FROM debian",+ "ENTRYPOINT bash",+ "RUN foo",+ "ENTRYPOINT another"+ ]+ in ruleCatches multipleEntrypoints $ Text.unlines dockerFile++ it "single entrypoint, different stages" $+ let dockerFile =+ [ "FROM debian as distro1",+ "ENTRYPOINT bash",+ "RUN foo",+ "FROM debian as distro2",+ "ENTRYPOINT another"+ ]+ in ruleCatchesNot multipleEntrypoints $ Text.unlines dockerFile++ it "many entrypoints, different stages" $+ let dockerFile =+ [ "FROM debian as distro1",+ "ENTRYPOINT bash",+ "RUN foo",+ "ENTRYPOINT another",+ "FROM debian as distro2",+ "ENTRYPOINT another"+ ]+ in ruleCatches multipleEntrypoints $ Text.unlines dockerFile+ it "single entry" $ ruleCatchesNot multipleEntrypoints "ENTRYPOINT /bin/true"+ it "no entry" $ ruleCatchesNot multipleEntrypoints "FROM busybox"+ it "workdir variable" $ ruleCatchesNot absoluteWorkdir "WORKDIR ${work}"+ it "scratch" $ ruleCatchesNot noUntagged "FROM scratch"+ --+ describe "add files and archives" $ do+ it "add for tar" $ ruleCatchesNot copyInsteadAdd "ADD file.tar /usr/src/app/"+ it "add for zip" $ ruleCatchesNot copyInsteadAdd "ADD file.zip /usr/src/app/"+ it "add for gzip" $ ruleCatchesNot copyInsteadAdd "ADD file.gz /usr/src/app/"+ it "add for bz2" $ ruleCatchesNot copyInsteadAdd "ADD file.bz2 /usr/src/app/"+ it "add for xz" $ ruleCatchesNot copyInsteadAdd "ADD file.xz /usr/src/app/"+ it "add for tgz" $ ruleCatchesNot copyInsteadAdd "ADD file.tgz /usr/src/app/"+ it "add for url" $ ruleCatchesNot copyInsteadAdd "ADD http://file.com /usr/src/app/"+ --+ describe "copy last argument" $ do+ it "no warn on 2 args" $ ruleCatchesNot copyEndingSlash "COPY foo bar"+ it "warn on 3 args" $ ruleCatches copyEndingSlash "COPY foo bar baz"+ it "no warn on 3 args" $ ruleCatchesNot copyEndingSlash "COPY foo bar baz/"+ --+ describe "copy from existing alias" $ do+ it "warn on missing alias" $ ruleCatches copyFromExists "COPY --from=foo bar ."+ it "warn on alias defined after" $+ let dockerFile =+ [ "FROM scratch",+ "COPY --from=build foo .",+ "FROM node as build",+ "RUN baz"+ ]+ in ruleCatches copyFromExists $ Text.unlines dockerFile+ it "don't warn on correctly defined aliases" $+ let dockerFile =+ [ "FROM scratch as build",+ "RUN foo",+ "FROM node",+ "COPY --from=build foo .",+ "RUN baz"+ ]+ in ruleCatchesNot copyFromExists $ Text.unlines dockerFile+ --+ describe "copy from own FROM" $ do+ it "warn on copying from your the same FROM" $+ let dockerFile =+ [ "FROM node as foo",+ "COPY --from=foo bar ."+ ]+ in ruleCatches copyFromAnother $ Text.unlines dockerFile+ it "don't warn on copying form other sources" $+ let dockerFile =+ [ "FROM scratch as build",+ "RUN foo",+ "FROM node as run",+ "COPY --from=build foo .",+ "RUN baz"+ ]+ in ruleCatchesNot copyFromAnother $ Text.unlines dockerFile+ --+ describe "Duplicate aliases" $ do+ it "warn on duplicate aliases" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN something",+ "FROM scratch as foo",+ "RUN something"+ ]+ in ruleCatches fromAliasUnique $ Text.unlines dockerFile+ it "don't warn on unique aliases" $+ let dockerFile =+ [ "FROM scratch as build",+ "RUN foo",+ "FROM node as run",+ "RUN baz"+ ]+ in ruleCatchesNot fromAliasUnique $ Text.unlines dockerFile+ --+ describe "format error" $+ it "display error after line pos" $ do+ let ast = parseText "FOM debian:jessie"+ expectedMsg =+ "<string>:1:1 unexpected 'F' expecting '#', ADD, ARG, CMD, COPY, ENTRYPOINT, "+ <> "ENV, EXPOSE, FROM, HEALTHCHECK, LABEL, MAINTAINER, ONBUILD, RUN, SHELL, STOPSIGNAL, "+ <> "USER, VOLUME, WORKDIR, or end of input "+ case ast of+ Left err -> assertEqual "Unexpected error msg" expectedMsg (formatError err)+ Right _ -> assertFailure "AST should fail parsing"+ --+ describe "Rules can be ignored with inline comments" $ do+ it "ignores single rule" $+ let dockerFile =+ [ "FROM ubuntu",+ "# hadolint ignore=DL3002",+ "USER root"+ ]+ in ruleCatchesNot noRootUser $ Text.unlines dockerFile+ it "ignores only the given rule" $+ let dockerFile =+ [ "FROM scratch",+ "# hadolint ignore=DL3001",+ "USER root"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile+ it "ignores only the given rule, when multiple passed" $+ let dockerFile =+ [ "FROM scratch",+ "# hadolint ignore=DL3001,DL3002",+ "USER root"+ ]+ in ruleCatchesNot noRootUser $ Text.unlines dockerFile+ it "ignores the rule only if directly above the instruction" $+ let dockerFile =+ [ "# hadolint ignore=DL3001,DL3002",+ "FROM ubuntu",+ "USER root"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile+ it "won't ignore the rule if passed invalid rule names" $+ let dockerFile =+ [ "FROM scratch",+ "# hadolint ignore=crazy,DL3002",+ "USER root"+ ]+ in ruleCatches noRootUser $ Text.unlines dockerFile+ it "ignores multiple rules correctly, even with some extra whitespace" $+ let dockerFile =+ [ "FROM node as foo",+ "# hadolint ignore=DL3023, DL3021",+ "COPY --from=foo bar baz ."+ ]+ in do+ ruleCatchesNot copyFromAnother $ Text.unlines dockerFile+ ruleCatchesNot copyEndingSlash $ Text.unlines dockerFile+ --+ describe "JSON notation in ENTRYPOINT and CMD" $ do+ it "warn on ENTRYPOINT" $+ let dockerFile =+ [ "FROM node as foo",+ "ENTRYPOINT something"+ ]+ in ruleCatches useJsonArgs $ Text.unlines dockerFile+ it "don't warn on ENTRYPOINT json notation" $+ let dockerFile =+ [ "FROM scratch as build",+ "ENTRYPOINT [\"foo\", \"bar\"]"+ ]+ in ruleCatchesNot useJsonArgs $ Text.unlines dockerFile+ it "warn on CMD" $+ let dockerFile =+ [ "FROM node as foo",+ "CMD something"+ ]+ in ruleCatches useJsonArgs $ Text.unlines dockerFile+ it "don't warn on CMD json notation" $+ let dockerFile =+ [ "FROM scratch as build",+ "CMD [\"foo\", \"bar\"]",+ "CMD [ \"foo\", \"bar\" ]"+ ]+ in ruleCatchesNot useJsonArgs $ Text.unlines dockerFile++ --+ describe "Detects missing pipefail option" $ do+ it "warn on missing pipefail" $+ let dockerFile =+ [ "FROM scratch",+ "RUN wget -O - https://some.site | wc -l > /number"+ ]+ in ruleCatches usePipefail $ Text.unlines dockerFile+ it "don't warn on commands with no pipes" $+ let dockerFile =+ [ "FROM scratch as build",+ "RUN wget -O - https://some.site && wc -l file > /number"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "don't warn on commands with pipes and the pipefail option" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/bash\", \"-eo\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "don't warn on commands with pipes and the pipefail option 2" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/bash\", \"-e\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "don't warn on commands with pipes and the pipefail option 3" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/bash\", \"-o\", \"errexit\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "don't warn on commands with pipes and the pipefail zsh" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/zsh\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "don't warn on powershell" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"pwsh\", \"-c\"]",+ "RUN Get-Variable PSVersionTable | Select-Object -ExpandProperty Value"+ ]+ in ruleCatchesNot usePipefail $ Text.unlines dockerFile+ it "warns when using plain sh" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/sh\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatches usePipefail $ Text.unlines dockerFile+ it "warn on missing pipefail in the next image" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number",+ "FROM scratch as build2",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatches usePipefail $ Text.unlines dockerFile+ it "warn on missing pipefail if next SHELL is not using it" $+ let dockerFile =+ [ "FROM scratch as build",+ "SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number",+ "SHELL [\"/bin/sh\", \"-c\"]",+ "RUN wget -O - https://some.site | wc -l file > /number"+ ]+ in ruleCatches usePipefail $ Text.unlines dockerFile+ --+ describe "Allowed docker registries" $ do+ it "warn on non-allowed registry" $+ let dockerFile =+ [ "FROM random.com/debian"+ ]+ in ruleCatches (registryIsAllowed ["docker.io"]) $ Text.unlines dockerFile+ it "don't warn on empty allowed registries" $+ let dockerFile =+ [ "FROM random.com/debian"+ ]+ in ruleCatchesNot (registryIsAllowed []) $ Text.unlines dockerFile+ it "don't warn on allowed registries" $+ let dockerFile =+ [ "FROM random.com/debian"+ ]+ in ruleCatchesNot (registryIsAllowed ["x.com", "random.com"]) $ Text.unlines dockerFile+ it "doesn't warn on scratch image" $+ let dockerFile =+ [ "FROM scratch"+ ]+ in ruleCatchesNot (registryIsAllowed ["x.com", "random.com"]) $ Text.unlines dockerFile+ it "allows boths all forms of docker.io" $+ let dockerFile =+ [ "FROM ubuntu:18.04 AS builder1",+ "FROM zemanlx/ubuntu:18.04 AS builder2",+ "FROM docker.io/zemanlx/ubuntu:18.04 AS builder3"+ ]+ in ruleCatchesNot (registryIsAllowed ["docker.io"]) $ Text.unlines dockerFile++ it "allows using previous stages" $+ let dockerFile =+ [ "FROM random.com/foo AS builder1",+ "FROM builder1 AS builder2"+ ]+ in ruleCatchesNot (registryIsAllowed ["random.com"]) $ Text.unlines dockerFile+ --+ describe "Wget or Curl" $ do+ it "warns when using both wget and curl" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz",+ "RUN curl localhost"+ ]+ in ruleCatches wgetOrCurl $ Text.unlines dockerFile+ it "warns when using both wget and curl in same instruction" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz && curl localhost"+ ]+ in ruleCatches wgetOrCurl $ Text.unlines dockerFile+ it "does not warn when using only wget" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz"+ ]+ in ruleCatchesNot wgetOrCurl $ Text.unlines dockerFile+ it "does not warn when using both curl and wget in different stages" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz",+ "FROM scratch",+ "RUN curl localhost"+ ]+ in ruleCatchesNot wgetOrCurl $ Text.unlines dockerFile+ it "does not warns when using both, on a single stage" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz",+ "RUN curl localhost",+ "FROM scratch",+ "RUN curl localhost"+ ]+ in ruleCatches wgetOrCurl $ Text.unlines dockerFile+ it "only warns on the relevant RUN instruction" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz",+ "RUN curl my.xyz",+ "RUN echo hello"+ ]+ in assertChecks+ wgetOrCurl+ (Text.unlines dockerFile)+ ( \checks ->+ assertBool+ "Expecting warnings only in 1 RUN instruction"+ (length checks == 1)+ )+ it "only warns on many relevant RUN instructions" $+ let dockerFile =+ [ "FROM node as foo",+ "RUN wget my.xyz",+ "RUN curl my.xyz",+ "RUN echo hello",+ "RUN wget foo.com"+ ]+ in assertChecks+ wgetOrCurl+ (Text.unlines dockerFile)+ ( \checks ->+ assertBool+ "Expecting warnings only in 2 RUN instructions"+ (length checks == 2)+ )+ --+ describe "Regression Tests" $+ it "Comments with backslashes at the end are just comments" $+ let dockerFile =+ [ "FROM alpine:3.6",+ "# The following comment makes hadolint still complain about DL4006",+ "# \\",+ "# should solve DL4006",+ "SHELL [\"/bin/sh\", \"-o\", \"pipefail\", \"-c\"]",+ "# RUN with pipe. causes DL4006, but should be fixed by above SHELL",+ "RUN echo \"kaka\" | sed 's/a/o/g' >> /root/afile"+ ]+ in ruleCatches usePipefail $ Text.unlines dockerFile++ -- Run tests for the Config module+ ConfigSpec.tests++assertChecks :: HasCallStack => Rule -> Text.Text -> ([RuleCheck] -> IO a) -> IO a+assertChecks rule s makeAssertions =+ case parseText (s <> "\n") of+ Left err -> assertFailure $ show err+ Right dockerFile -> makeAssertions $ analyze [rule] dockerFile++assertOnBuildChecks :: HasCallStack => Rule -> Text.Text -> ([RuleCheck] -> IO a) -> IO a+assertOnBuildChecks rule s makeAssertions =+ case parseText (s <> "\n") of+ Left err -> assertFailure $ show err+ Right dockerFile -> checkOnBuild dockerFile+ where+ checkOnBuild dockerFile = makeAssertions $ analyze [rule] (fmap wrapInOnBuild dockerFile)+ wrapInOnBuild (InstructionPos (Run args) so li) = InstructionPos (OnBuild (Run args)) so li+ wrapInOnBuild i = i++selectChecksWithLines :: [RuleCheck] -> [RuleCheck]+selectChecksWithLines checks = [c | c <- checks, linenumber c <= 0]++-- Assert a failed check exists for rule+ruleCatches :: HasCallStack => Rule -> Text.Text -> Assertion+ruleCatches rule s = assertChecks rule s f+ where+ f checks = do+ when (null checks) $+ assertFailure "I was expecting to catch at least one error"+ assertBool "Incorrect line number for result" $ null $ selectChecksWithLines checks++onBuildRuleCatches :: HasCallStack => Rule -> Text.Text -> Assertion+onBuildRuleCatches rule s = assertOnBuildChecks rule s f+ where+ f checks = do+ when (length checks /= 1) $+ assertFailure (Text.unpack . Text.unlines . formatChecks $ checks)+ assertBool "Incorrect line number for result" $ null $ selectChecksWithLines checks++ruleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion+ruleCatchesNot rule s = assertChecks rule s f+ where+ f checks =+ unless (null checks) $+ assertFailure $+ "Not expecting the following errors: \n"+ ++ (Text.unpack . Text.unlines . formatChecks $ checks)++onBuildRuleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion+onBuildRuleCatchesNot rule s = assertOnBuildChecks rule s f+ where+ f checks =+ unless (null checks) $+ assertFailure $+ "Not expecting the following errors: \n"+ ++ (Text.unpack . Text.unlines . formatChecks $ checks)