├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── Dockerfile ├── Dockerfile-dev ├── LICENSE ├── Makefile ├── README.md ├── appveyor.yml ├── bin └── dockerlint.coffee ├── package.json ├── src ├── checks.coffee ├── cli.coffee ├── parser.coffee └── utils.coffee └── test ├── checksTest.coffee ├── dockerfiles ├── comment_only ├── empty ├── line_continuations └── line_count ├── parserTest.coffee └── utilsTest.coffee /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Issue details 2 | 3 | _Please provide issue details here_. 4 | 5 | ### Steps to reproduce/test case 6 | 7 | _Please provide necessary steps and relevant parts of your Dockkerfile 8 | below for reproduction of this issue_ 9 | 10 | ### Affected version(s) 11 | 12 | _Please write down the affected version(s), or `master` when using a Git clone_ 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Issue details 2 | 3 | _Please provide issue details here and how your commit(s) fix it_. 4 | 5 | ### Steps to reproduce/test case 6 | 7 | _Please provide necessary steps and relevant parts of your Dockkerfile 8 | below for reproduction of this issue_ 9 | 10 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.tgz 4 | lib 5 | bin/dockerlint.js 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | bin/dockerlint.coffee 3 | Dockerfile 4 | Makefile 5 | Vagrantfile 6 | .github 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - "0.11" 5 | - "0.12" 6 | - "4" 7 | - "5" 8 | os: 9 | - linux 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:4-alpine 2 | 3 | LABEL maintainer="Red Cool Beans " 4 | 5 | RUN npm install -g dockerlint \ 6 | && npm cache clean 7 | 8 | ENTRYPOINT ["dockerlint"] 9 | CMD [ "-f", "/Dockerfile"] 10 | -------------------------------------------------------------------------------- /Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM node:4-alpine 2 | 3 | MAINTAINER Jasper Lievisse Adriaanse 4 | 5 | COPY . /dockerlint 6 | WORKDIR /dockerlint 7 | 8 | RUN npm install -g coffee-script \ 9 | && make js \ 10 | && npm install -g \ 11 | && npm cache clean 12 | 13 | ENTRYPOINT ["dockerlint"] 14 | ENTRYPOINT ["-f", "/Dockerfile"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Red Cool Beans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REPORTER ?= spec 2 | 3 | all: js 4 | 5 | deps: 6 | npm install 7 | 8 | lint: 9 | coffeelint src 10 | 11 | js: deps 12 | coffee -c bin/dockerlint.coffee 13 | coffee -o lib -c src 14 | # Insert shebang so the resulting script runs standalone 15 | { echo '#!/usr/bin/env node '; cat bin/dockerlint.js; } > bin/dockerlint.js.tmp 16 | mv bin/dockerlint.js.tmp bin/dockerlint.js 17 | 18 | clean: 19 | rm -fr bin/*.js bin/*.tmp lib *.tgz 20 | 21 | run: js 22 | node bin/dockerlint -f Dockerfile 23 | 24 | dist: js 25 | npm pack 26 | 27 | tag: 28 | git tag -a "v`cat package.json| jsawk 'return this.version'`" \ 29 | -m `cat package.json| jsawk 'return this.version'` 30 | 31 | test: js 32 | @NODE_ENV=test ./node_modules/.bin/mocha \ 33 | --require coffee-script/register \ 34 | --require chai \ 35 | --reporter ${REPORTER} \ 36 | --compilers coffee:coffee-script/register \ 37 | test/*.coffee 38 | 39 | .PHONY: deps lint run tag test 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM](https://nodei.co/npm/dockerlint.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/dockerlint/) 2 | [![Build Status](https://travis-ci.org/RedCoolBeans/dockerlint.svg?branch=master)](https://travis-ci.org/RedCoolBeans/dockerlint) 3 | [![Build status](https://ci.appveyor.com/api/projects/status/bwvl5wexs90wspyg?svg=true)](https://ci.appveyor.com/project/jasperla/dockerlint) 4 | 5 | # Dockerlint 6 | 7 | Linting tool for Dockerfiles based on recommendations from 8 | [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/) and [Best practices for writing Dockerfiles](https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/) as of Docker 1.6. 9 | 10 | ## Install 11 | 12 | With [npm](https://npmjs.org/) just do: 13 | 14 | $ [sudo] npm install -g dockerlint 15 | 16 | ## Usage 17 | 18 | Once installed it's as easy as: 19 | 20 | dockerlint Dockerfile 21 | 22 | Which will parse the file and notify you about any actual errors (such an 23 | omitted tag when `:` is set), and warn you about common pitfalls or bad idiom 24 | such as the common use case of `ADD`. 25 | 26 | In order to treat warnings as errors, use the `-p` flag. 27 | 28 | ## Docker image 29 | 30 | Alternatively there is a [Docker image](https://hub.docker.com/r/redcoolbeans/dockerlint) available. 31 | 32 | This image provides a quick and easy way to validate your Dockerfiles, without 33 | having to install Node.JS and the dockerlint dependencies on your system. 34 | 35 | First fetch the image from the [Docker Hub](https://hub.docker.com/): 36 | 37 | docker pull redcoolbeans/dockerlint 38 | 39 | You can either run it directly, or use [docker-compose](https://www.docker.com/docker-compose). 40 | 41 | ### docker run 42 | 43 | For a quick one-off validation: 44 | 45 | docker run -it --rm -v "$PWD/Dockerfile":/Dockerfile:ro redcoolbeans/dockerlint 46 | 47 | ### docker-compose 48 | 49 | For docker-compose use a `docker-compose.yml` such as the following: 50 | 51 | --- 52 | dockerlint: 53 | image: redcoolbeans/dockerlint 54 | volumes: 55 | - ./Dockerfile:/Dockerfile 56 | 57 | Then simply run: 58 | 59 | docker-compose up dockerlint 60 | 61 | This will validate the `Dockerfile` in your current directory. 62 | 63 | 64 | ### Running from a git clone 65 | 66 | If you've cloned this repository, you will need the following prerequisites: 67 | 1. make 68 | 2. [npm](https://www.npmjs.com/) 69 | 3. [coffee](http://coffeescript.org/) 70 | 71 | Installing prerequisites on ubuntu: 72 | 73 | sudo apt-get update 74 | sudo apt-get install make 75 | sudo apt-get install npm 76 | sudo ln -s /usr/bin/nodejs /usr/bin/node 77 | sudo npm install -g coffee-script 78 | 79 | You can run `dockerlint` with: 80 | 81 | make deps # runs npm install 82 | make js && coffee bin/dockerlint.coffee 83 | 84 | If you're building on Windows, you'll have to set the path to your `make`: 85 | 86 | npm config set dockerlint:winmake "mingw32-make.exe" 87 | 88 | or pass it to every invocation: 89 | 90 | npm run build:win --dockerlint:winmake=mingw32-make.exe 91 | 92 | ## Roadmap 93 | 94 | - Add support for --version which checks against a specific Docker version 95 | - Refactor code to move the rule specific functions into a Rule class 96 | 97 | ## License 98 | 99 | MIT, please see the LICENSE file. 100 | 101 | ## Contributing 102 | 103 | 1. Fork it 104 | 2. Create your feature branch (`git checkout -b my-new-feature`) 105 | 3. Commit your changes (`git commit -am 'Add some feature'`) 106 | 4. Push to the branch (`git push origin my-new-feature`) 107 | 5. Create new Pull Request 108 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | # node.js 4 | - nodejs_version: "0.12" 5 | - nodejs_version: "4" 6 | - nodejs_version: "5" 7 | 8 | install: 9 | - ps: | 10 | # Install MinGW. 11 | $url = "http://sourceforge.net/projects/mingw-w64/files/" 12 | $url += "Toolchains%20targetting%20Win64/Personal%20Builds/" 13 | $url += "mingw-builds/4.9.0/threads-win32/seh/" 14 | $url += "x86_64-4.9.0-release-win32-seh-rt_v3-rev2.7z/download" 15 | Invoke-WebRequest -UserAgent wget -Uri $url -OutFile mingw.7z 16 | &7z x -oC:\ mingw.7z > $null 17 | - set PATH=%PATH%;C:\mingw64\bin 18 | - ps: Install-Product node $env:nodejs_version 19 | - npm install 20 | - npm config set dockerlint:winmake "mingw32-make.exe" 21 | 22 | test_script: 23 | - node --version 24 | - npm --version 25 | - npm run test:win 26 | 27 | build: off 28 | -------------------------------------------------------------------------------- /bin/dockerlint.coffee: -------------------------------------------------------------------------------- 1 | args = require('subarg') process.argv.slice(2), alias: 2 | d: 'debug' 3 | f: 'file', 4 | h: 'help', 5 | p: 'pedantic' 6 | cli = require '../lib/cli' 7 | 8 | cli.run args 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockerlint", 3 | "version": "0.3.11", 4 | "description": "Linting for Dockerfiles", 5 | "main": "bin/dockerlint", 6 | "bin": { 7 | "dockerlint": "bin/dockerlint.js" 8 | }, 9 | "config": { 10 | "make": "make", 11 | "winmake": "mingw32-make.exe" 12 | }, 13 | "scripts": { 14 | "build": "make js", 15 | "test": "make test", 16 | "build:win": "%npm_package_config_winmake% js", 17 | "test:win": "%npm_package_config_winmake% test" 18 | }, 19 | "author": { 20 | "name": "RedCoolBeans", 21 | "email": "info@redcoolbeans.com", 22 | "url": "http://www.redcoolbeans.com" 23 | }, 24 | "license": "MIT", 25 | "repository": { 26 | "type": "git", 27 | "url": "git://github.com/redcoolbeans/dockerlint.git" 28 | }, 29 | "homepage": "https://github.com/redcoolbeans/dockerlint", 30 | "bugs": { 31 | "url": "https://github.com/redcoolbeans/dockerlint/issues" 32 | }, 33 | "keywords": [ 34 | "docker", 35 | "dockerfile", 36 | "lint", 37 | "validate" 38 | ], 39 | "dependencies": { 40 | "sty": "^0.6.1", 41 | "subarg": "^1.0.0" 42 | }, 43 | "devDependencies": { 44 | "chai": "^2.1.2", 45 | "coffee-script": "^1.9.0", 46 | "mocha": "^2.2.1", 47 | "should": "^5.2.0" 48 | }, 49 | "engines": { 50 | "node": ">= 0.10.0", 51 | "coffee-script": ">= 1.0.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/checks.coffee: -------------------------------------------------------------------------------- 1 | os = require 'os' 2 | path = require 'path' 3 | utils = require "#{__dirname}/utils" 4 | 5 | args = require('subarg')(process.argv.slice(2), alias: 6 | d: 'debug' 7 | f: 'file' 8 | h: 'help' 9 | p: 'pedantic') 10 | 11 | if args.pedantic 12 | exports.pedantic_ret = 'failed' 13 | exports.pedantic_severity = 'ERROR' 14 | else 15 | exports.pedantic_ret = 'warning' 16 | exports.pedantic_severity = 'WARN' 17 | 18 | exports.all = [ 19 | 'arg', 20 | 'from_first', 21 | 'no_empty_tag', 22 | 'no_empty_digest', 23 | 'json_array_brackets', 24 | 'json_array_even_quotes', 25 | 'json_array_format', 26 | 'env', 27 | 'recommended_exec_form', 28 | 'add', 29 | 'multiple_entries', 30 | 'sudo', 31 | 'absolute_workdir', 32 | 'onbuild_copyadd', 33 | 'onbuild_disallowed', 34 | 'label_no_empty_value', 35 | 'variable_use', 36 | 'no_trailing_spaces', 37 | 'unknown_instruction', 38 | 'maintainer_deprecated' 39 | ] 40 | 41 | # Match $VAR, ${VAR}, and ${VAR:-default} 42 | # FIXME: does not handle \$VAR escaping 43 | # Variable name 'VAR' is Group 1 or Group 2 44 | exports.varPattern = /// 45 | (?: # Don't capture, just match 46 | \$ # Dollar sign to start the variable 47 | (?: # Don't capture, just group 48 | ([\w]+) # Match one or more word characters (greedy), this is the variable name like VAR 49 | | # Or 50 | \{ # Match the starting brace for variables like ${VAR} 51 | (\w+) # Match one or more word characters (greedy), this is the variable name like VAR 52 | .*?\} # Match characters after variable name to handle ${VAR} and ${VAR:-default} 53 | )) # End the non-capturing groups 54 | ///g # Global match to find all variables in a string 55 | 56 | # Cache the ARG variables for lookup 57 | exports.arg = [] 58 | # Cache the ENV variables for lookup 59 | exports.env = [] 60 | 61 | Array::filter = (func) -> x for x in @ when func(x) 62 | 63 | # Returns all rules for `instruction` 64 | exports.getAll = (instruction, rules) -> 65 | rules.filter (r) -> r.instruction is instruction 66 | 67 | # Returns all rules except those for `instruction`. 68 | exports.getAllExcept = (instruction, rules) -> 69 | rules.filter (r) -> r.instruction isnt instruction 70 | 71 | # Return effective available variables from ARG and ENV 72 | exports.getAllVariables = (rules) -> 73 | this.arg(rules, true) 74 | this.env(rules, true) 75 | # ENV overrides ARG https://docs.docker.com/engine/reference/builder/#using-arg-variables 76 | utils.merge(exports.arg, exports.env) 77 | 78 | # Merge variables from rule (ARG, ENV) with provided object 79 | exports.mergeVariables = (o, rule, empty) -> 80 | for argument in rule.arguments 81 | if argument.split(' ')[0].match(/(\w+)=([^\s]+)/) 82 | for pair in argument.split(' ') 83 | p = pair.split(/(\w+)=([^\s]+)/) 84 | o[p[1]] = p[2] 85 | else 86 | env = argument.match(/^(\S+)\s(.*)/) 87 | if env 88 | env = env.slice(1) 89 | else if argument && empty 90 | # empty ARG definition so set an empty value 91 | o[argument] = '' 92 | return 'ok' 93 | else 94 | return 'failed' 95 | if env[0] && env[1] 96 | o[env[0]] = env[1] 97 | else 98 | return 'failed' 99 | return 'ok' 100 | 101 | # Check that all variables in a string are defined 102 | exports.variablesDefined = (vars, s) -> 103 | while match = exports.varPattern.exec(s) 104 | m = match[1] || match[2] 105 | unless vars[m] || isFinite(m) # Command line arguments such as $1, $2... should be ignored 106 | #utils.log 'DEBUG', "Undefined variable match #{match} within #{s}" 107 | return 'failed' 108 | return 'ok' 109 | 110 | # Replace all variables with values 111 | exports.variablesReplace = (vars, s) -> 112 | s.replace exports.varPattern, (match, g1, g2, offset, str) -> 113 | m = g1 || g2 114 | if vars[m] 115 | return str.replace(match,vars[m]) 116 | else 117 | #utils.log 'DEBUG', "Undefined variable replacement #{match} within #{str}" 118 | return str 119 | 120 | # FROM should be the first non-comment instruction in the Dockerfile 121 | # it may be preceeded by ARG 122 | # Reports: ERROR 123 | exports.from_first = (rules) -> 124 | non_comments = this.getAllExcept('comment', rules) 125 | first = non_comments[0] 126 | 127 | if first.instruction isnt 'FROM' 128 | unless first.instruction is 'ARG' 129 | utils.log 'ERROR', "First instruction must be 'FROM', is: #{first.instruction}" 130 | return 'failed' 131 | return 'ok' 132 | 133 | # If no tag is given to the FROM instruction, latest is assumed. If the used 134 | # tag does not exist, an error will be returned. 135 | # Reports: ERROR if tag is empty 136 | exports.no_empty_tag = (rules) -> 137 | from = this.getAll('FROM', rules) 138 | for rule in from 139 | # FROM lines can only have a single argument, so use [0]. 140 | if rule.arguments[0].match /:/ 141 | [image, tag] = rule.arguments[0].split ':' 142 | unless utils.notEmpty tag 143 | utils.log 'ERROR', "Tag must not be empty for \"#{image}\" on line #{rule.line}" 144 | return 'failed' 145 | return 'ok' 146 | 147 | # If no digest is given to the FROM instruction an error will be returned when 148 | # a digest is expected. 149 | # Reports: ERROR if digest is empty 150 | # Docker: 1.6 151 | exports.no_empty_digest = (rules) -> 152 | from = this.getAll('FROM', rules) 153 | for rule in from 154 | # FROM lines can only have a single argument, so use [0]. 155 | if rule.arguments[0].match /@/ 156 | [image, digest] = rule.arguments[0].split '@' 157 | unless utils.notEmpty digest 158 | utils.log 'ERROR', "Digest must not be empty for \"#{image}\" on line #{rule.line}" 159 | return 'failed' 160 | return 'ok' 161 | 162 | # The exec form is parsed as a JSON array, which means that you must use 163 | # double-quotes (") around words not single-quotes ('). 164 | # Reports: ERROR 165 | exports.json_array_format = (rules) -> 166 | for i in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 167 | rule = this.getAll(i, rules) 168 | for r in rule 169 | errmsg = "Arguments to #{i} in exec form must not contain single quotes on line #{r.line}" 170 | for argument in r.arguments 171 | # Check if we're dealing with Array notation 172 | if argument.match /^\[.*\]/ 173 | # Break the literal array into it's logical components 174 | for arg in argument.split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/, -1) 175 | if not arg.trim().match /^(\[?(\s+)?\".*\"(\s+)?\]?)|(\[\])$/ 176 | utils.log 'ERROR', errmsg 177 | return 'failed' 178 | return 'ok' 179 | 180 | # Ensure the exec form contains a balanced number of double quotes. 181 | # Reports: ERROR 182 | exports.json_array_even_quotes = (rules) -> 183 | for i in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 184 | rule = this.getAll(i, rules) 185 | for r in rule 186 | # First turn all the arguments into a single string so that we have the 187 | # full overview of the arguments which we then split on \". If the number 188 | # of elements is uneven `quotes` will be true and thus we have an invalid 189 | # argument list. Otherwise we get an even number of elements 190 | # (for an even number modulo 2 is 0). 191 | quotes = r.arguments.join(' ').split('"') 192 | unless (quotes.length) % 2 193 | utils.log 'ERROR', "Odd number of double quotes on line #{r.line}" 194 | return 'failed' 195 | return 'ok' 196 | 197 | # Ensure the exec form contains one opening and one closing square bracket. 198 | # Reports: ERROR 199 | exports.json_array_brackets = (rules) -> 200 | for i in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 201 | rule = this.getAll(i, rules) 202 | for r in rule 203 | # First make sure we're actually dealing with the exec form (not ignoring position) 204 | unless r.arguments[0].match(/(^\s*\[)|(\]\s*$)/g) 205 | continue 206 | 207 | # Check if this is a valid JSON array 208 | try 209 | # parse to JSON 210 | arg2json = JSON.parse r.arguments.join(' ') 211 | # count number of entries in main array that are arrays 212 | nArray = arg2json.filter (z) -> return utils.isArray(z) 213 | # if there are array entries, then this should be alerted 214 | if nArray.length > 0 215 | utils.log 'ERROR', "Nested array found on line #{r.line}" 216 | return 'failed' 217 | 218 | return 'ok' 219 | catch e 220 | utils.log 'ERROR', "Invalid array on line #{r.line}" 221 | return 'failed' 222 | return 'ok' 223 | 224 | # Using the exec form is recommended for certain instructions 225 | # Reports: WARN 226 | exports.recommended_exec_form = (rules) -> 227 | for i in [ 'CMD', 'ENTRYPOINT' ] 228 | rule = this.getAll(i, rules) 229 | for r in rule 230 | nr = r.arguments.join(' ').split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/, -1) 231 | lbracket = nr[0].match(/\[/g) 232 | rbracket = nr[nr.length-1].match(/\]/g) 233 | 234 | if !lbracket? || !rbracket? 235 | utils.log exports.pedantic_severity, "Recommended exec/array form not used on line #{r.line}" 236 | return exports.pedantic_ret 237 | return 'ok' 238 | 239 | # Although ADD and COPY are functionally similar, generally speaking, COPY is preferred. 240 | # The only/best use for ADD is to add-and-extract archives to an image. 241 | # Reports: WARN 242 | exports.add = (rules) -> 243 | add = this.getAll('ADD', rules) 244 | if add.length > 0 245 | lines = [] 246 | for rule in add 247 | # If the source file is a recognized format (tar, gz, bz, xz), it's allowed 248 | # usage of ADD. Because image size matters, using ADD to fetch packages from 249 | # remote URLs is strongly discouraged; you should use curl or wget instead. 250 | # That way you can delete the files you no longer need after they've been 251 | # extracted and you won't have to add another layer in your image. 252 | lines.push rule.line unless rule.arguments[0].match(/\.(tar|gz|bz2|xz)/) 253 | 254 | if lines.length > 0 255 | utils.log exports.pedantic_severity, "ADD instruction used instead of COPY on line #{lines.join ', '}" 256 | return exports.pedantic_ret 257 | return 'ok' 258 | 259 | # There can only be one CMD/ENTRYPOINT instruction in a Dockerfile. 260 | # If you list more than one CMD then only the last CMD will take effect. 261 | # Reports: ERROR 262 | exports.multiple_entries = (rules) -> 263 | for e in [ 'CMD', 'ENTRYPOINT' ] 264 | rule = this.getAll(e, rules) 265 | if rule.length > 1 266 | # Checks for multi-stage builds 267 | last_rule = rule.pop() 268 | froms = this.getAll('FROM', rules) 269 | while rule.length > 0 270 | matches = [] 271 | previous_rule = rule.pop() 272 | matches.push f for f in froms when f.line < last_rule.line && f.line > previous_rule.line 273 | if matches.length == 0 274 | utils.log 'ERROR', "Multiple #{e} instructions found in the same FROM instruction, only line #{last_rule.line} will take effect" 275 | return 'failed' 276 | last_rule = previous_rule 277 | return 'ok' 278 | 279 | # You should avoid installing or using sudo since it has unpredictable TTY and 280 | # signal-forwarding behavior that can cause more more problems than it solves 281 | # Reports: WARN 282 | exports.sudo = (rules) -> 283 | run = this.getAll('RUN', rules) 284 | for rule in run 285 | for argument in rule.arguments 286 | if argument.match /(^|.*;)\s*(\/?.*\/)?sudo(\s|$)/ 287 | utils.log exports.pedantic_severity, "sudo(8) usage found on line #{rule.line} which is discouraged" 288 | return exports.pedantic_ret 289 | return 'ok' 290 | 291 | # Check ENV syntax and save the variables for further evaluation if needed. 292 | # Reports: ERROR 293 | exports.env = (rules, ignore = false) -> 294 | environs = this.getAll('ENV', rules) 295 | for rule in environs 296 | unless exports.mergeVariables(exports.env, rule, false) is 'ok' 297 | if not ignore 298 | utils.log 'ERROR', "ENV invalid format #{rule.arguments} on line #{rule.line}" 299 | return 'failed' 300 | return 'ok' 301 | 302 | # Check ARG syntax and save the variables for further evaluation if needed. 303 | # Save pre-defined ARG variables 304 | # Reports: ERROR 305 | exports.arg = (rules, ignore = false) -> 306 | for pre in ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'http_proxy', 'FTP_PROXY', 'ftp_proxy', 'NO_PROXY', 'no_proxy'] 307 | exports.arg[pre] = 'true' 308 | 309 | args = this.getAll('ARG', rules) 310 | for rule in args 311 | unless exports.mergeVariables(exports.arg, rule, true) is 'ok' 312 | if not ignore 313 | utils.log 'ERROR', "ARG invalid format #{rule.arguments} on line #{rule.line}" 314 | return 'failed' 315 | return 'ok' 316 | 317 | # For clarity and reliability, you should always use absolute paths for your WORKDIR. 318 | # Reports: ERROR 319 | exports.absolute_workdir = (rules) -> 320 | vars = this.getAllVariables(rules) 321 | workdir = this.getAll('WORKDIR', rules) 322 | for rule in workdir 323 | while match = exports.varPattern.exec(rule.arguments[0]) 324 | m = match[1] || match[2] 325 | if exports.arg[m] 326 | unless exports.env[m] 327 | utils.log exports.pedantic_severity, "WORKDIR path #{rule.arguments} contains an ARG variable. WORKDIR should resolve to an absolute path at build time" 328 | return exports.pedantic_ret 329 | 330 | rule.arguments[0] = exports.variablesReplace(vars, rule.arguments[0]) 331 | 332 | if (typeof path.isAbsolute != "undefined") 333 | absolute = path.isAbsolute(rule.arguments[0]) 334 | else 335 | absolute = rule.arguments[0].charAt(0) == '/'; 336 | 337 | unless absolute 338 | utils.log 'ERROR', "WORKDIR path #{rule.arguments} must be absolute on line #{rule.line}" 339 | return 'failed' 340 | return 'ok' 341 | 342 | # Be careful when putting ADD or COPY in ONBUILD. 343 | # Reports: WARN 344 | exports.onbuild_copyadd = (rules) -> 345 | onbuild = this.getAll('ONBUILD', rules) 346 | for rule in onbuild 347 | for argument in rule.arguments 348 | if argument.match /ADD|COPY/ 349 | utils.log exports.pedantic_severity, "It is advised not to use ADD or COPY for ONBUILD on line #{rule.line}" 350 | return exports.pedantic_ret 351 | return 'ok' 352 | 353 | # Chaining ONBUILD instructions using ONBUILD ONBUILD isn't allowed. 354 | # The ONBUILD instruction may not trigger FROM or MAINTAINER instructions. 355 | # Reports: ERROR 356 | exports.onbuild_disallowed = (rules) -> 357 | onbuild = this.getAll('ONBUILD', rules) 358 | for rule in onbuild 359 | for argument in rule.arguments 360 | chained_instruction = argument.split(' ')[0] 361 | if chained_instruction.match(/ONBUILD|FROM|MAINTAINER/) 362 | utils.log 'ERROR', "ONBUILD may not be chained with #{chained_instruction} on line #{rule.line}" 363 | return 'failed' 364 | return 'ok' 365 | 366 | # LABEL instructions are a key-value pair, of which the value may be ommitted 367 | # iff there is no equal sign. 368 | # Reports: ERROR 369 | # Docker: 1.6 370 | exports.label_no_empty_value = (rules) -> 371 | label = this.getAll('LABEL', rules) 372 | for rule in label 373 | for argument in rule.arguments 374 | for pair in argument.split(' ') 375 | if pair.slice(-1) == '=' 376 | utils.log 'ERROR', "LABEL requires value for line #{rule.line}" 377 | return 'failed' 378 | return 'ok' 379 | 380 | # Variables used within allowed instructions must be defined in ENV or ARG 381 | # Reports: ERROR 382 | exports.variable_use = (rules) -> 383 | vars = this.getAllVariables(rules) 384 | for i in [ 'ADD', 'COPY', 'ENV', 'EXPOSE', 'FROM', 'LABEL', 'ONBUILD', 'RUN', 'STOPSIGNAL', 'USER', 'VOLUME', 'WORKDIR' ] 385 | instruction = this.getAll(i, rules) 386 | for rule in instruction 387 | for argument in rule.arguments 388 | unless exports.variablesDefined(vars, argument) is 'ok' 389 | utils.log exports.pedantic_severity, "#{rule.instruction} might contain undefined ARG or ENV variable on line #{rule.line}" 390 | return 'warning' 391 | return 'ok' 392 | 393 | # No trailing spaces 394 | exports.no_trailing_spaces = (rules) -> 395 | for rule in rules 396 | if rule.raw.endsWith ' ' 397 | utils.log 'ERROR', 'Lines cannot have trailing spaces' 398 | return 'failed' 399 | return 'ok' 400 | 401 | # Unknown instructions are not supported by dockerlint 402 | # Reports: ERROR 403 | exports.unknown_instruction = (rules) -> 404 | allowed_instructions = [ 'ADD', 'ARG', 'CMD', 'COPY', 'ENTRYPOINT', 'ENV', 'EXPOSE', 'FROM', 405 | 'HEALTHCHECK', 'LABEL', 'MAINTAINER', 'ONBUILD', 'RUN', 'SHELL', 406 | 'STOPSIGNAL', 'USER', 'VAR', 'VOLUME', 'WORKDIR' ] 407 | non_comments = this.getAllExcept('comment', rules) 408 | for rule in non_comments 409 | if rule.instruction not in allowed_instructions 410 | if utils.notEmpty rule.instruction 411 | utils.log 'ERROR', "#{rule.instruction} is invalid on line #{rule.line}" 412 | else 413 | utils.log 'ERROR', "Empty / bogus instruction is invalid on line #{rule.line}" 414 | return 'failed' 415 | return 'ok' 416 | 417 | # https://docs.docker.com/engine/reference/builder/#maintainer-deprecated 418 | # Reports: WARN 419 | exports.maintainer_deprecated = (rules) -> 420 | ms = this.getAll('MAINTAINER', rules) 421 | if ms.length > 0 422 | for m in ms 423 | utils.log exports.pedantic_severity, "MAINTAINER instruction is deprecated on line #{m.line}" 424 | return exports.pedantic_ret 425 | 426 | return 'ok' 427 | -------------------------------------------------------------------------------- /src/cli.coffee: -------------------------------------------------------------------------------- 1 | checks = require "#{__dirname}/checks" 2 | fs = require 'fs' 3 | meta = require "#{__dirname}/../package.json" 4 | parser = require "#{__dirname}/parser" 5 | utils = require "#{__dirname}/utils" 6 | 7 | usage = -> 8 | console.log "Dockerlint #{meta["version"]}\n\n 9 | \tusage: dockerlint [-h] [-d] [-p] [-f Dockerfile]" 10 | process.exit 0 11 | 12 | report = (dockerfile, ok) -> 13 | if ok 14 | console.log "" 15 | utils.log "INFO", "#{dockerfile} is OK.\n" 16 | else 17 | console.log "" 18 | utils.log "FATAL", "#{dockerfile} failed.\n" 19 | 20 | exports.run = (args) -> 21 | if args.help 22 | do usage 23 | 24 | # If no file is explicitly passed with -f, try the first 25 | # unbound argument and fallback to 'Dockerfile' 26 | dockerfile = args.file || args._[0] || 'Dockerfile' 27 | 28 | # Ensure that 'dockerfile' is a String; if the filename happens to be 29 | # an integer (e.g. '1'), lstat() and other cannot handle it. 30 | dockerfile = dockerfile.toString() 31 | 32 | unless fs.existsSync dockerfile 33 | utils.log "FATAL", "Cannot open #{dockerfile}." 34 | 35 | if not fs.lstatSync(dockerfile).isFile() 36 | utils.log "FATAL", "#{dockerfile} is not a file." 37 | 38 | rules = parser.parser(dockerfile) 39 | 40 | if rules.length == 0 41 | utils.log "FATAL", "#{dockerfile} does not contain any instructions" 42 | 43 | if args.debug 44 | utils.log 'DEBUG', rules 45 | 46 | ok = true 47 | for check in checks.all 48 | if checks[check](rules) is 'failed' 49 | ok = false 50 | 51 | report(dockerfile, ok) -------------------------------------------------------------------------------- /src/parser.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | utils = require "#{__dirname}/utils" 3 | 4 | # Return the first word from a string 5 | exports.getInstruction = (s) -> 6 | instruction = s.split(/[ \t]/)[0] 7 | if instruction.beginsWith('#') or instruction.match(/\s*[#;].*$/) 8 | 'comment' 9 | else 10 | instruction 11 | 12 | # Return everything but the first word from a string, 13 | # and remove a trailing '\' if needed 14 | exports.getArguments = (s) -> 15 | inst = this.getInstruction(s) 16 | inst = '#' if inst == 'comment' 17 | [s.replace(inst, '').replace(/\\(\s*)$/, '').trim()] 18 | 19 | exports.parser = (dockerfile) -> 20 | # First try to parse the entire file into `rules` before analyzing it. 21 | rules = [] 22 | self = this 23 | do -> 24 | lineno = 1 25 | cont = false 26 | rule = [] 27 | 28 | try 29 | data = fs.readFileSync(dockerfile).toString().split /\r?\n/ 30 | catch e 31 | return [] 32 | 33 | for line in data 34 | if utils.notEmpty(line) and not line.beginsWith '#' 35 | # If the current line ends with \ then set `cont` to true, 36 | # save the line and instruction and arguments into `rule`. 37 | if line.endsWith '\\' 38 | if cont 39 | # already on a continuation, just append to arguments 40 | rule[0].arguments = rule[0].arguments.concat self.getArguments(line) 41 | if not utils.notEmpty(rule[0].arguments[0]) 42 | rule[0].arguments.shift() 43 | else 44 | cont = true 45 | rule.push raw: line, line: lineno, instruction: self.getInstruction(line), arguments: self.getArguments(line) 46 | # if current line does not end with \ and cont is true 47 | # push the saved rule + arguments of current line into `rules` 48 | # and set `cont` to false and empty `rule` 49 | else if cont and not line.endsWith '\\' 50 | rules.push raw: line, line: rule[0].line, instruction: rule[0].instruction, arguments: rule[0].arguments.concat self.getArguments(line) 51 | rule = [] 52 | cont = false 53 | # Just save the line, nothing fancy going on now. 54 | else if not (line.endsWith '\\' and cont) 55 | rules.push raw: line, line: lineno, instruction: self.getInstruction(line), arguments: self.getArguments(line) 56 | else if line.beginsWith '#' 57 | rules.push raw: line, line: lineno, instruction: self.getInstruction(line), arguments: self.getArguments(line) 58 | 59 | lineno++ 60 | rules 61 | -------------------------------------------------------------------------------- /src/utils.coffee: -------------------------------------------------------------------------------- 1 | sty = require 'sty' 2 | 3 | String::beginsWith ?= (s) -> @[...s.length] is s 4 | String::endsWith ?= (s) -> s is '' or @[-s.length..] is s 5 | 6 | # Return false if empty, true otherwise. 7 | exports.notEmpty = (s) -> not (s.trim() == '') 8 | 9 | # log a message to the user, with increasing levels of importance: 10 | # DEBUG, INFO, WARN, ERROR and FATAL (for non-checks) 11 | exports.log = (level, msg) -> 12 | switch level 13 | when 'FATAL', 5 14 | console.error "#{sty.red 'ERROR'}: #{msg}" 15 | process.exit 1 16 | when 'ERROR', 4 17 | console.error "#{sty.red 'ERROR'}: #{msg}" 18 | when 'WARN', 3 19 | console.warn "#{sty.yellow 'WARN'}: #{msg}" 20 | when 'INFO', 2 21 | console.log "#{sty.green 'INFO'}: #{msg}" 22 | else 23 | process.stdout.write "#{sty.blue 'DEBUG'}:" 24 | console.dir msg 25 | 26 | # Return true if object is an array 27 | # From http://stackoverflow.com/a/16608045/4126114 28 | exports.isArray = (a) -> (!!a) && (a.constructor is Array) 29 | 30 | # Merge multiple objects, last key wins 31 | exports.merge = (xs...) -> 32 | if xs?.length > 0 33 | exports.tap {}, (m) -> m[k] = v for k, v of x for x in xs 34 | 35 | # Invoke function with object, return object 36 | exports.tap = (o, fn) -> fn(o); o 37 | -------------------------------------------------------------------------------- /test/checksTest.coffee: -------------------------------------------------------------------------------- 1 | c = require '../src/checks.coffee' 2 | chai = require 'chai' 3 | chai.should() 4 | 5 | rules = [ 6 | { line: 1, instruction: 'comment', arguments: ['MIT licensed'] }, 7 | { line: 2, instruction: 'FROM', arguments: [ 'cargos:latest' ] }, 8 | { line: 3, instruction: 'RUN', arguments: [ 'yum update &&', 'yum install mg &&', 'yum upgrade' ] }, 9 | ] 10 | 11 | describe "getAll", -> 12 | it "should return an Array", -> 13 | c.getAll('FROM', rules).should.be.instanceOf(Array) 14 | 15 | it "should return all rules for a given instruction", -> 16 | c.getAll('FROM', rules).should.be.deep.equal [rules[1]] 17 | 18 | it "should return all comments", -> 19 | c.getAll('comment', rules).should.be.deep.equal [rules[0]] 20 | 21 | it "should return an empty Array if the instruction is not found", -> 22 | c.getAll('JETPACK', rules).should.be.deep.equal [] 23 | 24 | describe "getAllExcept", -> 25 | it "should return an Array", -> 26 | c.getAllExcept('FROM', rules).should.be.instanceOf(Array) 27 | 28 | it "should return all rules except those for the given instruction", -> 29 | c.getAllExcept('comment', rules).should.be.deep.equal [rules[1], rules[2]] 30 | 31 | it "should return an the same Array if the instruction is not found", -> 32 | c.getAllExcept('JETPACK', rules).should.be.deep.equal rules 33 | 34 | describe "from_first", -> 35 | it "should fail if FROM is not the first instruction", -> 36 | r = [ 37 | { line: 1, instruction: 'RUN', arguments: ['yum -y update'] }, 38 | { line: 2, instruction: 'FROM', arguments: [ 'cargos:latest' ] }, 39 | ] 40 | c.from_first(r).should.be.equal 'failed' 41 | 42 | it "should not fail if FROM is the first instruction, preceeded by comments", -> 43 | c.from_first(rules).should.be.equal 'ok' 44 | 45 | it "should not fail if FROM is preceeded by ARG", -> 46 | r = [ 47 | { line: 1, instruction: 'ARG', arguments: ['TAG=latest'] }, 48 | { line: 2, instruction: 'FROM', arguments: [ 'cargos:$TAG' ] }, 49 | ] 50 | c.from_first(r).should.be.equal 'ok' 51 | 52 | it "should fail if no FROM is found", -> 53 | r = [ 54 | { line: 1, instruction: 'EXPOSE', arguments: ['80'] }, 55 | { line: 2, instruction: 'RUN', arguments: [ 'sshd -D' ] }, 56 | ] 57 | c.from_first(r).should.be.equal 'failed' 58 | 59 | describe "no_empty_tag", -> 60 | it "should fail if no tag is set when one is expected", -> 61 | c.no_empty_tag([ {line: 1, instruction: 'FROM', arguments: ['cargos:']} ]).should.be.equal 'failed' 62 | 63 | describe "no_empty_digest", -> 64 | it "should fail if no digest is set when one is expected", -> 65 | c.no_empty_digest([ {line: 1, instruction: 'FROM', arguments: ['cargos@']} ]).should.be.equal 'failed' 66 | 67 | describe "json_array_format", -> 68 | for cmd in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 69 | do (cmd) -> 70 | it "should not fail on single quotes in #{cmd} non-exec form", -> 71 | c.json_array_format([ {line: 1, instruction: cmd, arguments: ['\'/tmp\'']} ]).should.be.equal 'ok' 72 | 73 | it "should fail when single quotes are used in #{cmd} exec form", -> 74 | c.json_array_format([ {line: 1, instruction: cmd, arguments: ['[\'/root\']']} ]).should.be.equal 'failed' 75 | 76 | it "should not fail when double quotes are used in #{cmd} exec form", -> 77 | c.json_array_format([ {line: 1, instruction: cmd, arguments: ['["/root"]']} ]).should.be.equal 'ok' 78 | 79 | it "should not fail on arguments themselves having single quotes in #{cmd} exec form", -> 80 | c.json_array_format([ {line: 1, instruction: cmd, arguments: ['["\'$HOME\'"]']} ]).should.be.equal 'ok' 81 | 82 | describe "json_array_even_quotes", -> 83 | for cmd in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 84 | do (cmd) -> 85 | r = [ {line: 1, instruction: cmd, arguments: ['"""']} ] 86 | it "should fail when there are an unbalanced number of quotes in #{cmd} exec form", -> 87 | c.json_array_even_quotes(r).should.be.equal 'failed' 88 | 89 | describe "json_array_brackets", -> 90 | for cmd in [ 'CMD', 'ENTRYPOINT', 'RUN', 'VOLUME' ] 91 | do (cmd) -> 92 | for arg in [ '"foo"', '"echo [foo]"' ] 93 | do (cmd, arg) -> 94 | it "should not act on non-exec form arguments for #{cmd} #{arg}", -> 95 | c.json_array_brackets([ {line: 1, instruction: cmd, arguments: [arg] }]).should.be.equal 'ok' 96 | 97 | for arg in [ '["foo"]', ' ["foo"]', '["foo"] ', ' ["foo"] ' ] 98 | do (cmd, arg) -> 99 | it "should allow spaces around non-exec form arguments for #{cmd} #{arg}", -> 100 | c.json_array_brackets([ {line: 1, instruction: cmd, arguments: [arg] }]).should.be.equal 'ok' 101 | 102 | for test in [ 103 | {desc: "are multiple closing brackets", test: '[["foo"]'}, 104 | {desc: "are multiple opening brackets", test: '["bar"]]'}, 105 | {desc: "is no opening bracket", test: '"baz"]'}, 106 | {desc: "is no closing bracket", test: '["quux"'}, 107 | {desc: "are multiple commas", test: '["foo",,]'}, 108 | {desc: "are nested arrays (1)", test: '["foo",[]]'}, 109 | {desc: "are nested arrays (2)", test: '["foo",["foo"]]'}, 110 | ] 111 | do (cmd, test) -> 112 | it "should fail if #{test.desc} for #{cmd}", -> 113 | c.json_array_brackets([ {line: 1, instruction: cmd, arguments: [test.test] }]).should.be.equal 'failed' 114 | 115 | describe "recommended_exec_form", -> 116 | for cmd in [ 'CMD', 'ENTRYPOINT' ] 117 | do (cmd) -> 118 | r = [ {line: 1, instruction: cmd, arguments: ["/entrypoint.sh"]} ] 119 | it "should warn when not using exec form for #{cmd}", -> 120 | c.recommended_exec_form(r).should.be.equal 'warning' 121 | 122 | describe "add", -> 123 | it "should warn when ADD is used", -> 124 | c.add([ {line: 1, instruction: 'ADD', arguments: ['/config.json /']} ]).should.be.equal 'warning' 125 | 126 | for archive in [ 'tar', 'tar.gz', 'gz', 'bz2', 'xz', 'tar.xz' ] 127 | do (archive) -> 128 | it "should not fail when ADD is used with an #{archive} archive", -> 129 | c.add([ {line: 1, instruction: 'ADD', arguments: ["/file.#{archive}} /"]} ]).should.be.equal 'ok' 130 | 131 | describe "multiple_entries", -> 132 | for cmd in [ 'CMD', 'ENTRYPOINT' ] 133 | do (cmd) -> 134 | it "should fail when multiple #{cmd} are set in the same FROM instruction", -> 135 | r = [ 136 | { line: 1, instruction: cmd, arguments: ['/sbin/sshd -D'] }, 137 | { line: 2, instruction: cmd, arguments: ['/sbin/sshd -D'] }, 138 | ] 139 | c.multiple_entries(r).should.be.equal 'failed' 140 | 141 | it "should not fail when multiple #{cmd} are set in different FROM instruction (multi-stage builds)", -> 142 | r = [ 143 | { line: 1, instruction: cmd, arguments: ['/sbin/sshd -D'] }, 144 | { line: 2, instruction: 'FROM' }, 145 | { line: 3, instruction: cmd, arguments: ['/sbin/sshd -D'] }, 146 | ] 147 | c.multiple_entries(r).should.be.equal 'ok' 148 | 149 | describe "sudo", -> 150 | it "should warn when sudo is used", -> 151 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['sudo rm -rf /']} ]).should.be.equal 'warning' 152 | 153 | it "should warn when sudo is used in absolute path form", -> 154 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['/usr/bin/sudo rm -rf /']} ]).should.be.equal 'warning' 155 | 156 | it "should warn when sudo is used with preceding spaces/tabs", -> 157 | c.sudo([ {line: 1, instruction: 'RUN', arguments: [' sudo rm -rf /']} ]).should.be.equal 'warning' 158 | 159 | it "should warn when sudo is used after semicolon", -> 160 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['date; sudo rm -rf /']} ]).should.be.equal 'warning' 161 | 162 | it "should warn when sudo is used at the end of line", -> 163 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['date; sudo']} ]).should.be.equal 'warning' 164 | 165 | it "should not warn when sudoer file is being used", -> 166 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['echo "jenkins ALL=(ALL) ALL" >> etc/sudoers']} ]).should.be.equal 'ok' 167 | 168 | it "should not warn when sudo is not a verb in the sentence", -> 169 | c.sudo([ {line: 1, instruction: 'RUN', arguments: ['yum list installed | grep sudo']} ]).should.be.equal 'ok' 170 | 171 | describe "absolute_workdir", -> 172 | it "should fail when WORKDIR uses a relative path", -> 173 | c.absolute_workdir([ {line: 1, instruction: 'WORKDIR', arguments: ['../']} ]).should.be.equal 'failed' 174 | 175 | it "should not fail when WORKDIR uses an absolute path", -> 176 | c.absolute_workdir([ {line: 1, instruction: 'WORKDIR', arguments: ['/']} ]).should.be.equal 'ok' 177 | 178 | it "should warn when WORKDIR uses ARG variable creating absolute path", -> 179 | r = [ 180 | { line: 1, instruction: 'ARG', arguments: ['WD1=/'] }, 181 | { line: 2, instruction: 'WORKDIR', arguments: ['$WD1'] }, 182 | ] 183 | c.absolute_workdir(r).should.be.equal 'warning' 184 | 185 | it "should not fail when WORKDIR uses ENV variable creating absolute path", -> 186 | r = [ 187 | { line: 1, instruction: 'ENV', arguments: ['WD2=/'] }, 188 | { line: 2, instruction: 'WORKDIR', arguments: ['$WD2'] }, 189 | ] 190 | c.absolute_workdir(r).should.be.equal 'ok' 191 | 192 | it "should warn when WORKDIR uses ARG variable creating relative path", -> 193 | r = [ 194 | { line: 1, instruction: 'ARG', arguments: ['WD3=not/absolute'] }, 195 | { line: 2, instruction: 'WORKDIR', arguments: ['$WD3'] }, 196 | ] 197 | c.absolute_workdir(r).should.be.equal 'warning' 198 | 199 | it "should pass with relative ARG overwritten by absolute ENV when both are defined", -> 200 | r = [ 201 | { line: 1, instruction: 'ARG', arguments: ['WD4=not/absolute'] }, 202 | { line: 2, instruction: 'ENV', arguments: ['WD4=/absolute'] }, 203 | { line: 3, instruction: 'WORKDIR', arguments: ['$WD4'] }, 204 | ] 205 | c.absolute_workdir(r).should.be.equal 'ok' 206 | 207 | describe "onbuild_copyadd", -> 208 | for cmd in [ 'ADD', 'COPY' ] 209 | do (cmd) -> 210 | it "should fail when #{cmd} is used with ONBUILD", -> 211 | c.onbuild_copyadd([ {line: 1, instruction: 'ONBUILD', arguments: ["#{cmd} /file /"]} ]).should.be.equal 'warning' 212 | 213 | describe "onbuild_disallowed", -> 214 | for cmd in [ 'FROM', 'ONBUILD', 'MAINTAINER' ] 215 | do (cmd) -> 216 | it "should fail when #{cmd} is used with ONBUILD", -> 217 | c.onbuild_disallowed([ {line: 1, instruction: 'ONBUILD', arguments: [cmd]} ]).should.be.equal 'failed' 218 | 219 | describe "label_no_empty_value", -> 220 | it "should fail when LABEL expects a value and it's not set", -> 221 | c.label_no_empty_value([ {line: 1, instruction: 'LABEL', arguments: ['key=']} ]).should.be.equal 'failed' 222 | 223 | it "should pass when LABEL is a key and no value is expected", -> 224 | c.label_no_empty_value([ {line: 1, instruction: 'LABEL', arguments: ['key']} ]).should.be.equal 'ok' 225 | 226 | it "should pass when LABEL is a key=value", -> 227 | c.label_no_empty_value([ {line: 1, instruction: 'LABEL', arguments: ['key=value']} ]).should.be.equal 'ok' 228 | 229 | describe "variable_use", -> 230 | for cmd in [ 'ADD', 'COPY', 'EXPOSE', 'FROM', 'LABEL', 'ONBUILD', 'RUN', 'STOPSIGNAL', 'USER', 'VOLUME', 'WORKDIR' ] 231 | do (cmd) -> 232 | it "should warn when ARG or ENV is undefined when #{cmd} is used", -> 233 | c.variable_use([ {line: 1, instruction: cmd, arguments: ['$DNE']} ]).should.be.equal 'warning' 234 | 235 | it "should pass when ARG is defined when #{cmd} is used", -> 236 | r = [ 237 | { line: 1, instruction: 'ARG', arguments: ['VAR1=value'] }, 238 | { line: 2, instruction: cmd, arguments: ['$VAR1'] }, 239 | ] 240 | c.variable_use(r).should.be.equal 'ok' 241 | 242 | it "should pass when ARG is pre-defined when #{cmd} is used", -> 243 | c.variable_use([ { line: 1, instruction: cmd, arguments: ['$HTTP_PROXY'] } ]).should.be.equal 'ok' 244 | 245 | it "should pass when ENV is defined when #{cmd} is used", -> 246 | r = [ 247 | { line: 1, instruction: 'ENV', arguments: ['VAR2=value'] }, 248 | { line: 2, instruction: cmd, arguments: ['$VAR2'] }, 249 | ] 250 | c.variable_use(r).should.be.equal 'ok' 251 | 252 | it "should pass with ARG overwritten by ENV when both are defined when #{cmd} is used", -> 253 | r = [ 254 | { line: 1, instruction: 'ARG', arguments: ['VAR3=foo'] }, 255 | { line: 2, instruction: 'ENV', arguments: ['VAR3=bar'] }, 256 | { line: 3, instruction: cmd, arguments: ['$VAR3'] }, 257 | ] 258 | c.variable_use(r).should.be.equal 'ok' 259 | 260 | # ENV requires different syntax in the arguments 261 | it "should warn when ARG or ENV is undefined when ENV is used", -> 262 | c.variable_use([ {line: 1, instruction: 'ENV', arguments: ['EVAR=$DNE']} ]).should.be.equal 'warning' 263 | 264 | it "should pass when ARG is defined when ENV is used", -> 265 | r = [ 266 | { line: 1, instruction: 'ARG', arguments: ['VAR1=value'] }, 267 | { line: 2, instruction: 'ENV', arguments: ['EVAR1=$VAR1'] }, 268 | ] 269 | c.variable_use(r).should.be.equal 'ok' 270 | 271 | it "should pass when ARG is pre-defined when ENV is used", -> 272 | c.variable_use([ { line: 1, instruction: 'ENV', arguments: ['EVAR=$HTTP_PROXY'] } ]).should.be.equal 'ok' 273 | 274 | it "should pass when ENV is defined when ENV is used", -> 275 | r = [ 276 | { line: 1, instruction: 'ENV', arguments: ['VAR2=value'] }, 277 | { line: 2, instruction: 'ENV', arguments: ['EVAR2=$VAR2'] }, 278 | ] 279 | c.variable_use(r).should.be.equal 'ok' 280 | 281 | it "should pass with ARG overwritten by ENV when both are defined when ENV is used", -> 282 | r = [ 283 | { line: 1, instruction: 'ARG', arguments: ['VAR3=foo'] }, 284 | { line: 2, instruction: 'ENV', arguments: ['VAR3=bar'] }, 285 | { line: 3, instruction: 'ENV', arguments: ['EVAR3=$VAR3'] }, 286 | ] 287 | c.variable_use(r).should.be.equal 'ok' 288 | 289 | describe "no_trailing_spaces", -> 290 | it "should fail when lines contain trailing spaces", -> 291 | c.no_trailing_spaces([ {raw: 'FROM: alpine '}]).should.be.equal 'failed' 292 | -------------------------------------------------------------------------------- /test/dockerfiles/comment_only: -------------------------------------------------------------------------------- 1 | # MIT licensed 2 | -------------------------------------------------------------------------------- /test/dockerfiles/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedCoolBeans/dockerlint/a3e11bd9383391b18868de8ed71acd9eac9f890d/test/dockerfiles/empty -------------------------------------------------------------------------------- /test/dockerfiles/line_continuations: -------------------------------------------------------------------------------- 1 | FROM cargos:latest 2 | 3 | RUN yum update && \ 4 | yum install mg && \ 5 | yum upgrade 6 | 7 | RUN yum -y update && \\ yum -y install tmux 8 | -------------------------------------------------------------------------------- /test/dockerfiles/line_count: -------------------------------------------------------------------------------- 1 | # MIT licensed 2 | 3 | FROM cargos:latest 4 | 5 | RUN pkgin update && \ 6 | pkgin install tmux 7 | 8 | EXPOSE 80 9 | -------------------------------------------------------------------------------- /test/parserTest.coffee: -------------------------------------------------------------------------------- 1 | p = require '../src/parser.coffee' 2 | chai = require 'chai' 3 | chai.should() 4 | 5 | describe "getInstruction", -> 6 | it "should return the first word from a string", -> 7 | p.getInstruction("FROM cargos:latest").should.equal "FROM" 8 | 9 | it "should return the first word from a string with hard tabs", -> 10 | p.getInstruction("MAINTAINER cargos:latest").should.equal "MAINTAINER" 11 | 12 | it "should return 'comment' for lines starting with '#'", -> 13 | p.getInstruction('# Apology accepted, Captain Needa').should.equal 'comment' 14 | 15 | describe "getArguments", -> 16 | it "should return an Array", -> 17 | p.getArguments('USER darth').should.be.instanceOf(Array) 18 | 19 | it "should return everything but the first word from a string", -> 20 | p.getArguments('FROM cargos:latest').should.be.deep.equal ['cargos:latest'] 21 | 22 | it "should handle comments", -> 23 | p.getArguments('# Invalidate layer').should.be.deep.equal ['Invalidate layer'] 24 | 25 | it "should remove trailing backslashes", -> 26 | p.getArguments('RUN yum -y update \\').should.be.deep.equal ['yum -y update'] 27 | 28 | it "should remove trailing backslashes followed by whitespace", -> 29 | p.getArguments('RUN yum -y update \\ ').should.be.deep.equal ['yum -y update'] 30 | 31 | it "should leave backslashes elsewhere", -> 32 | p.getArguments('RUN yum -y update && \\ yum -y install tmux').should.be.deep.equal ['yum -y update && \\ yum -y install tmux'] 33 | 34 | describe "parser", -> 35 | it "should handle empty files", -> 36 | p.parser('test/dockerfiles/empty').should.be.deep.equal [] 37 | 38 | it "should handle comment-only files", -> 39 | p.parser('test/dockerfiles/comment_only').should.be.deep.equal [{raw: '# MIT licensed', line: 1, instruction: 'comment', arguments: ['MIT licensed']}] 40 | 41 | it "should return nothing when handling a non-existent file", -> 42 | p.parser('test/dockerfiles/nonexistent-file').should.be.deep.equal [] 43 | 44 | it "should count the lines correctly", -> 45 | p.parser('test/dockerfiles/line_count')[3].line.should.be.deep.equal 8 46 | 47 | it "should handle line continuations", -> 48 | output = [ 49 | { raw: 'FROM cargos:latest', line: 1, instruction: 'FROM', arguments: [ 'cargos:latest' ] }, 50 | { raw: ' yum upgrade', line: 3, instruction: 'RUN', arguments: [ 'yum update &&', 'yum install mg &&', 'yum upgrade' ] }, 51 | { raw: 'RUN yum -y update && \\\\ yum -y install tmux', line: 7, instruction: 'RUN', arguments: [ 'yum -y update && \\\\ yum -y install tmux' ] } 52 | ] 53 | p.parser('test/dockerfiles/line_continuations').should.be.deep.equal output 54 | -------------------------------------------------------------------------------- /test/utilsTest.coffee: -------------------------------------------------------------------------------- 1 | u = require '../src/utils.coffee' 2 | chai = require 'chai' 3 | chai.should() 4 | 5 | describe "beginsWith", -> 6 | it "should return true if the argument matches the first character", -> 7 | 'Greedo'.beginsWith('G').should.equal true 8 | 9 | it "should return false if the argument does not match the first character", -> 10 | 'Jango'.beginsWith('G').should.equal false 11 | 12 | describe "endsWith", -> 13 | it "should return true if the argument matches the last character", -> 14 | 'Zam'.endsWith('m').should.equal true 15 | 16 | it "should return false if the argument does not match the last character", -> 17 | 'Aurra'.endsWith('A').should.equal false 18 | 19 | describe "notEmpty", -> 20 | it "should return false if string is empty", -> 21 | u.notEmpty('').should.equal false 22 | 23 | it "should return true if string is not empty", -> 24 | u.notEmpty('Jabba').should.equal true 25 | 26 | describe "isArray", -> 27 | it "should return true if object is an array", -> 28 | u.isArray([]).should.equal true 29 | 30 | it "should return false if object is not an array", -> 31 | u.isArray('Jabba').should.equal false 32 | --------------------------------------------------------------------------------