packages feed

hadolint 1.9.0 → 1.10.1

raw patch · 7 files changed

+536/−260 lines, 7 filesdep ~language-dockerPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: language-docker

API changes (from Hackage documentation)

- 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: extractTokensWith :: (Token -> Maybe Token) -> ParsedBash -> [Token]
- Hadolint.Bash: findCommandNames :: ParsedBash -> [String]
- Hadolint.Bash: findCommands :: ParsedBash -> [Token]
- Hadolint.Bash: findPipes :: 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: hasPipes :: ParsedBash -> Bool
- Hadolint.Bash: noCommands :: (Token -> Bool) -> ParsedBash -> Bool
- Hadolint.Bash: parseShell :: Text -> ParsedBash
- Hadolint.Bash: shellcheck :: ParsedBash -> [Comment]
- Hadolint.Rules: commentMetadata :: Comment -> Metadata
- Hadolint.Rules: shellcheckBash :: ParsedFile -> [RuleCheck]
+ Hadolint.Rules: RulesConfig :: Set Registry -> RulesConfig
+ Hadolint.Rules: [allowedRegistries] :: RulesConfig -> Set Registry
+ Hadolint.Rules: instance GHC.Base.Monoid Hadolint.Rules.RulesConfig
+ Hadolint.Rules: instance GHC.Base.Semigroup Hadolint.Rules.RulesConfig
+ Hadolint.Rules: instance GHC.Classes.Eq Hadolint.Rules.RulesConfig
+ Hadolint.Rules: instance GHC.Show.Show Hadolint.Rules.RulesConfig
+ Hadolint.Rules: newtype RulesConfig
+ Hadolint.Rules: optionalRules :: RulesConfig -> [Rule]
+ Hadolint.Rules: registryIsAllowed :: Set Registry -> Rule
+ Hadolint.Rules: shellcheck :: Rule
+ Hadolint.Rules: type CheckerWithState state = state -> Linenumber -> Instruction ParsedShell -> (state, [Metadata])
+ Hadolint.Rules: type SimpleCheckerWithLine = (Linenumber -> Instruction ParsedShell -> Bool)
+ Hadolint.Rules: type SimpleCheckerWithState state = state -> Linenumber -> Instruction ParsedShell -> (state, Bool)
+ Hadolint.Shell: ParsedShell :: Text -> ParseResult -> ParsedShell
+ Hadolint.Shell: ShellOpts :: Text -> Set Text -> ShellOpts
+ Hadolint.Shell: [envVars] :: ShellOpts -> Set Text
+ Hadolint.Shell: [original] :: ParsedShell -> Text
+ Hadolint.Shell: [parsed] :: ParsedShell -> ParseResult
+ Hadolint.Shell: [shellName] :: ShellOpts -> Text
+ Hadolint.Shell: addVars :: [Text] -> ShellOpts -> ShellOpts
+ Hadolint.Shell: allCommands :: (Token -> Bool) -> ParsedShell -> Bool
+ Hadolint.Shell: cmdHasArgs :: String -> [String] -> Token -> Bool
+ Hadolint.Shell: data ParsedShell
+ Hadolint.Shell: data ShellOpts
+ Hadolint.Shell: defaultShellOpts :: ShellOpts
+ Hadolint.Shell: dropFlagArg :: [String] -> Token -> Token
+ Hadolint.Shell: extractTokensWith :: (Token -> Maybe Token) -> ParsedShell -> [Token]
+ Hadolint.Shell: findCommandNames :: ParsedShell -> [String]
+ Hadolint.Shell: findCommands :: ParsedShell -> [Token]
+ Hadolint.Shell: findPipes :: ParsedShell -> [Token]
+ Hadolint.Shell: getAllArgs :: Token -> [String]
+ Hadolint.Shell: getAllFlags :: Token -> [(Token, String)]
+ Hadolint.Shell: getArgsNoFlags :: Token -> [String]
+ Hadolint.Shell: getCommandName :: Token -> Maybe String
+ Hadolint.Shell: hasFlag :: String -> Token -> Bool
+ Hadolint.Shell: hasPipes :: ParsedShell -> Bool
+ Hadolint.Shell: noCommands :: (Token -> Bool) -> ParsedShell -> Bool
+ Hadolint.Shell: parseShell :: Text -> ParsedShell
+ Hadolint.Shell: setShell :: Text -> ShellOpts -> ShellOpts
+ Hadolint.Shell: shellcheck :: ShellOpts -> ParsedShell -> [Comment]
- Hadolint.Rules: apkAddPackages :: ParsedBash -> [String]
+ Hadolint.Rules: apkAddPackages :: ParsedShell -> [String]
- Hadolint.Rules: aptGetPackages :: ParsedBash -> [String]
+ Hadolint.Rules: aptGetPackages :: ParsedShell -> [String]
- Hadolint.Rules: argumentsRule :: (ParsedBash -> a) -> Arguments ParsedBash -> a
+ Hadolint.Rules: argumentsRule :: (ParsedShell -> a) -> Arguments ParsedShell -> a
- Hadolint.Rules: instructionRule :: Text -> Severity -> Text -> (Instruction ParsedBash -> Bool) -> Rule
+ Hadolint.Rules: instructionRule :: Text -> Severity -> Text -> (Instruction ParsedShell -> Bool) -> Rule
- Hadolint.Rules: instructionRuleLine :: Text -> Severity -> Text -> (Linenumber -> Instruction ParsedBash -> Bool) -> Rule
+ Hadolint.Rules: instructionRuleLine :: Text -> Severity -> Text -> SimpleCheckerWithLine -> Rule
- Hadolint.Rules: instructionRuleState :: Text -> Severity -> Text -> (state -> Linenumber -> Instruction ParsedBash -> (state, Bool)) -> state -> Rule
+ Hadolint.Rules: instructionRuleState :: Text -> Severity -> Text -> SimpleCheckerWithState state -> state -> Rule
- Hadolint.Rules: mapInstructions :: Metadata -> (state -> Linenumber -> Instruction ParsedBash -> (state, Bool)) -> state -> Rule
+ Hadolint.Rules: mapInstructions :: CheckerWithState state -> state -> Rule
- Hadolint.Rules: type ParsedFile = [InstructionPos ParsedBash]
+ Hadolint.Rules: type ParsedFile = [InstructionPos ParsedShell]
- Hadolint.Rules: usingProgram :: String -> ParsedBash -> Bool
+ Hadolint.Rules: usingProgram :: String -> ParsedShell -> Bool

