///|
fn diagnostic(
code : String,
level : String,
line : Int,
message : String,
) -> Diagnostic {
{ code, level, line, message, stage: None, }
}
///|
fn stage_at(doc : Dockerfile, line : Int) -> String? {
for inst in doc.instructions {
if inst.line == line {
return inst.stage
}
}
if doc.instructions.length() > 0 {
doc.instructions[doc.instructions.length() - 1].stage
} else {
None
}
}
///|
fn with_stage(item : Diagnostic, doc : Dockerfile) -> Diagnostic {
{
code: item.code,
level: item.level,
line: item.line,
message: item.message,
stage: stage_at(doc, item.line),
}
}
///|
fn install_packages(arguments : String, command : String) -> Array[String] {
let result : Array[String] = []
match find_ignore_case(arguments, command) {
None => result
Some(index) => {
let rest = arguments[index + command.length():].to_owned()
for part in tokens(rest) {
if part == "&&" || part == "||" || part == ";" {
break
}
if part.has_prefix("-") {
continue
}
if part == "apt-get" ||
part == "apt" ||
part == "apk" ||
part == "pip" ||
part == "pip3" ||
part == "install" ||
part == "add" {
break
}
result.push(part)
}
result
}
}
}
///|
fn packages_unpinned(packages : Array[String]) -> Bool {
if packages.length() == 0 {
return false
}
for pkg in packages {
if !(pkg.contains("=") || pkg.contains("~") || pkg.contains("@")) {
return true
}
}
false
}
///|
fn add_is_local_file(arguments : String) -> Bool {
let source = first_arg(arguments)
if source.is_empty() || source.has_prefix("$") {
return false
}
let lower = source.to_lower()
if lower.has_prefix("http://") || lower.has_prefix("https://") {
return false
}
if lower.has_suffix(".tar") ||
lower.has_suffix(".tar.gz") ||
lower.has_suffix(".tgz") ||
lower.has_suffix(".tar.xz") ||
lower.has_suffix(".tar.bz2") ||
lower.has_suffix(".txz") ||
lower.has_suffix(".zip") {
return false
}
true
}
///|
fn workdir_is_absolute(arguments : String) -> Bool {
let path = first_arg(arguments)
if path.is_empty() || path.has_prefix("$") {
return true
}
path.has_prefix("/") || (path.length() >= 2 && path.get_char(1) is Some(':'))
}
///|
fn has_apt_install(arguments : String) -> Bool {
find_ignore_case(arguments, "apt-get install") is Some(_) ||
find_ignore_case(arguments, "apt install") is Some(_)
}
///|
fn has_apk_add(arguments : String) -> Bool {
find_ignore_case(arguments, "apk add") is Some(_)
}
///|
fn has_pip_install(arguments : String) -> Bool {
find_ignore_case(arguments, "pip install") is Some(_) ||
find_ignore_case(arguments, "pip3 install") is Some(_)
}
///|
fn copy_paths(arguments : String) -> Array[String] {
let result : Array[String] = []
let parts = tokens(arguments)
let mut i = 0
while i < parts.length() {
if parts[i].has_prefix("--") {
if !parts[i].contains("=") && i + 1 < parts.length() {
i += 2
} else {
i += 1
}
} else {
result.push(parts[i])
i += 1
}
}
result
}
///|
fn has_npm_install(arguments : String) -> Bool {
find_ignore_case(arguments, "npm install") is Some(_) ||
find_ignore_case(arguments, "npm i ") is Some(_)
}
///|
fn stage_index_of(value : String) -> Int? {
let n = @string.parse_int(value) catch { _ => return None }
if n >= 0 {
Some(n)
} else {
None
}
}
///|
fn check_copy_from(
inst : Instruction,
named_stages : Array[String],
current_index : Int,
diagnostics : Array[Diagnostic],
) -> Unit {
match inst.copy_from {
None => ()
Some(from) => {
let current_name = inst.stage.unwrap_or("")
let refers_self = (from == current_name && current_name != "") ||
(stage_index_of(from) is Some(index) && index == current_index)
if refers_self {
diagnostics.push(
diagnostic(
"DL3023",
"error",
inst.line,
"COPY --from cannot reference its own FROM alias.",
),
)
return
}
let mut known = false
for name in named_stages {
if name == from {
known = true
}
}
match stage_index_of(from) {
Some(index) => if index >= 0 && index < current_index { known = true }
None => ()
}
if !known {
diagnostics.push(
diagnostic(
"DL3022",
"error",
inst.line,
"COPY --from should reference a previously defined FROM alias.",
),
)
}
}
}
}
///|
pub fn lint_dockerfile(doc : Dockerfile) -> LintResult {
lint_dockerfile_with(doc, default_options())
}
///|
pub fn lint_dockerfile_with(
doc : Dockerfile,
options : LintOptions,
) -> LintResult {
let diagnostics : Array[Diagnostic] = []
let named_stages : Array[String] = []
let mut prev_run = false
let mut last_user : String? = None
let mut last_user_line = 1
let mut has_pipefail = false
let mut current_index = -1
let mut cmd_count = 0
let mut entrypoint_count = 0
let mut wget_line = 0
let mut curl_line = 0
let seen_labels : Array[String] = []
if doc.instructions.length() > 0 {
match doc.instructions[0].kind {
From | Arg => ()
_ =>
diagnostics.push(
diagnostic(
"DL3061",
"error",
doc.instructions[0].line,
"Invalid instruction order. Dockerfile must begin with FROM, ARG, or a comment.",
),
)
}
}
for inst in doc.instructions {
match inst.kind {
From => {
current_index += 1
prev_run = false
last_user = None
last_user_line = inst.line
has_pipefail = false
cmd_count = 0
entrypoint_count = 0
match inst.stage {
Some(name) => {
check_unique_stage(name, named_stages, inst.line, diagnostics)
named_stages.push(name)
}
None => ()
}
check_from(inst, options, diagnostics)
}
Workdir =>
if !workdir_is_absolute(inst.arguments) {
diagnostics.push(
diagnostic(
"DL3000",
"error",
inst.line,
"Use absolute WORKDIR. Changing directory with relative paths can produce unexpected results.",
),
)
}
Copy => {
check_copy_from(inst, named_stages, current_index, diagnostics)
check_copy_dest(inst, diagnostics)
}
Add => {
check_copy_from(inst, named_stages, current_index, diagnostics)
if add_is_local_file(inst.arguments) {
diagnostics.push(
diagnostic(
"DL3020",
"error",
inst.line,
"Use COPY instead of ADD for files and directories.",
),
)
}
}
Maintainer =>
diagnostics.push(
diagnostic(
"DL4000",
"error",
inst.line,
"MAINTAINER is deprecated. Use LABEL instead.",
),
)
Env => check_env(inst, diagnostics)
Expose => check_expose(inst, diagnostics)
Label => {
check_label(inst, diagnostics)
for pair in env_pairs(inst.arguments) {
let (key, _) = pair
seen_labels.push(key)
}
}
User => {
last_user = Some(first_arg(inst.arguments))
last_user_line = inst.line
}
Shell => if inst.arguments.contains("pipefail") { has_pipefail = true }
Run => {
if prev_run {
diagnostics.push(
diagnostic(
"DL3059",
"warning",
inst.line,
"Multiple consecutive RUN instructions. Combine them to reduce layers.",
),
)
}
if contains_token(inst.arguments, "wget") {
wget_line = inst.line
}
if contains_token(inst.arguments, "curl") {
curl_line = inst.line
}
check_run(inst, has_pipefail, diagnostics)
match inst.node {
Run(run) =>
if run.form is Shell {
check_shell(inst, run.script, diagnostics)
}
_ => ()
}
}
Cmd => {
cmd_count += 1
if cmd_count > 1 {
diagnostics.push(
diagnostic(
"DL4003",
"warning",
inst.line,
"Multiple CMD instructions found. Only the last one takes effect.",
),
)
}
check_json_form(inst, diagnostics)
}
Entrypoint => {
entrypoint_count += 1
if entrypoint_count > 1 {
diagnostics.push(
diagnostic(
"DL4004",
"error",
inst.line,
"Multiple ENTRYPOINT instructions found.",
),
)
}
check_json_form(inst, diagnostics)
}
_ => ()
}
prev_run = inst.kind is Run
}
check_required_labels(seen_labels, options.required_labels, diagnostics)
if wget_line > 0 && curl_line > 0 {
diagnostics.push(
diagnostic(
"DL4001", "warning", curl_line, "Either use Wget or Curl but not both.",
),
)
}
match last_user {
None =>
if doc.instructions.length() > 0 {
diagnostics.push(
diagnostic(
"DL3002", "warning", 1, "Last USER should not be root. Add a non-root USER instruction.",
),
)
}
Some(user) =>
if user == "" || user.equal_ignore_ascii_case("root") || user == "0" {
diagnostics.push(
diagnostic(
"DL3002", "warning", last_user_line, "Last USER should not be root.",
),
)
}
}
let kept : Array[Diagnostic] = []
for item in diagnostics {
if !is_suppressed(item, doc, options) {
kept.push(with_stage(item, doc))
}
}
{ diagnostics: kept, instruction_count: doc.instructions.length(), }
}
///|
fn is_suppressed(
item : Diagnostic,
doc : Dockerfile,
options : LintOptions,
) -> Bool {
if contains_code(options.ignored, item.code) {
return true
}
for inst in doc.instructions {
if inst.line == item.line && contains_code(inst.ignored_codes, item.code) {
return true
}
}
false
}
///|
fn check_from(
inst : Instruction,
options : LintOptions,
diagnostics : Array[Diagnostic],
) -> Unit {
match inst.node {
From(image) => {
if is_scratch(image.image) || is_variable_ref(image.image) {
return
}
match image.platform {
None => ()
Some(_) =>
diagnostics.push(
diagnostic(
"DL3029",
"warning",
inst.line,
"Do not use --platform= with FROM. Use build arguments or the build command instead.",
),
)
}
if options.trusted_registries.length() > 0 {
let registry = image_registry(image.image)
if !contains_code(options.trusted_registries, registry) {
diagnostics.push(
diagnostic(
"DL3026",
"error",
inst.line,
"Use only an allowed registry: \{registry} is not trusted.",
),
)
}
}
if image.digest {
return
}
match image.tag {
None =>
diagnostics.push(
diagnostic(
"DL3006",
"error",
inst.line,
"Always tag the version of an image explicitly.",
),
)
Some(tag) =>
if tag.equal_ignore_ascii_case("latest") {
diagnostics.push(
diagnostic(
"DL3007",
"warning",
inst.line,
"Using latest is prone to errors from image updates.",
),
)
}
}
}
_ =>
diagnostics.push(
diagnostic("DL3006", "error", inst.line, "FROM is missing an image."),
)
}
}
///|
fn check_json_form(inst : Instruction, diagnostics : Array[Diagnostic]) -> Unit {
if !(inst.form is Json) {
diagnostics.push(
diagnostic(
"DL3025",
"warning",
inst.line,
"Use JSON notation for CMD and ENTRYPOINT arguments.",
),
)
}
}
///|
fn check_copy_dest(inst : Instruction, diagnostics : Array[Diagnostic]) -> Unit {
if inst.form is Json {
return
}
let paths = copy_paths(inst.arguments)
if paths.length() >= 3 {
let dest = paths[paths.length() - 1]
if !dest.has_suffix("/") && !dest.has_suffix("\\") {
diagnostics.push(
diagnostic(
"DL3021",
"error",
inst.line,
"COPY with more than two arguments requires the last argument to end with /",
),
)
}
}
}
///|
fn check_run(
inst : Instruction,
has_pipefail : Bool,
diagnostics : Array[Diagnostic],
) -> Unit {
if contains_token(inst.arguments, "sudo") {
diagnostics.push(
diagnostic(
"DL3004",
"error",
inst.line,
"Do not use sudo as it leads to unpredictable behavior. Use a USER instruction instead.",
),
)
}
if find_ignore_case(inst.arguments, "apt-get upgrade") is Some(_) ||
find_ignore_case(inst.arguments, "apt-get dist-upgrade") is Some(_) ||
find_ignore_case(inst.arguments, "apt upgrade") is Some(_) {
diagnostics.push(
diagnostic(
"DL3005",
"error",
inst.line,
"Do not use apt-get upgrade or dist-upgrade.",
),
)
}
if has_apt_install(inst.arguments) {
if !(contains_token(inst.arguments, "-y") ||
contains_token(inst.arguments, "--yes") ||
contains_token(inst.arguments, "--assume-yes")) {
diagnostics.push(
diagnostic(
"DL3014",
"warning",
inst.line,
"Use the -y switch to avoid manual input from apt-get.",
),
)
}
if !inst.arguments.contains("--no-install-recommends") {
diagnostics.push(
diagnostic(
"DL3015",
"warning",
inst.line,
"Avoid additional packages by specifying --no-install-recommends.",
),
)
}
if packages_unpinned(install_packages(inst.arguments, "apt-get install")) ||
packages_unpinned(install_packages(inst.arguments, "apt install")) {
diagnostics.push(
diagnostic(
"DL3008",
"warning",
inst.line,
"Pin versions in apt-get install. Use package=version.",
),
)
}
if !inst.arguments.contains("/var/lib/apt/lists") {
diagnostics.push(
diagnostic(
"DL3009",
"warning",
inst.line,
"Delete the apt-get lists after installing packages.",
),
)
}
}
if has_apk_add(inst.arguments) {
if !inst.arguments.contains("--no-cache") {
diagnostics.push(
diagnostic(
"DL3019",
"warning",
inst.line,
"Use the --no-cache switch with apk add to avoid extra cache cleanup.",
),
)
}
if packages_unpinned(install_packages(inst.arguments, "apk add")) {
diagnostics.push(
diagnostic(
"DL3018",
"warning",
inst.line,
"Pin versions in apk add. Use package=version.",
),
)
}
}
if has_pip_install(inst.arguments) {
if packages_unpinned(install_packages(inst.arguments, "pip install")) ||
packages_unpinned(install_packages(inst.arguments, "pip3 install")) {
diagnostics.push(
diagnostic(
"DL3013",
"warning",
inst.line,
"Pin versions in pip. Use package==version.",
),
)
}
if !inst.arguments.contains("--no-cache-dir") {
diagnostics.push(
diagnostic(
"DL3042",
"warning",
inst.line,
"Avoid pip cache with --no-cache-dir.",
),
)
}
}
if contains_token(inst.arguments, "cd") {
diagnostics.push(
diagnostic(
"DL3003",
"warning",
inst.line,
"Use WORKDIR to switch to a directory.",
),
)
}
if has_npm_install(inst.arguments) &&
packages_unpinned(install_packages(inst.arguments, "npm install")) {
diagnostics.push(
diagnostic(
"DL3016",
"warning",
inst.line,
"Pin versions in npm. Use package@version.",
),
)
}
if contains_token(inst.arguments, "apt") &&
!contains_token(inst.arguments, "apt-get") &&
!contains_token(inst.arguments, "apt-cache") {
diagnostics.push(
diagnostic(
"DL3027",
"warning",
inst.line,
"Do not use apt as it is meant to be an end-user tool. Use apt-get or apt-cache.",
),
)
}
if find_ignore_case(inst.arguments, "yum install") is Some(_) {
if packages_unpinned(install_packages(inst.arguments, "yum install")) {
diagnostics.push(
diagnostic(
"DL3033",
"warning",
inst.line,
"Pin versions in yum install.",
),
)
}
if !inst.arguments.contains("yum clean") {
diagnostics.push(
diagnostic(
"DL3032",
"warning",
inst.line,
"Use yum clean all after installing packages.",
),
)
}
}
if find_ignore_case(inst.arguments, "dnf install") is Some(_) {
if packages_unpinned(install_packages(inst.arguments, "dnf install")) {
diagnostics.push(
diagnostic(
"DL3041",
"warning",
inst.line,
"Pin versions in dnf install.",
),
)
}
if !inst.arguments.contains("dnf clean") {
diagnostics.push(
diagnostic(
"DL3040",
"warning",
inst.line,
"Use dnf clean all after installing packages.",
),
)
}
}
if find_ignore_case(inst.arguments, "gem install") is Some(_) &&
packages_unpinned(install_packages(inst.arguments, "gem install")) {
diagnostics.push(
diagnostic(
"DL3028",
"warning",
inst.line,
"Pin versions in gem install. Use package:version.",
),
)
}
if inst.arguments.contains("|") && !has_pipefail {
diagnostics.push(
diagnostic(
"DL4006",
"warning",
inst.line,
"Set the SHELL option -o pipefail before RUN with a pipe.",
),
)
}
}