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 +16/−0
- app/Main.hs +86/−42
- hadolint.cabal +13/−12
- src/Hadolint/Bash.hs +0/−115
- src/Hadolint/Rules.hs +137/−89
- src/Hadolint/Shell.hs +155/−0
- test/Spec.hs +129/−2
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