Files

README.md view
@@ -24,6 +24,7 @@ ```bash hadolint <Dockerfile> hadolint --ignore DL3003 --ignore DL3006 <Dockerfile> # exclude specific rules+hadolint --trusted-registry my-company.com:500 <Dockerfile> # Warn when using untrusted FROM images ```  Docker comes to the rescue to provide an easy way how to run `hadolint` on most@@ -78,6 +79,20 @@   - SC1010 ``` +Additionally, Hadolint can warn you when images from untrusted repositories are being+used in Dockerfiles, you can append the `trustedRegistries` keys to the configuration+file as shown below:++```yaml+ignored:+  - DL3000+  - SC1010++trustedRegistries:+  - docker.io+  - my-company.com:5000+```+ Configuration files can be used globally or per project. By default, hadolint will look for a configuration file in the current directory with the name `.hadolint.yaml` @@ -154,6 +169,7 @@ | [DL3023](https://github.com/hadolint/hadolint/wiki/DL3023)   | `COPY --from` cannot reference its own `FROM` alias                                                                                                 | | [DL3024](https://github.com/hadolint/hadolint/wiki/DL3024)   | `FROM` aliases (stage names) must be unique                                                                                                         | | [DL3025](https://github.com/hadolint/hadolint/wiki/DL3025)   | Use arguments JSON notation for CMD and ENTRYPOINT arguments                                                                                        |+| [DL3025](https://github.com/hadolint/hadolint/wiki/DL3026)   | Use only an allowed registry in the FROM image                                                                                                      | | [DL4000](https://github.com/hadolint/hadolint/wiki/DL4000)   | MAINTAINER is deprecated.                                                                                                                           | | [DL4001](https://github.com/hadolint/hadolint/wiki/DL4001)   | Either use Wget or Curl but not both.                                                                                                               | | [DL4003](https://github.com/hadolint/hadolint/wiki/DL4003)   | Multiple `CMD` instructions found.                                                                                                                  |
app/Main.hs view
@@ -4,35 +4,39 @@  module Main where -import Hadolint.Rules-import Language.Docker (parseFile)-import Language.Docker.Syntax (Dockerfile)- import Control.Applicative import Control.Monad (filterM)-import Data.Maybe (listToMaybe)+import Data.Coerce (coerce)+import Data.Maybe (fromMaybe, listToMaybe) import Data.Semigroup ((<>))-import qualified Data.Version as V (showVersion)+import qualified Data.Set as Set+import Data.String+import Data.Text (Text)+import qualified Data.Version import qualified Data.Yaml as Yaml-import Development.GitRev (gitDescribe)+import qualified Development.GitRev import GHC.Generics+import qualified Language.Docker as Docker+import Language.Docker.Syntax (Dockerfile) import Options.Applicative hiding (ParseError)-import qualified Paths_hadolint as Version -- version from hadolint.cabal file+import qualified Paths_hadolint -- version from hadolint.cabal file import System.Directory        (XdgDirectory(..), doesFileExist, getCurrentDirectory,         getXdgDirectory) import System.Exit (exitFailure, exitSuccess) import System.FilePath ((</>)) -import Data.Text (Text) import qualified Hadolint.Formatter.Checkstyle as Checkstyle import qualified Hadolint.Formatter.Codeclimate as Codeclimate-import Hadolint.Formatter.Format (toResult)+import qualified Hadolint.Formatter.Format as Format import qualified Hadolint.Formatter.Json as Json import qualified Hadolint.Formatter.TTY as TTY+import qualified Hadolint.Rules as Rules  type IgnoreRule = Text +type TrustedRegistry = Text+ data OutputFormat     = Json     | TTY@@ -45,16 +49,19 @@     , format :: OutputFormat     , ignoreRules :: [IgnoreRule]     , dockerfiles :: [String]+    , rulesConfig :: Rules.RulesConfig     } deriving (Show) -newtype ConfigFile = ConfigFile-    { ignored :: [IgnoreRule]+data ConfigFile = ConfigFile+    { ignored :: Maybe [IgnoreRule]+    , trustedRegistries :: Maybe [TrustedRegistry]     } deriving (Show, Eq, Generic)  instance Yaml.FromJSON ConfigFile -ignoreFilter :: [IgnoreRule] -> RuleCheck -> Bool-ignoreFilter ignoredRules (RuleCheck (Metadata code _ _) _ _ _) = code `notElem` ignoredRules+ignoreFilter :: [IgnoreRule] -> Rules.RuleCheck -> Bool+ignoreFilter ignoredRules (Rules.RuleCheck (Rules.Metadata code _ _) _ _ _) =+    code `notElem` ignoredRules  toOutputFormat :: String -> Maybe OutputFormat toOutputFormat "json" = Just Json@@ -75,7 +82,8 @@     version <*>     outputFormat <*>     ignoreList <*>-    files+    files <*>+    parseRulesConfig   where     version = switch (long "version" <> short 'v' <> help "Show version")     --@@ -99,6 +107,15 @@     --     -- | Parse a list of dockerfile names     files = many (argument str (metavar "DOCKERFILE..." <> action "file"))+    --+    -- | Parses all the optional rules configuration+    parseRulesConfig =+        Rules.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 = execParser opts >>= applyConfig >>= lint@@ -110,12 +127,13 @@              header "hadolint - Dockerfile Linter written in Haskell")  applyConfig :: LintOptions -> IO LintOptions-applyConfig o@LintOptions {ignoreRules = (_:_)} = return o-applyConfig o = do-    theConfig <- findConfig-    case theConfig of-        Nothing -> return o-        Just config -> parseAndApply config+applyConfig o+    | not (null (ignoreRules o)) && rulesConfig o /= mempty = return o+    | otherwise = do+        theConfig <- findConfig+        case theConfig of+            Nothing -> return o+            Just config -> parseAndApply config   where     findConfig = do         localConfigFile <- (</> ".hadolint.yaml") <$> getCurrentDirectory@@ -125,15 +143,37 @@         result <- Yaml.decodeFileEither config         case result of             Left err -> printError err config-            Right (ConfigFile ignore) -> return o {ignoreRules = ignore}+            Right (ConfigFile ignore trusted) -> return (override ignore trusted)+    -- | Applies the configuration found in the file to the passed LintOptions+    override ignore trusted = applyTrusted trusted . applyIgnore ignore $ o+    applyIgnore ignore opts =+        case ignoreRules opts of+            [] -> opts {ignoreRules = fromMaybe [] ignore}+            _ -> opts+    applyTrusted trusted opts+        | null (Rules.allowedRegistries (rulesConfig opts)) =+            opts {rulesConfig = toRules trusted <> rulesConfig opts}+        | otherwise = opts+    -- | Converts a list of TrustedRegistry to a RulesConfig record+    toRules (Just trusted) = Rules.RulesConfig (Set.fromList . coerce $ trusted)+    toRules _ = mempty     printError err config =         case err of-            Yaml.AesonException _ ->+            Yaml.AesonException e ->                 error $-                "Error parsing your config file in  '" ++-                config ++-                "':\nIt should contain an 'ignored' key with a list of strings. For example:\n\n" ++-                unlines ["ignored:", "\t- DL3000", "\t- SC1099"]+                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"+                    , ""+                    , e+                    ]             _ ->                 error $                 "Error parsing your config file in  '" ++@@ -146,14 +186,17 @@  getVersion :: String getVersion-    | $(gitDescribe) == "UNKNOWN" =-        "Haskell Dockerfile Linter " ++ V.showVersion Version.version ++ "-no-git"-    | otherwise = "Haskell Dockerfile Linter " ++ $(gitDescribe)+    | $(Development.GitRev.gitDescribe) == "UNKNOWN" =+        "Haskell Dockerfile Linter " ++ Data.Version.showVersion Paths_hadolint.version ++ "-no-git"+    | otherwise = "Haskell Dockerfile Linter " ++ $(Development.GitRev.gitDescribe) +-- | 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 -> IO () lint LintOptions {showVersion = True} = putStrLn getVersion >> exitSuccess lint LintOptions {dockerfiles = []} = putStrLn "Please provide a Dockerfile" >> exitFailure-lint LintOptions {ignoreRules = ignoreList, dockerfiles = dFiles, format} = do+lint LintOptions {ignoreRules = ignoreList, dockerfiles = dFiles, format, rulesConfig} = do     processedFiles <- mapM (lintDockerfile ignoreList) dFiles     let allResults = results processedFiles     printResult allResults@@ -161,9 +204,9 @@         then exitFailure         else exitSuccess   where-    results = foldMap toResult -- Parse and check rules for each dockerfile,-                               -- then convert them to a Result and combine with-                               -- the result of the previous dockerfile results+    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     printResult res =         case format of             TTY -> TTY.printResult res@@ -171,17 +214,18 @@             Checkstyle -> Checkstyle.printResult res             CodeclimateJson -> Codeclimate.printResult res >> exitSuccess     lintDockerfile ignoreRules dockerFile = do-        ast <- parseFile $ parseFilename dockerFile+        ast <- Docker.parseFile (parseFilename dockerFile)         return (processedFile ast)       where         processedFile = fmap processRules-        processRules fileLines = filter ignoredRules (analyzeAll fileLines)+        processRules fileLines = filter ignoredRules (analyzeAll rulesConfig fileLines)         ignoredRules = ignoreFilter ignoreRules -analyzeAll :: Dockerfile -> [RuleCheck]-analyzeAll = analyze rules+-- | Returns the result of applying all the rules to the given dockerfile+analyzeAll :: Rules.RulesConfig -> Dockerfile -> [Rules.RuleCheck]+analyzeAll config = Rules.analyze (Rules.rules ++ Rules.optionalRules config) --- Helper to analyze AST quickly in GHCI-analyzeEither :: Either t Dockerfile -> [RuleCheck]-analyzeEither (Left _) = []-analyzeEither (Right dockerFile) = analyzeAll dockerFile+-- | Helper to analyze AST quickly in GHCI+analyzeEither :: Rules.RulesConfig -> Either t Dockerfile -> [Rules.RuleCheck]+analyzeEither _ (Left _) = []+analyzeEither config (Right dockerFile) = analyzeAll config dockerFile
hadolint.cabal view
@@ -2,10 +2,10 @@ -- -- see: https://github.com/sol/hpack ----- hash: 0723e0fb6351589066a83e427a444fd8297f43911d23ac2c3a053888a957f94b+-- hash: 1117d08fad1cb1f1cbc95bffaff37881c65142fe9772c00690c26a5e0070a4c6  name:           hadolint-version:        1.9.0+version:        1.10.1 synopsis:       Dockerfile Linter JavaScript API description:    A smarter Dockerfile linter that helps you build best practice Docker images. category:       Development@@ -27,27 +27,27 @@ library   hs-source-dirs:       src-  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path   build-depends:       ShellCheck >=0.5.0     , aeson     , base >=4.8 && <5     , bytestring     , containers-    , language-docker >=6.0.1 && <7+    , language-docker >=6.0.3 && <7     , megaparsec >=6.4     , mtl     , split >=0.2     , text     , void   exposed-modules:-      Hadolint.Bash-      Hadolint.Rules+      Hadolint.Formatter.Checkstyle+      Hadolint.Formatter.Codeclimate       Hadolint.Formatter.Format       Hadolint.Formatter.Json       Hadolint.Formatter.TTY-      Hadolint.Formatter.Codeclimate-      Hadolint.Formatter.Checkstyle+      Hadolint.Rules+      Hadolint.Shell   other-modules:       Paths_hadolint   default-language: Haskell2010@@ -56,14 +56,15 @@   main-is: Main.hs   hs-source-dirs:       app-  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path   build-depends:       base >=4.8 && <5+    , containers     , directory >=1.3.0     , filepath     , gitrev >=1.3.1     , hadolint-    , language-docker >=6.0.1 && <7+    , language-docker >=6.0.3 && <7     , megaparsec >=6.4     , optparse-applicative >=0.14.0     , text@@ -79,7 +80,7 @@   main-is: Spec.hs   hs-source-dirs:       test-  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -optP-Wno-nonportable-include-path   build-depends:       HUnit >=1.2     , ShellCheck >=0.5.0@@ -88,7 +89,7 @@     , bytestring >=0.10     , hadolint     , hspec-    , language-docker >=6.0.1 && <7+    , language-docker >=6.0.3 && <7     , megaparsec >=6.4     , split >=0.2     , text
− src/Hadolint/Bash.hs
@@ -1,115 +0,0 @@-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--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" ++ 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-              }-    }--extractTokensWith :: (Token -> Maybe Token) -> ParsedBash -> [Token]-extractTokensWith extractor (ParsedBash _ ast) =-    case prRoot ast of-        Nothing -> []-        Just script -> nub . execWriter $ ShellCheck.AST.doAnalysis extract script-  where-    extract :: Token -> Writer [Token] ()-    extract token =-      case extractor token of-        Nothing -> return ()-        Just t -> tell [t]--findPipes :: ParsedBash -> [Token]-findPipes = extractTokensWith pipesExtractor-  where-    pipesExtractor pipe@T_Pipe{} = Just pipe-    pipesExtractor _ = Nothing--hasPipes :: ParsedBash -> Bool-hasPipes = not . null . findPipes--findCommands :: ParsedBash -> [Token]-findCommands = extractTokensWith commandsExtractor-  where-    commandsExtractor = ShellCheck.ASTLib.getCommand--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
@@ -4,13 +4,13 @@ module Hadolint.Rules where  import Control.Arrow ((&&&))-import Data.List (dropWhile, isInfixOf, isPrefixOf, mapAccumL)+import Data.List (dropWhile, isInfixOf, isPrefixOf, mapAccumL, nub) import Data.List.NonEmpty (toList) import Data.Maybe (catMaybes)-import qualified Hadolint.Bash as Bash+import qualified Hadolint.Shell as Shell import Language.Docker.Syntax -import Data.Semigroup ((<>))+import Data.Semigroup (Semigroup, (<>)) import qualified Data.Set as Set import qualified Data.Text as Text import Data.Void (Void)@@ -37,32 +37,57 @@     , success :: Bool     } deriving (Eq) +-- | 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)+ instance Ord RuleCheck where     a `compare` b = linenumber a `compare` linenumber b +instance Semigroup RulesConfig where+    RulesConfig a <> RulesConfig b = RulesConfig (a <> b)++instance Monoid RulesConfig where+    mempty = RulesConfig mempty+    mappend = (<>)+ type IgnoreRuleParser = Megaparsec.Parsec Void Text.Text -type ParsedFile = [InstructionPos Bash.ParsedBash]+type ParsedFile = [InstructionPos Shell.ParsedShell] +-- | 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)++-- | A function to check individual dockerfile instructions.+-- It gets the current line number.+-- It should return True if the check passes for the given instruction.+type SimpleCheckerWithLine = (Linenumber -> Instruction Shell.ParsedShell -> Bool)++-- | 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])+ 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 with parsed bash and returns the executed checks+-- a Rule takes a Dockerfile with parsed shell 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 Bash.ParsedBash -> (state, Bool))-    -> state-    -> Rule-mapInstructions metadata f initialState dockerfile =+mapInstructions :: CheckerWithState state -> state -> Rule+mapInstructions f initialState dockerfile =     let (_, results) = mapAccumL applyRule initialState dockerfile-    in results+    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,@@ -72,37 +97,34 @@         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)+        in (newState, [RuleCheck m source linenumber False | m <- res])  instructionRule ::-       Text.Text -> Severity -> Text.Text -> (Instruction Bash.ParsedBash -> Bool) -> Rule+       Text.Text -> Severity -> Text.Text -> (Instruction Shell.ParsedShell -> Bool) -> Rule instructionRule code severity message check =     instructionRuleLine code severity message (const check) -instructionRuleLine ::-       Text.Text-    -> Severity-    -> Text.Text-    -> (Linenumber -> Instruction Bash.ParsedBash -> Bool)-    -> Rule+instructionRuleLine :: Text.Text -> Severity -> Text.Text -> SimpleCheckerWithLine -> Rule instructionRuleLine code severity message check =     instructionRuleState code severity message checkAndDropState ()   where     checkAndDropState state line instr = (state, check line instr)  instructionRuleState ::-       Text.Text-    -> Severity-    -> Text.Text-    -> (state -> Linenumber -> Instruction Bash.ParsedBash -> (state, Bool))-    -> state-    -> Rule-instructionRuleState code severity message = mapInstructions (Metadata code severity message)+       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, [])  withState :: a -> b -> (a, b) withState st res = (st, res) -argumentsRule :: (Bash.ParsedBash -> a) -> Arguments Bash.ParsedBash -> a+argumentsRule :: (Shell.ParsedShell -> a) -> Arguments Shell.ParsedShell -> a argumentsRule applyRule args =     case args of         ArgumentsText as -> applyRule as@@ -110,13 +132,17 @@  -- Enforce rules on a dockerfile and return failed checks analyze :: [Rule] -> Dockerfile -> [RuleCheck]-analyze list dockerfile = filter failed $ concat [r parsedFile | r <- list]+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+    ]   where-    failed RuleCheck {metadata = Metadata {code}, linenumber, success} =-        not success && not (wasIgnored code linenumber)+    notIgnored RuleCheck {metadata = Metadata {code}, linenumber} = 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+    parsedFile = map (fmap Shell.parseShell) dockerfile  ignored :: Dockerfile -> [(Linenumber, [Text.Text])] ignored dockerfile =@@ -145,7 +171,7 @@ rules :: [Rule] rules =     [ absoluteWorkdir-    , shellcheckBash+    , shellcheck     , invalidCmd     , copyInsteadAdd     , copyEndingSlash@@ -178,22 +204,8 @@     , usePipefail     ] -commentMetadata :: ShellCheck.Interface.Comment -> Metadata-commentMetadata (ShellCheck.Interface.Comment severity code message) =-    Metadata (Text.pack ("SC" ++ show code)) severity (Text.pack message)--shellcheckBash :: ParsedFile -> [RuleCheck]-shellcheckBash = concatMap check-  where-    check (InstructionPos (Run args) source linenumber) =-        argumentsRule (applyRule source linenumber) args-    check _ = []-    applyRule source linenumber args =-        rmDup [RuleCheck m source linenumber False | m <- convert 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)+optionalRules :: RulesConfig -> [Rule]+optionalRules RulesConfig {allowedRegistries} = [registryIsAllowed allowedRegistries]  allFromImages :: ParsedFile -> [(Linenumber, BaseImage)] allFromImages dockerfile = [(l, f) | (l, From f) <- instr]@@ -236,6 +248,27 @@ fromAlias (TaggedImage _ _ alias) = alias fromAlias (DigestedImage _ _ alias) = alias +-------------+--  RULES  --+-------------+shellcheck :: Rule+shellcheck = mapInstructions check Shell.defaultShellOpts+  where+    check :: CheckerWithState Shell.ShellOpts+    check _ _ (From _) = (Shell.defaultShellOpts, []) -- Reset the state+    check st _ (Arg name _) = (Shell.addVars [name] st, [])+    check st _ (Env pairs) = (Shell.addVars (map fst pairs) st, [])+    check st _ (Shell (ArgumentsList script)) = (Shell.setShell (Shell.original script) st, [])+    check st _ (Shell (ArgumentsText script)) = (Shell.setShell (Shell.original script) st, [])+    check st _ (Run (ArgumentsList script)) = (st, doCheck st script)+    check st _ (Run (ArgumentsText script)) = (st, doCheck st script)+    check st _ _ = (st, [])+    doCheck opts script = nub [commentMetadata c | c <- Shell.shellcheck opts script]+    -- | Converts ShellCheck errors into our own errors type+    commentMetadata :: ShellCheck.Interface.Comment -> Metadata+    commentMetadata (ShellCheck.Interface.Comment severity code message) =+        Metadata (Text.pack ("SC" ++ show code)) severity (Text.pack message)+ absoluteWorkdir :: Rule absoluteWorkdir = instructionRule code severity message check   where@@ -258,8 +291,8 @@     check _ = True  -- Check if a command contains a program call in the Run instruction-usingProgram :: String -> Bash.ParsedBash -> Bool-usingProgram prog args = not $ null [cmd | cmd <- Bash.findCommandNames args, cmd == prog]+usingProgram :: String -> Shell.ParsedShell -> Bool+usingProgram prog args = not $ null [cmd | cmd <- Shell.findCommandNames args, cmd == prog]  multipleCmds :: Rule multipleCmds = instructionRuleState code severity message check False@@ -299,7 +332,7 @@             newState = Set.union state newArgs         in withState newState (Set.size newState < 2)     extractCommands args =-        Set.fromList [w | w <- Bash.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@@ -311,7 +344,7 @@         \`vim`, `shutdown`, `service`, `ps`, `free`, `top`, `kill`, `mount`, `ifconfig`"     check (Run args) = argumentsRule detectInvalid args     check _ = True-    detectInvalid args = null [arg | arg <- Bash.findCommandNames args, arg `elem` invalidCmds]+    detectInvalid args = null [arg | arg <- Shell.findCommandNames args, arg `elem` invalidCmds]     invalidCmds = ["ssh", "vim", "shutdown", "service", "ps", "free", "top", "kill", "mount"]  noRootUser :: Rule@@ -371,7 +404,8 @@     code = "DL3005"     severity = ErrorC     message = "Do not use apt-get upgrade or dist-upgrade"-    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "apt-get" ["upgrade"])) args+    check (Run args) =+        argumentsRule (Shell.noCommands (Shell.cmdHasArgs "apt-get" ["upgrade"])) args     check _ = True  noUntagged :: Rule@@ -408,16 +442,16 @@     check _ = True     versionFixed package = "=" `isInfixOf` package -aptGetPackages :: Bash.ParsedBash -> [String]+aptGetPackages :: Shell.ParsedShell -> [String] aptGetPackages args =     [ arg-    | cmd <- dropTarget <$> Bash.findCommands args-    , arg <- Bash.getArgsNoFlags cmd-    , Bash.cmdHasArgs "apt-get" ["install"] cmd+    | cmd <- dropTarget <$> Shell.findCommands args+    , arg <- Shell.getArgsNoFlags cmd+    , Shell.cmdHasArgs "apt-get" ["install"] cmd     , arg /= "install"     ]   where-    dropTarget = Bash.dropFlagArg ["t", "target-release"]+    dropTarget = Shell.dropFlagArg ["t", "target-release"]  aptGetCleanup :: Rule aptGetCleanup dockerfile = instructionRuleState code severity message check Nothing dockerfile@@ -440,8 +474,8 @@         | not (hasUpdate args) || not (imageIsUsed line baseimage) = True         | otherwise = hasCleanup args     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)+        any (Shell.cmdHasArgs "rm" ["-rf", "/var/lib/apt/lists/*"]) (Shell.findCommands args)+    hasUpdate args = any (Shell.cmdHasArgs "apt-get" ["update"]) (Shell.findCommands args)     imageIsUsed line baseimage = isLastImage line baseimage || imageIsUsedLater line baseimage     isLastImage line baseimage =         case reverse (allFromImages dockerfile) of@@ -459,7 +493,7 @@     code = "DL3017"     severity = ErrorC     message = "Do not use apk upgrade"-    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "apk" ["upgrade"])) args+    check (Run args) = argumentsRule (Shell.noCommands (Shell.cmdHasArgs "apk" ["upgrade"])) args     check _ = True  apkAddVersionPinned :: Rule@@ -473,16 +507,16 @@     check _ = True     versionFixed package = "=" `isInfixOf` package -apkAddPackages :: Bash.ParsedBash -> [String]+apkAddPackages :: Shell.ParsedShell -> [String] apkAddPackages args =     [ arg-    | cmd <- dropTarget <$> Bash.findCommands args-    , arg <- Bash.getArgsNoFlags cmd-    , Bash.cmdHasArgs "apk" ["add"] cmd+    | cmd <- dropTarget <$> Shell.findCommands args+    , arg <- Shell.getArgsNoFlags cmd+    , Shell.cmdHasArgs "apk" ["add"] cmd     , arg /= "add"     ]   where-    dropTarget = Bash.dropFlagArg ["t", "virtual"]+    dropTarget = Shell.dropFlagArg ["t", "virtual"]  apkAddNoCache :: Rule apkAddNoCache = instructionRule code severity message check@@ -492,9 +526,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 (Bash.noCommands forgotCacheOption) args+    check (Run args) = argumentsRule (Shell.noCommands forgotCacheOption) args     check _ = True-    forgotCacheOption cmd = Bash.cmdHasArgs "apk" ["add"] cmd && not (Bash.hasFlag "no-cache" cmd)+    forgotCacheOption cmd = Shell.cmdHasArgs "apk" ["add"] cmd && not (Shell.hasFlag "no-cache" cmd)  useAdd :: Rule useAdd = instructionRule code severity message check@@ -530,20 +564,20 @@     message =         "Pin versions in pip. Instead of `pip install <package>` use `pip install \         \<package>==<version>`"-    check (Run args) = argumentsRule (Bash.noCommands forgotToPinVersion) args+    check (Run args) = argumentsRule (Shell.noCommands forgotToPinVersion) args     check _ = True     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 Bash.getCommandName cmd of+        case Shell.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)+        ["install"] `isInfixOf` Shell.getAllArgs cmd &&+        not (["-r"] `isInfixOf` Shell.getAllArgs cmd || ["."] `isInfixOf` Shell.getAllArgs cmd)+    packages cmd = stripInstallPrefix (Shell.getArgsNoFlags cmd)     versionFixed package = hasVersionSymbol package || isVersionedGit package     isVersionedGit package = "git+http" `isInfixOf` package && "@" `isInfixOf` package     versionSymbols = ["==", ">=", "<=", ">", "<", "!=", "~=", "==="]@@ -570,11 +604,11 @@     message =         "Pin versions in npm. Instead of `npm install <package>` use `npm install \         \<package>@<version>`"-    check (Run args) = argumentsRule (Bash.noCommands forgotToPinVersion) args+    check (Run args) = argumentsRule (Shell.noCommands forgotToPinVersion) args     check _ = True     forgotToPinVersion cmd = isNpmInstall cmd && not (all versionFixed (packages cmd))-    isNpmInstall = Bash.cmdHasArgs "npm" ["install"]-    packages cmd = stripInstallPrefix (Bash.getArgsNoFlags cmd)+    isNpmInstall = Shell.cmdHasArgs "npm" ["install"]+    packages cmd = stripInstallPrefix (Shell.getArgsNoFlags cmd)     versionFixed package =         if hasGitPrefix package             then isVersionedGit package@@ -595,14 +629,14 @@     code = "DL3014"     severity = WarningC     message = "Use the `-y` switch to avoid manual input `apt-get -y install <package>`"-    check (Run args) = argumentsRule (Bash.noCommands forgotAptYesOption) args+    check (Run args) = argumentsRule (Shell.noCommands forgotAptYesOption) args     check _ = True     forgotAptYesOption cmd = isAptGetInstall cmd && not (hasYesOption cmd)-    isAptGetInstall = Bash.cmdHasArgs "apt-get" ["install"]+    isAptGetInstall = Shell.cmdHasArgs "apt-get" ["install"]     hasYesOption cmd =         "y" `elem` allFlags cmd ||         "yes" `elem` allFlags cmd || length (filter (== "q") (allFlags cmd)) > 1-    allFlags cmd = snd <$> Bash.getAllFlags cmd+    allFlags cmd = snd <$> Shell.getAllFlags cmd  aptGetNoRecommends :: Rule aptGetNoRecommends = instructionRule code severity message check@@ -610,11 +644,11 @@     code = "DL3015"     severity = InfoC     message = "Avoid additional packages by specifying `--no-install-recommends`"-    check (Run args) = argumentsRule (Bash.noCommands forgotNoInstallRecommends) args+    check (Run args) = argumentsRule (Shell.noCommands forgotNoInstallRecommends) args     check _ = True     forgotNoInstallRecommends cmd = isAptGetInstall cmd && not (hasRecommendsOption cmd)-    isAptGetInstall = Bash.cmdHasArgs "apt-get" ["install"]-    hasRecommendsOption = Bash.hasFlag "no-install-recommends"+    isAptGetInstall = Shell.cmdHasArgs "apt-get" ["install"]+    hasRecommendsOption = Shell.hasFlag "no-install-recommends"  isArchive :: Text.Text -> Bool isArchive path =@@ -702,7 +736,7 @@     code = "DL4005"     severity = WarningC     message = "Use SHELL to change the default shell"-    check (Run args) = argumentsRule (Bash.noCommands (Bash.cmdHasArgs "ln" ["/bin/sh"])) args+    check (Run args) = argumentsRule (Shell.noCommands (Shell.cmdHasArgs "ln" ["/bin/sh"])) args     check _ = True  useJsonArgs :: Rule@@ -725,15 +759,29 @@     check _ _ (Shell args) = (argumentsRule hasPipefailOption args, True)     check False _ (Run args) = (False, argumentsRule notHasPipes args)     check st _ _ = (st, True)-    notHasPipes script = not (Bash.hasPipes script)+    notHasPipes script = not (Shell.hasPipes script)     hasPipefailOption script =         not $         null             [ True-            | cmd <- Bash.findCommands script+            | cmd <- Shell.findCommands script             , validShell <- ["/bin/bash", "/bin/zsh", "/bin/ash", "bash", "zsh", "ash"]-            , Bash.getCommandName cmd == Just validShell-            , Bash.hasFlag "o" cmd-            , arg <- Bash.getAllArgs cmd+            , Shell.getCommandName cmd == Just validShell+            , Shell.hasFlag "o" cmd+            , arg <- Shell.getAllArgs cmd             , arg == "pipefail"             ]++registryIsAllowed :: Set.Set Registry -> Rule+registryIsAllowed allowed = instructionRule code severity message check+  where+    code = "DL3026"+    severity = ErrorC+    message = "Use only an allowed registry in the FROM image"+    check (From (UntaggedImage img _)) = Set.null allowed || isAllowed img+    check (From (TaggedImage img _ _)) = Set.null allowed || isAllowed img+    check _ = True+    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
+ src/Hadolint/Shell.hs view
@@ -0,0 +1,155 @@+{-# LANGUAGE OverloadedStrings #-}++module Hadolint.Shell where++import Control.Monad.Writer (Writer, execWriter, tell)+import Data.Functor.Identity (runIdentity)+import Data.List (nub)+import Data.Maybe (listToMaybe, mapMaybe)+import Data.Semigroup ((<>))+import qualified Data.Set as Set+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++data ParsedShell = ParsedShell+    { original :: Text.Text+    , parsed :: ParseResult+    }++data ShellOpts = ShellOpts+    { 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"+            ]++addVars :: [Text.Text] -> ShellOpts -> ShellOpts+addVars vars (ShellOpts n v) = ShellOpts n (v <> Set.fromList vars)++setShell :: Text.Text -> ShellOpts -> ShellOpts+setShell s (ShellOpts _ v) = ShellOpts s v++shellcheck :: ShellOpts -> ParsedShell -> [Comment]+shellcheck (ShellOpts sh env) (ParsedShell txt _) = map comment runShellCheck+  where+    runShellCheck = crComments $ runIdentity $ checkScript si spec+    comment (PositionedComment _ _ c) = c+    si = mockedSystemInterface [("", "")]+    spec = CheckSpec filename script sourced exclusions Nothing+    script = "#!" ++ extractShell sh ++ "\n" ++ printVars ++ Text.unpack txt+    filename = "" -- filename can be ommited because we only want the parse results back+    sourced = False+    exclusions =+        [ 2187 -- exclude the warning about the ash shell not being supported+        ]+    -- | Shellcheck complains when the shebang has more than one argument, so we only take the first+    extractShell s =+        case listToMaybe . Text.words $ s of+            Nothing -> ""+            Just shell -> Text.unpack shell+    -- | Inject all the collected env vars as exported variables so they can be used+    printVars = Text.unpack . Text.unlines . Set.toList $ Set.map (\v -> "export " <> v <> "=1") env++parseShell :: Text.Text -> ParsedShell+parseShell txt =+    ParsedShell+    { original = txt+    , parsed =+          runIdentity $+          ShellCheck.Parser.parseScript+              (mockedSystemInterface [("", "")])+              ParseSpec+              { psFilename = "" -- There is no filename+              , psScript = "#!/bin/bash\n" ++ Text.unpack txt+              , psCheckSourced = False+              }+    }++extractTokensWith :: (Token -> Maybe Token) -> ParsedShell -> [Token]+extractTokensWith extractor (ParsedShell _ ast) =+    case prRoot ast of+        Nothing -> []+        Just script -> nub . execWriter $ ShellCheck.AST.doAnalysis extract script+  where+    extract :: Token -> Writer [Token] ()+    extract token =+        case extractor token of+            Nothing -> return ()+            Just t -> tell [t]++findPipes :: ParsedShell -> [Token]+findPipes = extractTokensWith pipesExtractor+  where+    pipesExtractor pipe@T_Pipe {} = Just pipe+    pipesExtractor _ = Nothing++hasPipes :: ParsedShell -> Bool+hasPipes = not . null . findPipes++findCommands :: ParsedShell -> [Token]+findCommands = extractTokensWith commandsExtractor+  where+    commandsExtractor = ShellCheck.ASTLib.getCommand++allCommands :: (Token -> Bool) -> ParsedShell -> Bool+allCommands check script = all check (findCommands script)++noCommands :: (Token -> Bool) -> ParsedShell -> Bool+noCommands check = allCommands (not . check)++getCommandName :: Token -> Maybe String+getCommandName = ShellCheck.ASTLib.getCommandName++findCommandNames :: ParsedShell -> [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
test/Spec.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE OverloadedLists #-} import Test.HUnit hiding (Label) import Test.Hspec @@ -474,6 +475,101 @@                 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" -- $RANDOM is not available in sh+                        ]+                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" -- $RANDOM is available in bash+                        ]+                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+        --+        --         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"@@ -824,6 +920,35 @@                         , "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  assertChecks :: HasCallStack => Rule -> Text.Text -> ([RuleCheck] -> IO a) -> IO a assertChecks rule s makeAssertions =@@ -860,9 +985,11 @@ ruleCatchesNot :: HasCallStack => Rule -> Text.Text -> Assertion ruleCatchesNot rule s = assertChecks rule s f   where-    f checks = assertEqual "Found check of rule" 0 $ length checks+    f checks = assertEqual ("Error: " ++ errorMessages checks) 0 $ length checks+    errorMessages checks = Text.unpack . Text.unlines $ map (message . metadata) 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+    f checks = assertEqual ("Error: " ++ errorMessages checks) 0 $ length checks+    errorMessages checks = Text.unpack . Text.unlines $ map (message . metadata) checks