packages feed

hadolint 1.7.1 → 1.7.2

raw patch · 4 files changed

+435/−203 lines, 4 filesdep +mtlPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: mtl

API changes (from Hackage documentation)

- Hadolint.Rules: bashCommands :: [String] -> [[String]]
- Hadolint.Rules: dropOptionsWithArg :: [String] -> [String] -> [String]
- Hadolint.Rules: findPackages :: [String] -> [String]
- Hadolint.Rules: isApkAdd :: [String] -> Bool
- Hadolint.Rules: isAptGetInstall :: [String] -> Bool
+ Hadolint.Bash: ParsedBash :: Text -> ParseResult -> ParsedBash
+ Hadolint.Bash: [original] :: ParsedBash -> Text
+ Hadolint.Bash: [parsed] :: ParsedBash -> ParseResult
+ Hadolint.Bash: allCommands :: (Token -> Bool) -> ParsedBash -> Bool
+ Hadolint.Bash: cmdHasArgs :: String -> [String] -> Token -> Bool
+ Hadolint.Bash: data ParsedBash
+ Hadolint.Bash: dropFlagArg :: [String] -> Token -> Token
+ Hadolint.Bash: findCommandNames :: ParsedBash -> [String]
+ Hadolint.Bash: findCommands :: ParsedBash -> [Token]
+ Hadolint.Bash: getAllArgs :: Token -> [String]
+ Hadolint.Bash: getAllFlags :: Token -> [(Token, String)]
+ Hadolint.Bash: getArgsNoFlags :: Token -> [String]
+ Hadolint.Bash: getCommandName :: Token -> Maybe String
+ Hadolint.Bash: hasFlag :: String -> Token -> Bool
+ Hadolint.Bash: noCommands :: (Token -> Bool) -> ParsedBash -> Bool
+ Hadolint.Bash: parseShell :: Text -> ParsedBash
+ Hadolint.Rules: type ParsedFile = [InstructionPos ParsedBash]
- Hadolint.Bash: shellcheck :: String -> [Comment]
+ Hadolint.Bash: shellcheck :: ParsedBash -> [Comment]
- Hadolint.Rules: allAliasedImages :: Dockerfile -> [(Linenumber, ImageAlias)]
+ Hadolint.Rules: allAliasedImages :: ParsedFile -> [(Linenumber, ImageAlias)]
- Hadolint.Rules: allFromImages :: Dockerfile -> [(Linenumber, BaseImage)]
+ Hadolint.Rules: allFromImages :: ParsedFile -> [(Linenumber, BaseImage)]
- Hadolint.Rules: allImageNames :: Dockerfile -> [(Linenumber, Text)]
+ Hadolint.Rules: allImageNames :: ParsedFile -> [(Linenumber, Text)]
- Hadolint.Rules: apkAddPackages :: [String] -> [String]
+ Hadolint.Rules: apkAddPackages :: ParsedBash -> [String]
- Hadolint.Rules: aptGetPackages :: [String] -> [String]
+ Hadolint.Rules: aptGetPackages :: ParsedBash -> [String]
- Hadolint.Rules: argumentsRule :: ([String] -> a) -> Arguments Text -> a
+ Hadolint.Rules: argumentsRule :: (ParsedBash -> a) -> Arguments ParsedBash -> a
- Hadolint.Rules: instructionRule :: Text -> Severity -> Text -> (Instruction Text -> Bool) -> Rule
+ Hadolint.Rules: instructionRule :: Text -> Severity -> Text -> (Instruction ParsedBash -> Bool) -> Rule
- Hadolint.Rules: instructionRuleLine :: Text -> Severity -> Text -> (Linenumber -> Instruction Text -> Bool) -> Rule
+ Hadolint.Rules: instructionRuleLine :: Text -> Severity -> Text -> (Linenumber -> Instruction ParsedBash -> Bool) -> Rule
- Hadolint.Rules: instructionRuleState :: Text -> Severity -> Text -> (state -> Linenumber -> Instruction Text -> (state, Bool)) -> state -> Rule
+ Hadolint.Rules: instructionRuleState :: Text -> Severity -> Text -> (state -> Linenumber -> Instruction ParsedBash -> (state, Bool)) -> state -> Rule
- Hadolint.Rules: mapInstructions :: Metadata -> (state -> Linenumber -> Instruction Text -> (state, Bool)) -> state -> Rule
+ Hadolint.Rules: mapInstructions :: Metadata -> (state -> Linenumber -> Instruction ParsedBash -> (state, Bool)) -> state -> Rule
- Hadolint.Rules: previouslyDefinedAliases :: Linenumber -> Dockerfile -> [Text]
+ Hadolint.Rules: previouslyDefinedAliases :: Linenumber -> ParsedFile -> [Text]
- Hadolint.Rules: shellcheckBash :: Dockerfile -> [RuleCheck]
+ Hadolint.Rules: shellcheckBash :: ParsedFile -> [RuleCheck]
- Hadolint.Rules: stripInstallPrefix :: (String -> Bool) -> [String] -> Maybe [String]
+ Hadolint.Rules: stripInstallPrefix :: [String] -> [String]
- Hadolint.Rules: type Rule = Dockerfile -> [RuleCheck]
+ Hadolint.Rules: type Rule = ParsedFile -> [RuleCheck]
- Hadolint.Rules: usingProgram :: String -> [String] -> Bool
+ Hadolint.Rules: usingProgram :: String -> ParsedBash -> Bool

