diff --git a/README.md b/README.md index deb8d36..9b8bd3c 100644 --- a/README.md +++ b/README.md @@ -33,20 +33,26 @@ Options: -o, --output Specify the format to use for output of linting results. Valid values are `json` or `cli` (default). [string] -j, --json Output linting results as JSON, equivalent to `-o json`. [boolean] + -c, --config Path for .dockerfilelintrc configuration file [string] + -r, --ruleset Path for custom ruleset js file [string] -v, --version Show version number [boolean] -h, --help Show help [boolean] Examples: - dockerfilelint Dockerfile Lint a Dockerfile in the current working - directory + dockerfilelint Dockerfile Lint a Dockerfile in the current + working directory - dockerfilelint test/example/* -j Lint all files in the test/example directory and - output results in JSON + dockerfilelint test/example/* -j Lint all files in the test/example + directory and output results in JSON - dockerfilelint 'FROM latest' Lint the contents given as a string on the - command line + dockerfilelint 'FROM latest' Lint the contents given as a string on + the command line - dockerfilelint < Dockerfile Lint the contents of Dockerfile via stdin + dockerfilelint < Dockerfile Lint the contents of Dockerfile via + stdin + dockerfilelint -r custom-ruleset.js Lint the contents of Dockerfile using + Dockerfile the default rules plus a set of custom + rules defined in custom-ruleset.js ``` #### Configuring @@ -89,6 +95,41 @@ apt-get_missing_rm deprecated_in_1.13 ``` +### Custom Rulesets + +You can add your own custom rulesets by supplying the -r option to dockerfilelint. Simply create a ruleset.js file first. It must export the "rules" variable with the proper keys. Here is an example of a simple two-rule ruleset file. + +``` +module.exports.rules = { + 'add_prohibited': { + 'title': 'ADD Command Prohibited', + 'description': 'ADD command is not allowed! Use copy instead!', + 'category': 'Optimization', + 'function': ({ cmd, args, line, instruction }) => { + return cmd.toLowerCase() === 'add'; + } + }, + + 'avoid_curl_bashing': { + 'title': 'Avoid Curl Bashing', + 'description': 'Do not pipe bash or wget commands directly to shell. This is very insecure and can cause many issues with security. If you must, make sure to vet the script and verify its authenticity. E.G. "RUN wget http://my_website/script.sh | sh" is prohibited', + 'category': 'Optimization', + 'function': ({ cmd, line, args}) => { + // This function doesn't care about full instruction so it omits if from arguments + return cmd.toLowerCase() === 'run' && args.match(/(curl|wget)[^|^>]*[|>]/); + } + }, +} +``` + +Notice, if a rule function returns true, it will be sent as a message to the reporter, otherwise it will be ignored for that line. + +After you have your ruleset defined, simply call dockerfilelint with the -r command to pass the ruleset file in: + +``` +dockerfilelint -r path/to/ruleset.js path/to/Dockerfile +``` + #### From a Docker container (Replace the ``pwd``/Dockerfile with the path to your local Dockerfile) diff --git a/bin/dockerfilelint b/bin/dockerfilelint index 4811479..dad6113 100755 --- a/bin/dockerfilelint +++ b/bin/dockerfilelint @@ -22,12 +22,18 @@ var argv = require('yargs') desc: 'Path for .dockerfilelintrc configuration file', type: 'string' }) + .option('r', { + alias: 'ruleset', + desc: 'Path for custom ruleset js file', + type: 'string' + }) .alias('v', 'version') .help().alias('h', 'help') .example('dockerfilelint Dockerfile', 'Lint a Dockerfile in the current working directory\n') .example('dockerfilelint test/example/* -j', 'Lint all files in the test/example directory and output results in JSON\n') .example(`dockerfilelint 'FROM latest'`, 'Lint the contents given as a string on the command line\n') .example('dockerfilelint < Dockerfile', 'Lint the contents of Dockerfile via stdin') + .example('dockerfilelint -r custom-ruleset.js Dockerfile', 'Lint the contents of Dockerfile using the default rules plus a set of custom rules defined in custom-ruleset.js') .wrap(86) .check(argv => { if (!argv.output && argv.json) argv.output = 'json' @@ -41,7 +47,7 @@ var chalk = require('chalk'); var Reporter = argv.output === 'json' ? require('../lib/reporter/json_reporter') : require('../lib/reporter/cli_reporter'); var reporter = new Reporter(); -var fileContent, configFilePath; +var fileContent, configFilePath, customRuleset; if (argv._.length === 0 || argv._[0] === '-') { // read content from stdin fileContent = ''; @@ -83,13 +89,14 @@ argv._.forEach((fileName) => { return process.exit(1); } - processContent(configFilePath, fileName, fileContent); + processContent(configFilePath, fileName, fileContent, argv.ruleset); }); report(); -function processContent (configFilePath, name, content) { - reporter.addFile(name, content, dockerfilelint.run(configFilePath, content)); +function processContent (configFilePath, name, content, customRulesetPath) { + const items = dockerfilelint.run(configFilePath, content, customRulesetPath); + reporter.addFile(name, content, items, customRulesetPath); } function report () { diff --git a/lib/index.js b/lib/index.js index dd7a408..da14351 100644 --- a/lib/index.js +++ b/lib/index.js @@ -9,7 +9,7 @@ var apk = require('./apk'); var apt = require('./apt'); var messages = require('./messages'); -module.exports.run = function(configPath, content) { +module.exports.run = function(configPath, content, customRulesetPath) { // Parse the file into an array of instructions var instructions = {}; var lines = content.split(/\r?\n/) || []; @@ -56,9 +56,13 @@ module.exports.run = function(configPath, content) { cmdFound: false // this doesn't seem to be used anywhere }], items: [], - rules: loadRules(configPath) + rules: loadRules(configPath), + customRuleset: loadCustomRuleset(customRulesetPath) } + // Add custom ruleset to message bus + messages.addCustomRuleset(state.customRuleset); + for (var idx in instructions) { var result = runLine(state, instructions, idx); state.items = state.items.concat(result.items); @@ -101,6 +105,16 @@ function loadRules(configPath) { return rc.rules; } +function loadCustomRuleset(customRulesetPath){ + if (!customRulesetPath) return {}; + try{ + const absPath = path.join(process.cwd(), customRulesetPath) + return require(absPath)?.rules; + } catch (e) { + return {}; + } +} + function tryLoadFile(filename) { try { var stats = fs.lstatSync(filename); @@ -294,6 +308,24 @@ function runLine(state, instructions, idx) { } } + if (state.customRuleset){ + // For each custom rule, run the rule and push the result + for(const rule of Object.entries(state.customRuleset)){ + // Rules simply should return true or false. This can also be interpreted as + // truthy or falsey. If true, send the message to the message bus. + // pass as a dictionary to allow users to only use the arguments that they want + const result = rule[1].function?.({ + cmd: cmd, + args: args, + line: line, + instruction: instruction + }); + if (result) { + items.push(messages.build(state.rules, rule[0], line)); + } + } + } + // Remove any null items from the result items = items.filter(function(n){ return n != null }); diff --git a/lib/messages.js b/lib/messages.js index 4b883b7..30824f2 100644 --- a/lib/messages.js +++ b/lib/messages.js @@ -1,6 +1,10 @@ var reference = require('./reference'); var messages = module.exports = { + addCustomRuleset: function(customRules) { + reference = { ...reference, ...customRules }; + }, + parseBool: function(s) { s = s.toLowerCase(); if (s === 'off' || s === 'false' || s === '0' || s === 'n') { diff --git a/lib/reporter/cli_reporter.js b/lib/reporter/cli_reporter.js index dfd0e6b..bf6e2b3 100644 --- a/lib/reporter/cli_reporter.js +++ b/lib/reporter/cli_reporter.js @@ -36,6 +36,9 @@ class CliReporter extends Reporter { self.ui.div( { text: 'File: ' + file, padding: PAD_TOP1_LEFT0 } ); + if(fileReport.customRulesetPath){ + self.ui.div('Custom Ruleset: ' + fileReport.customRulesetPath); + } let linesWithItems = Object.keys(fileReport.itemsByLine); if (linesWithItems.length === 0) { self.ui.div('Issues: ' + chalk.green('None found') + ' 👍'); diff --git a/lib/reporter/json_reporter.js b/lib/reporter/json_reporter.js index d2e2620..59f29fc 100644 --- a/lib/reporter/json_reporter.js +++ b/lib/reporter/json_reporter.js @@ -33,7 +33,12 @@ class JsonReporter extends Reporter { let fileReport = self.fileReports[file]; let linesWithItems = Object.keys(fileReport.itemsByLine); - let jsonReport = { file: file, issues_count: fileReport.uniqueIssues, issues: [] }; + let jsonReport = { + file: file, + issues_count: fileReport.uniqueIssues, + customRuleset: fileReport.customRulesetPath, + issues: [] + }; if (linesWithItems.length === 0) { reportFiles.push(jsonReport); diff --git a/lib/reporter/reporter.js b/lib/reporter/reporter.js index 3e3c926..b303447 100644 --- a/lib/reporter/reporter.js +++ b/lib/reporter/reporter.js @@ -8,13 +8,14 @@ class Reporter { } // group file items by line for easy reporting - addFile (file, fileContent, items) { + addFile (file, fileContent, items, customRulesetPath) { let self = this; if (!file) return self; let fileReport = self.fileReports[file] || { itemsByLine: {}, uniqueIssues: 0, - contentArray: (fileContent || '').replace('\r', '').split('\n') + contentArray: (fileContent || '').replace('\r', '').split('\n'), + customRulesetPath: customRulesetPath }; let ibl = fileReport.itemsByLine; [].concat(items).forEach((item) => { diff --git a/test/examples/Dockerfile.custom-ruleset b/test/examples/Dockerfile.custom-ruleset new file mode 100644 index 0000000..057d4f5 --- /dev/null +++ b/test/examples/Dockerfile.custom-ruleset @@ -0,0 +1,3 @@ +FROM scratch + +RUN wget http://my_website/script.sh | sh \ No newline at end of file diff --git a/test/index.js b/test/index.js index 4bb8ba1..d451b17 100644 --- a/test/index.js +++ b/test/index.js @@ -160,6 +160,24 @@ describe("index", function(){ }); }); + describe("#customRuleset", function() { + it("validates a custom ruleset can be supplied to extend rules", function() { + let expected = [{ + title: 'Avoid Curl Bashing', + line: 3, + rule: 'avoid_curl_bashing' + }] + let result = dockerfilelint.run('./test/examples', fs.readFileSync('./test/examples/Dockerfile.custom-ruleset', 'UTF-8'), './test/ruleset.js'); + _.forEach(result, function(r) { + delete r['function']; + delete r['description']; + delete r['category']; + }); + expect(result).to.have.length(expected.length); + expect(result).to.deep.equal(expected); + }); + }); + describe("#misc", function(){ it("validates the misc Dockerfile have the exact right issues reported", function(){ var expected = [ diff --git a/test/ruleset.js b/test/ruleset.js new file mode 100644 index 0000000..3765339 --- /dev/null +++ b/test/ruleset.js @@ -0,0 +1,20 @@ +module.exports.rules = { + 'add_prohibited': { + 'title': 'ADD Command Prohibited', + 'description': 'ADD command is not allowed! Use copy instead!', + 'category': 'Optimization', + 'function': ({ cmd, args, line, instruction }) => { + return cmd.toLowerCase() === 'add'; + } + }, + + 'avoid_curl_bashing': { + 'title': 'Avoid Curl Bashing', + 'description': 'Do not pipe bash or wget commands directly to shell. This is very insecure and can cause many issues with security. If you must, make sure to vet the script and verify its authenticity. E.G. "RUN wget http://my_website/script.sh | sh" is prohibited', + 'category': 'Optimization', + 'function': ({ cmd, line, args}) => { + // This function doesn't care about full instruction so it omits if from arguments + return cmd.toLowerCase() === 'run' && args.match(/(curl|wget)[^|^>]*[|>]/); + } + }, +} \ No newline at end of file