Skip to content

Commit 7c77c02

Browse files
author
svl7
committed
Additional checks added, including tests:
- Detect empty continuation lines in multi-line RUN commands. It's currently deprecated and will lead to errors in future releases of Docker, see: moby/moby@2fd736a - Check syntax of HEALTHCHECK command according to Docker documentation - Detect multiple declarations of HEALTHCHECK and STOPSIGNAL
1 parent eaca957 commit 7c77c02

File tree

9 files changed

+163
-21
lines changed

9 files changed

+163
-21
lines changed

lib/checks.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,39 @@ var commands = module.exports = {
160160
return ['invalid_format'];
161161
}
162162
return [];
163+
},
164+
// valid: HEALTHCHECK NONE -> disabled
165+
// HEALTHCHECK [OPTIONS] CMD
166+
// options are: --interval=DURATION --timeout=DURATION
167+
// --start-period=DURATION --retries=N
168+
is_valid_healtcheck: function(args) {
169+
args = args.split(' ')
170+
hc_opts_regex = RegExp(/--(interval|timeout|start-period|retries)=\d+/);
171+
cmd_position = args.indexOf('cmd');
172+
cmd_opts = args.slice(cmd_position + 1);
173+
174+
if (cmd_position !== -1){
175+
// CMD given, but no tool specified
176+
if (cmd_opts.length === 0) return ['invalid_format'];
177+
// HEALTHCHECK options after CMD are invalid
178+
if (hc_opts_regex.test(cmd_opts)) return ['healthcheck_trailing_opts'];
179+
}
180+
// CMD not first arg -> expecting HEALTHCHECK opts
181+
if (cmd_position !== -1 && cmd_position !== 0){
182+
healthcheck_opts = args.slice(0, cmd_position);
183+
for(const opt of healthcheck_opts){
184+
// only options specified by Docker DSL are valid
185+
if (!hc_opts_regex.test(opt)) return ['invalid_format'];
186+
}
187+
}
188+
if (args[0] === 'none'){
189+
if (args.length != 1) return ['invalid_format'];
190+
}
191+
else if (args[0] != 'cmd') return ['invalid_format'];
192+
193+
opts = RegExp(/--(interval|timeout|start-period|retries)=\d+/);
194+
// TODO iterate over all but CMD and check if it's an option at the wrong place
195+
if (opts.test(args.slice(-1))) return ['invalid_format'];
196+
return [];
163197
}
164-
}
198+
}