Files

hadolint.cabal view
@@ -2,10 +2,10 @@ -- -- see: https://github.com/sol/hpack ----- hash: dee8c05033f3341030b3d3436b7ec3db7ceb472be45eb05a90bce71111365c2a+-- hash: 1968b5091609b7ed9433b56e82cdefd1ad484dce803b5f213ac9a3438de4313f  name:           hadolint-version:        1.7.1+version:        1.7.2 synopsis:       Dockerfile Linter JavaScript API description:    A smarter Dockerfile linter that helps you build best practice Docker images. category:       Development@@ -36,6 +36,7 @@     , containers     , language-docker >=6.0.1 && <7     , megaparsec >=6.4+    , mtl     , split >=0.2     , text     , void
src/Hadolint/Bash.hs view
@@ -1,16 +1,101 @@ module Hadolint.Bash where +import Control.Monad.Writer (Writer, execWriter, tell) import Data.Functor.Identity (runIdentity)+import Data.List (nub)+import Data.Maybe (mapMaybe)+import qualified Data.Text as Text+import qualified ShellCheck.AST+import ShellCheck.AST (Id(..), Token(..))+import qualified ShellCheck.ASTLib import ShellCheck.Checker import ShellCheck.Interface+import qualified ShellCheck.Parser -shellcheck :: String -> [Comment]-shellcheck bashScript = map comment $ crComments $ runIdentity $ checkScript si spec+data ParsedBash = ParsedBash+    { original :: Text.Text+    , parsed :: ParseResult+    }++shellcheck :: ParsedBash -> [Comment]+shellcheck (ParsedBash txt _) = map comment $ crComments $ runIdentity $ checkScript si spec   where     comment (PositionedComment _ _ c) = c     si = mockedSystemInterface [("", "")]     spec = CheckSpec filename script sourced exclusions (Just Bash)-    script = "#!/bin/bash\n" ++ bashScript+    script = "#!/bin/bash\n" ++ Text.unpack txt     filename = "" -- filename can be ommited because we only want the parse results back     sourced = False     exclusions = []++parseShell :: Text.Text -> ParsedBash+parseShell txt =+    ParsedBash+    { original = txt+    , parsed =+          runIdentity $+          ShellCheck.Parser.parseScript+              (mockedSystemInterface [("", "")])+              ParseSpec+              { psFilename = "" -- There is no filename+              , psScript = "#!/bin/bash\n" ++ Text.unpack txt+              , psCheckSourced = False+              }+    }++findCommands :: ParsedBash -> [Token]+findCommands (ParsedBash _ ast) =+    case prRoot ast of+        Nothing -> []+        Just script -> nub . execWriter $ ShellCheck.AST.doAnalysis extract script+  where+    extract :: Token -> Writer [Token] ()+    extract t =+        case ShellCheck.ASTLib.getCommand t of+            Nothing -> return ()+            Just cmd -> tell [cmd]++allCommands :: (Token -> Bool) -> ParsedBash -> Bool+allCommands check script = all check (findCommands script)++noCommands :: (Token -> Bool) -> ParsedBash -> Bool+noCommands check = allCommands (not . check)++getCommandName :: Token -> Maybe String+getCommandName = ShellCheck.ASTLib.getCommandName++findCommandNames :: ParsedBash -> [String]+findCommandNames = mapMaybe getCommandName . findCommands++cmdHasArgs :: String -> [String] -> Token -> Bool+cmdHasArgs command arguments token@T_SimpleCommand {}+    | ShellCheck.ASTLib.getCommandName token /= Just command = False+    | otherwise = not $ null [arg | arg <- getAllArgs token, arg `elem` arguments]+cmdHasArgs _ _ _ = False++getAllArgs :: Token -> [String]+getAllArgs (T_SimpleCommand _ _ (_:allArgs)) = concatMap ShellCheck.ASTLib.oversimplify allArgs+getAllArgs _ = []++getArgsNoFlags :: Token -> [String]+getArgsNoFlags cmd@(T_SimpleCommand _ _ (_:allArgs)) = concatMap ShellCheck.ASTLib.oversimplify args+  where+    flags = [t | (t, _) <- getAllFlags cmd]+    args = [a | a <- allArgs, a `notElem` flags]+getArgsNoFlags _ = []++getAllFlags :: Token -> [(Token, String)]+getAllFlags cmd@T_SimpleCommand {} = [(t, f) | (t, f) <- ShellCheck.ASTLib.getAllFlags cmd, f /= ""]+getAllFlags _ = []++hasFlag :: String -> Token -> Bool+hasFlag flag = any (\(_, f) -> f == flag) . getAllFlags++dropFlagArg :: [String] -> Token -> Token+dropFlagArg flags cmd@(T_SimpleCommand cid b allArgs) = T_SimpleCommand cid b filterdArgs+  where+    filterdArgs = [arg | arg <- allArgs, isNotNextToken arg]+    isNotNextToken arg = ShellCheck.AST.getId arg `notElem` findTokensToDrop+    findTokensToDrop = [next (ShellCheck.AST.getId t) | (t, f) <- getAllFlags cmd, f `elem` flags]+    next (Id i) = Id (i + 2)+dropFlagArg _ token = token
src/Hadolint/Rules.hs view
@@ -6,8 +6,7 @@ import Control.Arrow ((&&&)) import Data.List (dropWhile, isInfixOf, isPrefixOf, mapAccumL) import Data.List.NonEmpty (toList)-import Data.List.Split (splitOneOf)-import Hadolint.Bash+import qualified Hadolint.Bash as Bash import Language.Docker.Syntax  import Data.Semigroup ((<>))@@ -42,33 +41,49 @@  type IgnoreRuleParser = Megaparsec.Parsec Void Text.Text +type ParsedFile = [InstructionPos Bash.ParsedBash]+ 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" --- a Rule takes a Dockerfile and returns the executed checks-type Rule = Dockerfile -> [RuleCheck]+-- a Rule takes a Dockerfile with parsed bash and returns the executed checks+type Rule = ParsedFile -> [RuleCheck]  -- Apply a function on each instruction and create a check -- for the according line number mapInstructions ::-       Metadata -> (state -> Linenumber -> Instruction Text.Text -> (state, Bool)) -> state -> Rule+       Metadata+    -> (state -> Linenumber -> Instruction Bash.ParsedBash -> (state, Bool))+    -> state+    -> Rule mapInstructions metadata f initialState dockerfile =     let (_, results) = mapAccumL applyRule initialState dockerfile     in 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     applyRule state (InstructionPos i source linenumber) =-        let (newState, res) = f state linenumber i+        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 metadata source linenumber res) -instructionRule :: Text.Text -> Severity -> Text.Text -> (Instruction Text.Text -> Bool) -> Rule+instructionRule ::+       Text.Text -> Severity -> Text.Text -> (Instruction Bash.ParsedBash -> Bool) -> Rule instructionRule code severity message check =     instructionRuleLine code severity message (const check)  instructionRuleLine ::-       Text.Text -> Severity -> Text.Text -> (Linenumber -> Instruction Text.Text -> Bool) -> Rule+       Text.Text+    -> Severity+    -> Text.Text+    -> (Linenumber -> Instruction Bash.ParsedBash -> Bool)+    -> Rule instructionRuleLine code severity message check =     instructionRuleState code severity message checkAndDropState ()   where@@ -78,7 +93,7 @@        Text.Text     -> Severity     -> Text.Text-    -> (state -> Linenumber -> Instruction Text.Text -> (state, Bool))+    -> (state -> Linenumber -> Instruction Bash.ParsedBash -> (state, Bool))     -> state     -> Rule instructionRuleState code severity message = mapInstructions (Metadata code severity message)@@ -86,22 +101,21 @@ withState :: a -> b -> (a, b) withState st res = (st, res) -argumentsRule :: ([String] -> a) -> Arguments Text.Text -> a+argumentsRule :: (Bash.ParsedBash -> a) -> Arguments Bash.ParsedBash -> a argumentsRule applyRule args =     case args of-        ArgumentsText as -> applyRule . normalizeArgs $ as-        ArgumentsList as -> applyRule . normalizeArgs $ as-  where-    normalizeArgs = words . Text.unpack+        ArgumentsText as -> applyRule as+        ArgumentsList as -> applyRule as  -- Enforce rules on a dockerfile and return failed checks analyze :: [Rule] -> Dockerfile -> [RuleCheck]-analyze list dockerfile = filter failed $ concat [r dockerfile | r <- list]+analyze list dockerfile = filter failed $ concat [r parsedFile | r <- list]   where     failed RuleCheck {metadata = Metadata {code}, linenumber, success} =         not success && not (wasIgnored code linenumber)     wasIgnored c ln = not $ null [line | (line, codes) <- allIgnores, line == ln, c `elem` codes]     allIgnores = ignored dockerfile+    parsedFile = map (fmap Bash.parseShell) dockerfile  ignored :: Dockerfile -> [(Linenumber, [Text.Text])] ignored dockerfile =@@ -165,7 +179,7 @@ commentMetadata (ShellCheck.Interface.Comment severity code message) =     Metadata (Text.pack ("SC" ++ show code)) severity (Text.pack message) -shellcheckBash :: Dockerfile -> [RuleCheck]+shellcheckBash :: ParsedFile -> [RuleCheck] shellcheckBash = concatMap check   where     check (InstructionPos (Run args) source linenumber) =@@ -173,32 +187,28 @@     check _ = []     applyRule source linenumber args =         rmDup [RuleCheck m source linenumber False | m <- convert args]-    convert args = [commentMetadata c | c <- shellcheck $ unwords args]+    convert args = [commentMetadata c | c <- Bash.shellcheck args]     rmDup :: [RuleCheck] -> [RuleCheck]     rmDup [] = []     rmDup (x:xs) = x : rmDup (filter (\y -> metadata x /= metadata y) xs) --- Split different bash commands-bashCommands :: [String] -> [[String]]-bashCommands = splitOneOf [";", "|", "&&"]--allFromImages :: Dockerfile -> [(Linenumber, BaseImage)]+allFromImages :: ParsedFile -> [(Linenumber, BaseImage)] allFromImages dockerfile = [(l, f) | (l, From f) <- instr]   where     instr = fmap (lineNumber &&& instruction) dockerfile -allAliasedImages :: Dockerfile -> [(Linenumber, ImageAlias)]+allAliasedImages :: ParsedFile -> [(Linenumber, ImageAlias)] allAliasedImages dockerfile =     [(l, alias) | (l, Just alias) <- map extractAlias (allFromImages dockerfile)]   where     extractAlias (l, f) = (l, fromAlias f) -allImageNames :: Dockerfile -> [(Linenumber, Text.Text)]+allImageNames :: ParsedFile -> [(Linenumber, Text.Text)] allImageNames dockerfile = [(l, fromName baseImage) | (l, baseImage) <- allFromImages dockerfile]  -- | Returns a list of all image aliases in FROM instructions that --  are defined before the given line number.-previouslyDefinedAliases :: Linenumber -> Dockerfile -> [Text.Text]+previouslyDefinedAliases :: Linenumber -> ParsedFile -> [Text.Text] previouslyDefinedAliases line dockerfile =     [i | (l, ImageAlias i) <- allAliasedImages dockerfile, l < line] @@ -245,8 +255,8 @@     check _ = True  -- Check if a command contains a program call in the Run instruction-usingProgram :: String -> [String] -> Bool-usingProgram prog args = or [True | cmd:_ <- bashCommands args, cmd == prog]+usingProgram :: String -> Bash.ParsedBash -> Bool+usingProgram prog args = not $ null [cmd | cmd <- Bash.findCommandNames args, cmd == prog]  multipleCmds :: Rule multipleCmds = instructionRuleState code severity message check Nothing@@ -284,7 +294,8 @@         let newArgs = extractCommands args             newState = Set.union state newArgs         in withState newState (Set.size newState < 2)-    extractCommands args = Set.fromList [w | w <- args, w == "curl" || w == "wget"]+    extractCommands args =+        Set.fromList [w | w <- Bash.findCommandNames args, w == "curl" || w == "wget"]  invalidCmd :: Rule invalidCmd = instructionRule code severity message check@@ -294,10 +305,9 @@     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`"-    check (Run args) = argumentsRule applyRule args+    check (Run args) = argumentsRule detectInvalid args     check _ = True-    applyRule (arg:_) = arg `notElem` invalidCmds-    applyRule _ = True+    detectInvalid args = null [arg | arg <- Bash.findCommandNames args, arg `elem` invalidCmds]     invalidCmds = ["ssh", "vim", "shutdown", "service", "ps", "free", "top", "kill", "mount"]  noRootUser :: Rule@@ -338,7 +348,7 @@     code = "DL3005"     severity = ErrorC     message = "Do not use apt-get upgrade or dist-upgrade"-    check (Run args) = argumentsRule (not . isInfixOf ["apt-get", "upgrade"]) args+    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "apt-get" ["upgrade"])) args     check _ = True  noUntagged :: Rule@@ -371,19 +381,20 @@     message =         "Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get \         \install <package>=<version>`"-    check (Run args) = argumentsRule (\as -> and [versionFixed p | p <- aptGetPackages as]) args+    check (Run args) = argumentsRule (all versionFixed . aptGetPackages) args     check _ = True     versionFixed package = "=" `isInfixOf` package -aptGetPackages :: [String] -> [String]+aptGetPackages :: Bash.ParsedBash -> [String] aptGetPackages args =-    concat-        [ filter noOption (dropOptionsWithArg ["-t", "--target-release"] cmd)-        | cmd <- bashCommands args-        , isAptGetInstall cmd-        ]+    [ arg+    | cmd <- dropTarget <$> Bash.findCommands args+    , arg <- Bash.getArgsNoFlags cmd+    , Bash.cmdHasArgs "apt-get" ["install"] cmd+    , arg /= "install"+    ]   where-    noOption arg = arg `notElem` ["apt-get", "install"] && not ("-" `isPrefixOf` arg)+    dropTarget = Bash.dropFlagArg ["t", "target-release"]  aptGetCleanup :: Rule aptGetCleanup dockerfile = instructionRuleState code severity message check Nothing dockerfile@@ -398,13 +409,16 @@     --   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 args) =-        withState st (argumentsRule (applyRule line baseimage) args)+        withState st (argumentsRule (didNotForgetToCleanup line baseimage) args)     check st _ _ = withState st True-    applyRule line baseimage args+    -- 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-    hasCleanup cmd = ["rm", "-rf", "/var/lib/apt/lists/*"] `isInfixOf` cmd-    hasUpdate cmd = ["apt-get", "update"] `isInfixOf` cmd+    hasCleanup args =+        any (Bash.cmdHasArgs "rm" ["-rf", "/var/lib/apt/lists/*"]) (Bash.findCommands args)+    hasUpdate args = any (Bash.cmdHasArgs "apt-get" ["update"]) (Bash.findCommands args)     imageIsUsed line baseimage = isLastImage line baseimage || imageIsUsedLater line baseimage     isLastImage line baseimage =         case reverse (allFromImages dockerfile) of@@ -416,24 +430,15 @@             Just (ImageAlias alias) ->                 alias `elem` [i | (l, i) <- allImageNames dockerfile, l > line] -dropOptionsWithArg :: [String] -> [String] -> [String]-dropOptionsWithArg _ [] = []-dropOptionsWithArg os (x:xs)-    | x `elem` os = dropOptionsWithArg os (drop 1 xs)-    | otherwise = x : dropOptionsWithArg os xs- noApkUpgrade :: Rule noApkUpgrade = instructionRule code severity message check   where     code = "DL3017"     severity = ErrorC     message = "Do not use apk upgrade"-    check (Run args) = argumentsRule (not . isInfixOf ["apk", "upgrade"]) args+    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "apk" ["upgrade"])) args     check _ = True -isApkAdd :: [String] -> Bool-isApkAdd cmd = ["apk"] `isInfixOf` cmd && ["add"] `isInfixOf` cmd- apkAddVersionPinned :: Rule apkAddVersionPinned = instructionRule code severity message check   where@@ -445,16 +450,16 @@     check _ = True     versionFixed package = "=" `isInfixOf` package -apkAddPackages :: [String] -> [String]+apkAddPackages :: Bash.ParsedBash -> [String] apkAddPackages args =-    concat-        [ filter noOption (dropOptionsWithArg ["-t", "--virtual"] cmd)-        | cmd <- bashCommands args-        , isApkAdd cmd-        ]+    [ arg+    | cmd <- dropTarget <$> Bash.findCommands args+    , arg <- Bash.getArgsNoFlags cmd+    , Bash.cmdHasArgs "apk" ["add"] cmd+    , arg /= "add"+    ]   where-    noOption arg = arg `notElem` options && not ("--" `isPrefixOf` arg)-    options = ["apk", "add", "-q", "-p", "-v", "-f", "-t"]+    dropTarget = Bash.dropFlagArg ["t", "virtual"]  apkAddNoCache :: Rule apkAddNoCache = instructionRule code severity message check@@ -464,9 +469,9 @@     message =         "Use the `--no-cache` switch to avoid the need to use `--update` and remove \         \`/var/cache/apk/*` when done installing packages"-    check (Run args) = argumentsRule (\as -> not (isApkAdd as) || hasNoCacheOption as) args+    check (Run args) = argumentsRule (Bash.noCommands forgotCacheOption) args     check _ = True-    hasNoCacheOption cmd = ["--no-cache"] `isInfixOf` cmd+    forgotCacheOption cmd = Bash.cmdHasArgs "apk" ["add"] cmd && not (Bash.hasFlag "no-cache" cmd)  useAdd :: Rule useAdd = instructionRule code severity message check@@ -502,46 +507,27 @@     message =         "Pin versions in pip. Instead of `pip install <package>` use `pip install \         \<package>==<version>`"-    check (Run args) =-        argumentsRule (\as -> not (isPipInstall as) || all versionFixed (packages as)) args+    check (Run args) = argumentsRule (Bash.noCommands forgotToPinVersion) args     check _ = True-    isPipInstall :: [String] -> Bool+    forgotToPinVersion cmd = isPipInstall cmd && not (all versionFixed (packages cmd))+    -- Check if the command is a pip* install command, and that specific pacakges are being listed     isPipInstall cmd =-        case getInstallArgs cmd of-            Nothing -> False-            Just args -> not (["-r"] `isInfixOf` args || ["."] `isInfixOf` args)-    packages cmd =-        case getInstallArgs cmd of-            Nothing -> []-            Just args -> findPackages args-    getInstallArgs = stripInstallPrefix isInstallCommand-    isInstallCommand ('p':'i':'p':_) = True-    isInstallCommand _ = False+        case Bash.getCommandName cmd of+            Just ('p':'i':'p':_) -> relevantInstall cmd+            _ -> False+    -- If the user is installing requirements from a file or just the local module, then we are not interested+    -- in running this rule+    relevantInstall cmd =+        ["install"] `isInfixOf` Bash.getAllArgs cmd &&+        not (["-r"] `isInfixOf` Bash.getAllArgs cmd || ["."] `isInfixOf` Bash.getAllArgs cmd)+    packages cmd = stripInstallPrefix (Bash.getArgsNoFlags cmd)     versionFixed package = hasVersionSymbol package || isVersionedGit package     isVersionedGit package = "git+http" `isInfixOf` package && "@" `isInfixOf` package-    versionSymbols = ["==", ">=", "<=", ">", "<", "!="]+    versionSymbols = ["==", ">=", "<=", ">", "<", "!=", "~=", "==="]     hasVersionSymbol package = or [s `isInfixOf` package | s <- versionSymbols] --- | Returns all the packages after pip install-findPackages :: [String] -> [String]-findPackages = takeWhile (not . isEnd) . dropWhile isFlag-  where-    isEnd word = isFlag word || word `elem` ["&&", "||", ";", "|"]-    isFlag ('-':_) = True-    isFlag _ = False--stripInstallPrefix :: (String -> Bool) -> [String] -> Maybe [String]-stripInstallPrefix isCommand args =-    if ["install"] `isPrefixOf` dropUntilInstall-        then dropUntilInstall |> drop 1 |> Just-        else Nothing-  where-    dropUntilInstall =-        args |> -- using a pipiline for readability-        dropWhile (not . isCommand) |>-        drop 1 |>-        dropWhile (/= "install")-    a |> f = f a+stripInstallPrefix :: [String] -> [String]+stripInstallPrefix = dropWhile (== "install")  {-|   Rule for pinning NPM packages to version, tag, or commit@@ -561,13 +547,11 @@     message =         "Pin versions in npm. Instead of `npm install <package>` use `npm install \         \<package>@<version>`"-    check (Run args) = argumentsRule (all versionFixed . packages) args+    check (Run args) = argumentsRule (Bash.noCommands forgotToPinVersion) args     check _ = True-    packages cmd =-        case getInstallArgs cmd of-            Nothing -> []-            Just args -> findPackages args-    getInstallArgs = stripInstallPrefix (== "npm")+    forgotToPinVersion cmd = isNpmInstall cmd && not (all versionFixed (packages cmd))+    isNpmInstall = Bash.cmdHasArgs "npm" ["install"]+    packages cmd = stripInstallPrefix (Bash.getArgsNoFlags cmd)     versionFixed package =         if hasGitPrefix package             then isVersionedGit package@@ -582,21 +566,20 @@                 then dropWhile ('/' <) pkg                 else pkg -isAptGetInstall :: [String] -> Bool-isAptGetInstall cmd = ["apt-get"] `isInfixOf` cmd && ["install"] `isInfixOf` cmd- aptGetYes :: Rule aptGetYes = instructionRule code severity message check   where     code = "DL3014"     severity = WarningC     message = "Use the `-y` switch to avoid manual input `apt-get -y install <package>`"-    check (Run args) = argumentsRule (\as -> not (isAptGetInstall as) || hasYesOption as) args+    check (Run args) = argumentsRule (Bash.noCommands forgotAptYesOption) args     check _ = True+    forgotAptYesOption cmd = isAptGetInstall cmd && not (hasYesOption cmd)+    isAptGetInstall = Bash.cmdHasArgs "apt-get" ["install"]     hasYesOption cmd =-        ["-y"] `isInfixOf` cmd ||-        ["--yes"] `isInfixOf` cmd || ["-qq"] `isInfixOf` cmd || startsWithYesFlag cmd-    startsWithYesFlag cmd = True `elem` ["-y" `isInfixOf` arg | arg <- cmd]+        "y" `elem` allFlags cmd ||+        "yes" `elem` allFlags cmd || length (filter (== "q") (allFlags cmd)) > 1+    allFlags cmd = snd <$> Bash.getAllFlags cmd  aptGetNoRecommends :: Rule aptGetNoRecommends = instructionRule code severity message check@@ -604,10 +587,11 @@     code = "DL3015"     severity = InfoC     message = "Avoid additional packages by specifying `--no-install-recommends`"-    check (Run args) =-        argumentsRule (\as -> not (isAptGetInstall as) || hasNoRecommendsOption as) args+    check (Run args) = argumentsRule (Bash.noCommands forgotNoInstallRecommends) args     check _ = True-    hasNoRecommendsOption cmd = ["--no-install-recommends"] `isInfixOf` cmd+    forgotNoInstallRecommends cmd = isAptGetInstall cmd && not (hasRecommendsOption cmd)+    isAptGetInstall = Bash.cmdHasArgs "apt-get" ["install"]+    hasRecommendsOption = Bash.hasFlag "no-install-recommends"  isArchive :: Text.Text -> Bool isArchive path =@@ -695,6 +679,5 @@     code = "DL4005"     severity = WarningC     message = "Use SHELL to change the default shell"-    check (Run args) = argumentsRule (not . any shellSymlink . bashCommands) args+    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "ln" ["/bin/sh"])) args     check _ = True-    shellSymlink args = usingProgram "ln" args && isInfixOf ["/bin/sh"] args
test/Spec.hs view
@@ -6,6 +6,7 @@ import Hadolint.Rules  import Language.Docker.Parser+import Language.Docker.Syntax import Data.Semigroup ((<>)) import qualified Data.Text as Text @@ -23,7 +24,7 @@                     "FROM hub.docker.io/debian@sha256:\                     \7959ed6f7e35f8b1aaa06d1d8259d4ee25aa85a086d5c125480c333183f9deeb"             it "explicit tagged with name" $-              ruleCatchesNot noLatestTag "FROM debian:jessie AS builder"+                ruleCatchesNot noLatestTag "FROM debian:jessie AS builder"             it "local aliases are OK to be untagged" $                 let dockerFile =                         [ "FROM golang:1.9.3-alpine3.7 AS build"@@ -33,7 +34,9 @@                         , "FROM alpine:3.7"                         , "RUN baz"                         ]-                in ruleCatchesNot noUntagged $ Text.unlines dockerFile+                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"@@ -43,33 +46,49 @@                         , "FROM alpine:3.7"                         , "RUN baz"                         ]-                in ruleCatches noUntagged $ Text.unlines dockerFile+                in do+                  ruleCatches noUntagged $ Text.unlines dockerFile+                  onBuildRuleCatches noUntagged $ Text.unlines dockerFile         --         describe "no root or sudo rules" $ do-            it "sudo" $ ruleCatches noSudo "RUN sudo apt-get update"+            it "sudo" $ do+              ruleCatches noSudo "RUN sudo apt-get update"+              onBuildRuleCatches noSudo "RUN sudo apt-get update"             it "no root" $ ruleCatches noRootUser "USER root"             it "no root" $ ruleCatchesNot noRootUser "USER foo"             it "no root UID" $ ruleCatches noRootUser "USER 0"             it "no root:root" $ ruleCatches noRootUser "USER root:root"             it "no root UID:GID" $ ruleCatches noRootUser "USER 0:0"-            it "install sudo" $ ruleCatchesNot noSudo "RUN apt-get install sudo"-            it "sudo chained programs" $+            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" $ ruleCatches invalidCmd "RUN top"-            it "install ssh" $ ruleCatchesNot invalidCmd "RUN apt-get install ssh"+            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 "apt-get rules" $ do-            it "apt upgrade" $ ruleCatches noAptGetUpgrade "RUN apt-get update && apt-get upgrade"-            it "apt-get version pinning" $+            it "apt 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 ruleCatches aptGetCleanup $ Text.unlines dockerFile+                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"@@ -77,7 +96,9 @@                         , "FROM scratch"                         , "RUN echo hey!"                         ]-                in ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+                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"@@ -85,7 +106,9 @@                         , "FROM scratch"                         , "RUN apt-get update && apt-get install python"                         ]-                in ruleCatches aptGetCleanup $ Text.unlines dockerFile+                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"@@ -93,15 +116,19 @@                         , "FROM foo"                         , "RUN hey!"                         ]-                in ruleCatches aptGetCleanup $ Text.unlines dockerFile-            it "now warn apt-get cleanup in intermediate stage that cleans lists" $+                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 ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+                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"@@ -109,13 +136,17 @@                         , "FROM scratch"                         , "RUN hey!"                         ]-                in ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+                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 ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+                in do+                  ruleCatchesNot aptGetCleanup $ Text.unlines dockerFile+                  onBuildRuleCatchesNot aptGetCleanup $ Text.unlines dockerFile              it "apt-get pinned chained" $                 let dockerFile =@@ -123,7 +154,10 @@                         , " && apt-get -yqq --no-install-recommends install nodejs=0.10 \\"                         , " && rm -rf /var/lib/apt/lists/*"                         ]-                in ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+                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 \\"@@ -132,21 +166,31 @@                         , "git=1:2.5.0* \\"                         , "ruby=1:2.1.*"                         ]-                in ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+                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" $ ruleCatches noApkUpgrade "RUN apk update && apk upgrade"-            it "apk add version pinning single" $ ruleCatches apkAddVersionPinned "RUN apk add flex"-            it "apk add no version pinning single" $+            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 ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+                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 \\"@@ -155,7 +199,9 @@                         , "python2=2.7.13-r1 \\"                         , "libbz2=1.0.6-r5"                         ]-                in ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+                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 \\"@@ -164,10 +210,15 @@                         , "python2=2.7.13-r1 \\"                         , "libbz2=1.0.6-r5"                         ]-                in ruleCatches apkAddVersionPinned $ Text.unlines dockerFile-            it "apk add with --no-cache" $ ruleCatches apkAddNoCache "RUN apk add flex=2.6.4-r1"-            it "apk add without --no-cache" $+                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 \\"@@ -177,139 +228,226 @@                         , "&& python setup.py install \\"                         , "&& apk del build-dependencies"                         ]-                in ruleCatchesNot apkAddVersionPinned $ Text.unlines dockerFile+                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" $+            it "pip2 version not pinned" $ do                 ruleCatches pipVersionPinned "RUN pip2 install MySQL_python"-            it "pip3 version not pinned" $+                onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"+            it "pip3 version not pinned" $ do                 ruleCatches pipVersionPinned "RUN pip3 install MySQL_python"-            it "pip3 version pinned" $+                onBuildRuleCatches pipVersionPinned "RUN pip2 install MySQL_python"+            it "pip3 version pinned" $ do                 ruleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"-            it "pip install requirements" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip3 install MySQL_python==1.2.2"+            it "pip install requirements" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"-            it "pip install use setup.py" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip install -r requirements.txt"+            it "pip install use setup.py" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install ."-            it "pip version not pinned" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip install ."+            it "pip version not pinned" $ do                 ruleCatches pipVersionPinned "RUN pip install MySQL_python"-            it "pip version pinned" $+                onBuildRuleCatches pipVersionPinned "RUN pip install MySQL_python"+            it "pip version pinned" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install MySQL_python==1.2.2"-            it "pip version pinned with flag" $+                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" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"-            it "pip version pinned with python -m" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip install --ignore-installed MySQL_python==1.2.2"+            it "pip version pinned with python -m" $ do                 ruleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"-            it "pip install git" $+                onBuildRuleCatchesNot pipVersionPinned "RUN python -m pip install example==1.2.2"+            it "pip install git" $ do                 ruleCatchesNot                     pipVersionPinned                     "RUN pip install git+https://github.com/rtfd/r-ext.git@0.6-alpha#egg=r-ext"-            it "pip install unversioned git" $+                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"-            it "pip install upper bound" $+                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'"-            it "pip install lower bound" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster>=0.7'"+            it "pip install lower bound" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"-            it "pip install excluded version" $+                onBuildRuleCatchesNot pipVersionPinned "RUN pip install 'alabaster<0.7'"+            it "pip install excluded version" $ do                 ruleCatchesNot pipVersionPinned "RUN pip install 'alabaster!=0.7'"-            it "pip install user directory" $+                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"-            it "pip install no pip version check" $+                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"-            it "pip install no cache dir" $+                onBuildRuleCatchesNot+                    pipVersionPinned+                    "RUN pip install MySQL_python==1.2.2 --disable-pip-version-check"+            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"         --         describe "npm pinning" $ do-            it "version pinned in package.json" $ ruleCatchesNot npmVersionPinned "RUN npm install"-            it "version pinned in package.json with arguments" $+            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"-            it "version pinned" $ ruleCatchesNot npmVersionPinned "RUN npm install express@4.1.1"-            it "version pinned with scope" $+                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\""-            it "version pinned multiple packages" $+                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"-            it "version pinned with --global" $+                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\""-            it "version pinned with -g" $+                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\""-            it "commit pinned for git+ssh" $+                onBuildRuleCatchesNot npmVersionPinned "RUN npm install -g express@\"4.1.1\""+            it "commit pinned for git+ssh" $ do                 ruleCatchesNot                     npmVersionPinned                     "RUN npm install git+ssh://git@github.com:npm/npm.git#v1.0.27"-            it "commit pinned for git+http" $+                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"-            it "commit pinned for git+https" $+                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"-            it "commit pinned for git" $+                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"+       --version range is not supported-      --it "version pinned with scope" $-      --  ruleCatchesNot npmVersionPinned "RUN npm install @myorg/privatepackage@\">=0.1.0 <0.2.0\""-            it "version not pinned" $ ruleCatches npmVersionPinned "RUN npm install express"-            it "version not pinned with scope" $+            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"-            it "version not pinned multiple packages" $+                onBuildRuleCatches npmVersionPinned "RUN npm install @myorg/privatepackage"+            it "version not pinned multiple packages" $ do                 ruleCatches npmVersionPinned "RUN npm install express sax@0.1.1"-            it "version not pinned with --global" $+                onBuildRuleCatches npmVersionPinned "RUN npm install express sax@0.1.1"+            it "version not pinned with --global" $ do                 ruleCatches npmVersionPinned "RUN npm install --global express"-            it "commit not pinned for git+ssh" $+                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"-            it "commit not pinned for git+http" $+                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"-            it "commit not pinned for git+https" $+                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"-            it "commit not pinned for 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" $ ruleCatches useShell "RUN ln -sfv /bin/bash /bin/sh"-            it "RUN ln with unrelated symlinks" $+            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"-            it "RUN ln with multiple acceptable commands" $+                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 "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" $ ruleCatches aptGetYes "RUN apt-get install python"-            it "apt-get yes shortflag" $ ruleCatchesNot aptGetYes "RUN apt-get install -yq python"-            it "apt-get yes quiet level 2 implies -y" $+            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"-            it "apt-get yes different pos" $+                onBuildRuleCatchesNot aptGetYes "RUN apt-get install -qq python"+            it "apt-get yes different pos" $ do                 ruleCatchesNot aptGetYes "RUN apt-get install -y python"-            it "apt-get with auto yes" $ ruleCatchesNot aptGetYes "RUN apt-get -y install python"-            it "apt-get with auto expanded yes" $+                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"-            it "apt-get install recommends" $+                onBuildRuleCatchesNot aptGetYes "RUN apt-get --yes install python"+            it "apt-get install recommends" $ do                 ruleCatchesNot                     aptGetNoRecommends                     "RUN apt-get install --no-install-recommends python"-            it "apt-get no install recommends" $+                onBuildRuleCatchesNot+                    aptGetNoRecommends+                    "RUN apt-get install --no-install-recommends python"+            it "apt-get no install recommends" $ do                 ruleCatches aptGetNoRecommends "RUN apt-get install python"-            it "apt-get no install recommends" $+                onBuildRuleCatches aptGetNoRecommends "RUN apt-get install python"+            it "apt-get no install recommends" $ do                 ruleCatches aptGetNoRecommends "RUN apt-get -y install python"-            it "apt-get version" $+                onBuildRuleCatches aptGetNoRecommends "RUN apt-get -y install python"+            it "apt-get version" $ do                 ruleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"-            it "apt-get pinned" $+                onBuildRuleCatchesNot aptGetVersionPinned "RUN apt-get install -y python=1.2.2"+            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 &&\\"@@ -320,7 +458,9 @@                           \openjdk-8-jdk=8u131-b11-1~bpo8+1 &&\\"                         , " rm -rf /var/lib/apt/lists/*"                         ]-                in ruleCatchesNot aptGetVersionPinned $ Text.unlines dockerFile+                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"@@ -464,6 +604,17 @@         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+ -- Assert a failed check exists for rule ruleCatches :: HasCallStack => Rule -> Text.Text -> Assertion ruleCatches rule s = assertChecks rule s f@@ -472,7 +623,19 @@       assertEqual "No check for rule found" 1 $ length checks       assertBool "Incorrect line number for result" $ null [c | c <- checks, linenumber c <= 0] +onBuildRuleCatches :: HasCallStack => Rule -> Text.Text -> Assertion+onBuildRuleCatches rule s = assertOnBuildChecks rule s f+  where+    f checks = do+      assertEqual "No check for rule found" 1 $ length checks+      assertBool "Incorrect line number for result" $ null [c | c <- checks, linenumber c <= 0]+ ruleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion ruleCatchesNot rule s = assertChecks rule s f+  where+    f checks = assertEqual "Found check of rule" 0 $ length checks++onBuildRuleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion+onBuildRuleCatchesNot rule s = assertOnBuildChecks rule s f   where     f checks = assertEqual "Found check of rule" 0 $ length checks