lib/index.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,27 @@ var messages = require('./messages');
1212
module.exports.run = function(configPath, content) {
1313
// Parse the file into an array of instructions
1414
var instructions = {};
15+
var emptyLineContinuations = {};
1516
var lines = content.split(/\r?\n/) || [];
1617

1718
// Supporting multi-line commands, read each line into an array of "instructions", noting the
1819
// original line number the instruction started on
1920
var partialLine = '';
2021
var partialLineNum = 0;
22+
2123
lines.forEach(function(line, lineNum) {
2224
// Trim whitespace from the line
2325
line = line.trim();
2426

25-
// Ignore empty lines and comments
26-
if (line === '' || line.startsWith('#')) {
27+
// Ignore comments
28+
if (line.startsWith('#')) {
29+
return;
30+
}
31+
// Remember empty continuation lines
32+
if (line === '') {
33+
if (partialLine !== ''){
34+
emptyLineContinuations[partialLineNum] = true;
35+
}
2736
return;
2837
}
2938

@@ -59,7 +68,7 @@ module.exports.run = function(configPath, content) {
5968
}
6069

6170
for (var idx in instructions) {
62-
var result = runLine(state, instructions, idx);
71+
var result = runLine(state, instructions, idx, emptyLineContinuations);
6372
state.items = state.items.concat(result.items);
6473

6574
// We care about only having 1 cmd instruction
@@ -103,7 +112,8 @@ function tryLoadFile(filename) {
103112
}
104113
}
105114

106-
function runLine(state, instructions, idx) {
115+
var keyword_occurences = { stopsignal: 0, healthcheck: 0 }
116+
function runLine(state, instructions, idx, emptyLineContinuations) {
107117
// return items in an object with the line number as key, value is array of items for this line
108118
var items = [];
109119
var line = parseInt(idx) + 1;
@@ -159,6 +169,11 @@ function runLine(state, instructions, idx) {
159169
var aptgetSubcommands = [];
160170
var commands = command_parser.split_commands(args);
161171

172+
// empty line continuation
173+
if (emptyLineContinuations[idx]) {
174+
items.push(messages.build(state.rules, 'empty_continuation_line', line))
175+
}
176+
162177
// parse apt commands
163178
apt.aptget_commands(commands).forEach(function(aptget_command, index) {
164179
var subcommand = apt.aptget_subcommand(aptget_command);
@@ -215,6 +230,7 @@ function runLine(state, instructions, idx) {
215230
});
216231
break;
217232
case 'cmd':
233+
// TODO empty contination line in CMD as well?
218234
break;
219235
case 'label':
220236
checks.label_format(args).forEach(function(item) {
@@ -264,12 +280,26 @@ function runLine(state, instructions, idx) {
264280
case 'onbuild':
265281
break;
266282
case 'stopsignal':
283+
keyword_occurences.stopsignal ++;
284+
// don't report again if more than two instances are found
285+
if (keyword_occurences.stopsignal == 2) {
286+
items.push(messages.build(state.rules, 'multiple_stopsignals', line))
287+
}
267288
break;
268289
case 'shell':
269290
checks.is_valid_shell(args).forEach(function(item) {
270291
items.push(messages.build(state.rules, item, line));
271292
});
272293
break;
294+
case 'healthcheck':
295+
keyword_occurences.healthcheck ++;
296+
if (keyword_occurences.healthcheck == 2) {
297+
items.push(messages.build(state.rules, 'multiple_healthchecks', line))
298+
}
299+
checks.is_valid_healtcheck(args).forEach(function(item) {
300+
items.push(messages.build(state.rules, item, line));
301+
});
302+
break;
273303
default:
274304
items.push(messages.build(state.rules, 'invalid_command', line));
275305
break;

lib/reference.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,25 @@ container create/start process manage the mapping to host ports using either of
168168
'title': 'apt-get update with matching cache rm',
169169
'description': 'Use of apt-get update should be paired with rm -rf /var/lib/apt/lists/* in the same layer.',
170170
'category': 'Optimization'
171+
},
172+
'empty_continuation_line': {
173+
'title': 'Empty continuation line',
174+
'description': 'Empty continuation lines will become errors in a future release.',
175+
'category': 'Deprecation'
176+
},
177+
'healthcheck_trailing_opts': {
178+
'title': 'HEALTHCHECK trailing options',
179+
'description': 'Options for HEALTHCHECK must come before command.',
180+
'category': 'Possible Bug'
181+
},
182+
'multiple_healthchecks':{
183+
'title': 'Multiple HEALTHCHECK declarations',
184+
'description': 'Multiple HEALTHCHECK definitions are pointless, only last one is used.',
185+
'category': 'Clarity'
186+
},
187+
'multiple_stopsignals':{
188+
'title': 'Multiple STOPSIGNAL declarations',
189+
'description': 'Multiple STOPSIGNAL definitions are pointless, only last one is used.',
190+
'category': 'Clarity'
171191
}
172192
}

test/cli_reporter.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('cli_reporter', () => {
9999
title: 'Expose Only Container Port',
100100
description: 'Using `EXPOSE` to specify a host port is not allowed.',
101101
category: 'Deprecation',
102-
line: 25
102+
line: 26
103103
}
104104
];
105105
let report = new CliReporter()
@@ -124,7 +124,7 @@ describe('cli_reporter', () => {
124124
' ' + chalk.cyan('3') + ' ' + chalk.cyan.inverse('Clarity') + ' ' + chalk.cyan('Base Image Latest') + ' ' + chalk.gray('Base images should not use the latest tag.'),
125125
' ' + chalk.cyan('Tag'),
126126
'',
127-
'Line 25: ' + chalk.magenta('EXPOSE 80:80'),
127+
'Line 26: ' + chalk.magenta('EXPOSE 80:80'),
128128
'Issue Category Title Description',
129129
' ' + chalk.red('4') + ' ' + chalk.red.inverse('Deprecation') + ' ' + chalk.red('Expose Only') + ' ' + chalk.gray('Using `EXPOSE` to specify a host port is not allowed.'),
130130
' ' + chalk.red('Container Port'),
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM ruby:2.7
2+
# correct
3+
HEALTHCHECK none
4+
HEALTHCHECK --interval=1 CMD curl localhost:8080
5+
# invalid
6+
HEALTHCHECK CMD --interval=1 curl localhost:8080
7+
HEALTHCHECK CMD --interval=1
8+
HEALTHCHECK CMD curl localhost --interval=1
9+
HEALTHCHECK CMD
10+
HEALTHCHECK --interval=1
11+
HEALTHCHECK none CMD curl
12+
HEALTHCHECK --interval=1 none

test/examples/Dockerfile.misc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ RUN apt-get update -y
1717
RUN apt-get update \
1818
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
1919
curl \
20+
2021
&& rm -rf /var/lib/apt/lists/*
2122
RUN apk add python-pip
2223
RUN apk add --no-cache python-dev build-base wget && apk del python-dev build-base wget
@@ -33,6 +34,7 @@ WORKDIR in valid
3334
ARG test
3435
ONBUILD RUN echo test
3536
STOPSIGNAL SIGKILL
37+
STOPSIGNAL SIGKILL
3638
ADD /test* /test2
3739
CMD ["bash"]
3840
SHELL ["/bin/sh", "-c"]

test/index.js

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,44 @@ describe("index", function(){
143143
});
144144
});
145145

146+
describe("#healthcheck", function () {
147+
it("validates the HEALTHCHECK syntax issues are detected", function () {
148+
var expected = [
149+
{ title: 'Multiple HEALTHCHECK declarations',
150+
line: 4,
151+
rule: 'multiple_healthchecks'},
152+
{ title: 'HEALTHCHECK trailing options',
153+
line: 6,
154+
rule: 'healthcheck_trailing_opts'},
155+
{ title: 'HEALTHCHECK trailing options',
156+
line: 7,
157+
rule: 'healthcheck_trailing_opts'},
158+
{ title: 'HEALTHCHECK trailing options',
159+
line: 8,
160+
rule: 'healthcheck_trailing_opts'},
161+
{ title: 'Invalid Argument Format',
162+
line: 9,
163+
rule: 'invalid_format'},
164+
{ title: 'Invalid Argument Format',
165+
line: 10,
166+
rule: 'invalid_format'},
167+
{ title: 'Invalid Argument Format',
168+
line: 11,
169+
rule: 'invalid_format'},
170+
{ title: 'Invalid Argument Format',
171+
line: 12,
172+
rule: 'invalid_format'},
173+
];
174+
const lintResult = dockerfilelint.run('./test/examples', fs.readFileSync('./test/examples/Dockerfile.healthcheck', 'UTF-8'));
175+
_.forEach(lintResult, function(r) {
176+
delete r['description'];
177+
delete r['category'];
178+
});
179+
expect(lintResult).to.have.length(expected.length);
180+
expect(lintResult).to.deep.equal(expected);
181+
});
182+
});
183+
146184
describe("#misc", function(){
147185
it("validates the misc Dockerfile have the exact right issues reported", function(){
148186
var expected = [
@@ -197,36 +235,42 @@ describe("index", function(){
197235
{ title: 'apt-get update without matching apt-get install',
198236
rule: 'apt-get-update_require_install',
199237
line: 16 },
238+
{ title: 'Empty continuation line',
239+
rule: 'empty_continuation_line',
240+
line: 17 },
200241
{ title: 'Consider `--no-cache or --update with rm -rf /var/cache/apk/*`',
201242
rule: 'apkadd-missing_nocache_or_updaterm',
202-
line: 21 },
243+
line: 22 },
203244
{ title: 'Consider `--virtual` when using apk add and del together in a command.',
204245
rule: 'apkadd-missing-virtual',
205-
line: 22 },
246+
line: 23 },
206247
{ title: 'Invalid Port Exposed',
207248
rule: 'invalid_port',
208-
line: 24 },
249+
line: 25 },
209250
{ title: 'Expose Only Container Port',
210251
rule: 'expose_host_port',
211-
line: 25 },
252+
line: 26 },
212253
{ title: 'Invalid Argument Format',
213254
rule: 'invalid_format',
214-
line: 27 },
255+
line: 28 },
215256
{ title: 'Label Is Invalid',
216257
rule: 'label_invalid',
217-
line: 29 },
258+
line: 30 },
218259
{ title: 'Extra Arguments',
219260
rule: 'extra_args',
220-
line: 31 },
261+
line: 32 },
221262
{ title: 'Invalid WORKDIR',
222263
rule: 'invalid_workdir',
223-
line: 32 },
264+
line: 33 },
265+
{ title: 'Multiple STOPSIGNAL declarations',
266+
rule: 'multiple_stopsignals',
267+
line: 37 },
224268
{ title: 'Invalid ADD Source',
225269
rule: 'add_src_invalid',
226-
line: 36 },
270+
line: 38 },
227271
{ title: 'Invalid ADD Destination',
228272
rule: 'add_dest_invalid',
229-
line: 36 }
273+
line: 38 }
230274
];
231275

232276
var result = dockerfilelint.run('./test/examples', fs.readFileSync('./test/examples/Dockerfile.misc', 'UTF-8'));

test/json_reporter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('json_reporter', () => {
8181
description: 'Base images should not use the latest tag.'
8282
},
8383
{
84-
line: '25',
84+
line: '26',
8585
content: 'EXPOSE 80:80',
8686
category: 'Deprecation',
8787
title: 'Expose Only Container Port',

test/reporter.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('reporter', () => {
7676
expect(Object.keys(reporter.fileReports)).to.have.length(1);
7777
let fileReport = reporter.fileReports[file];
7878
expect(fileReport.uniqueIssues).to.equal(3);
79-
expect(fileReport.contentArray).to.have.length(40);
79+
expect(fileReport.contentArray).to.have.length(42);
8080
expect(fileReport.itemsByLine).to.deep.equal({
8181
'5': [ items[0] ],
8282
'6': items.splice(1)
@@ -104,7 +104,7 @@ describe('reporter', () => {
104104
expect(Object.keys(reporter.fileReports)).to.have.length(1);
105105
let fileReport = reporter.fileReports[file];
106106
expect(fileReport.uniqueIssues).to.equal(1);
107-
expect(fileReport.contentArray).to.have.length(40);
107+
expect(fileReport.contentArray).to.have.length(42);
108108
expect(fileReport.itemsByLine).to.deep.equal({
109109
'6': [ items[0] ]
110110
});
@@ -149,7 +149,7 @@ describe('reporter', () => {
149149
});
150150
let file2Report = reporter.fileReports[file2];
151151
expect(file2Report.uniqueIssues).to.equal(2);
152-
expect(file2Report.contentArray).to.have.length(40);
152+
expect(file2Report.contentArray).to.have.length(42);
153153
expect(file2Report.itemsByLine).to.deep.equal({
154154
'5': [ file2Items[0] ],
155155
'6': [ file2Items[1] ]

0 commit comments

Comments
 (0)