///|
pub struct Diagnostic {
line : Int
code : String
message : String
} derive(Debug, Eq, ToJson)
///|
pub fn lint(instructions : Array[Instruction]) -> Array[Diagnostic] {
let out = []
fn report(line : Int, code : String, message : String) -> Unit {
out.push({ line, code, message, })
}
let stages : Map[String, Int] = Map([])
let mut stage = -1
let mut pipefail = false
let stage_shells : Map[String, Bool] = Map([])
let stage_posix : Map[String, Bool] = Map([])
let mut posix_shell = true
let mut stage_alias : String? = None
let mut last_user : Instruction? = None
fn report_user() -> Unit {
if last_user is Some(ins) {
let name = ins.arguments.split(":").collect()[0].trim().to_owned()
if name == "root" || name == "0" {
report(ins.line, "DL3002", "Use a non-root runtime user")
}
}
}
let counts : Map[String, Int] = Map([])
let known = [
"FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ADD", "COPY",
"ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "ONBUILD", "STOPSIGNAL", "HEALTHCHECK",
"SHELL",
]
for ins in instructions {
let args = words(ins.arguments)
if !known.contains(ins.name) {
report(ins.line, "ML1001", "Unknown instruction: " + ins.name)
continue
}
if ins.name == "FROM" {
report_user()
last_user = None
if stage_alias is Some(stage_name) {
stage_shells[stage_name] = pipefail
stage_posix[stage_name] = posix_shell
}
stage_alias = None
stage += 1
pipefail = false
counts.clear()
let offset = if args.length() > 0 && args[0].has_prefix("--platform=") {
1
} else {
0
}
if args.length() != offset + 1 &&
!(args.length() == offset + 3 && args[offset + 1].to_upper() == "AS") {
report(ins.line, "ML1002", "Malformed FROM instruction")
continue
}
let base = args[offset]
let base_stage = stages.contains(base.to_lower())
pipefail = stage_shells.get(base.to_lower()).unwrap_or(false)
posix_shell = stage_posix.get(base.to_lower()).unwrap_or(true)
if !base_stage &&
base != "scratch" &&
!base.contains("$") &&
!base.contains("@") {
let last = base.split("/").to_array().last().unwrap().to_owned()
if !last.contains(":") {
report(ins.line, "DL3006", "Pin the base image version")
} else if last.has_suffix(":latest") {
report(ins.line, "DL3007", "Avoid the latest image tag")
}
}
if args.length() == offset + 3 {
let name = args[offset + 2].to_lower()
stage_alias = Some(name)
if stages.contains(name) {
report(ins.line, "DL3024", "Duplicate build stage name")
}
stages[name] = stage
}
continue
}
if stage < 0 && ins.name != "ARG" {
report(ins.line, "ML1003", "Instruction before first FROM")
}
if ["COPY", "ADD"].contains(ins.name) {
let parsed = Some(ins.split_arguments()) catch { _ => None }
if parsed is Some(a) {
if a.values.length() < 2 {
report(ins.line, "ML1007", "COPY/ADD require source and destination")
} else if a.values.length() > 2 &&
!a.values[a.values.length() - 1].has_suffix("/") {
report(
ins.line,
"DL3021",
"Multiple sources require a destination ending in /",
)
}
}
}
match ins.name {
"USER" => last_user = Some(ins)
"ADD" => {
let inputs = ins.split_arguments().values catch { _ => [] }
let sources = if inputs.is_empty() {
[]
} else {
inputs[:inputs.length() - 1].to_owned()
}
let special = sources
.iter()
.any(s => {
s.has_prefix("http://") ||
s.has_prefix("https://") ||
s.has_prefix("git://") ||
[".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".txz", ".tbz2"]
.iter()
.any(ext => s.has_suffix(ext))
})
if !special {
report(
ins.line,
"DL3020",
"Prefer COPY when ADD extraction or URL semantics are not needed",
)
}
}
"MAINTAINER" =>
report(ins.line, "DL4000", "MAINTAINER is deprecated; use LABEL")
"WORKDIR" =>
if !ins.arguments.has_prefix("/") && !ins.arguments.has_prefix("$") {
report(ins.line, "DL3000", "Use an absolute WORKDIR")
}
"SHELL" => {
let form = Some(ins.split_arguments()) catch { _ => None }
match form {
Some(a) => {
if !a.json_form {
report(ins.line, "ML1006", "SHELL requires JSON exec form")
}
pipefail = a.values.contains("pipefail")
posix_shell = !a.values.is_empty() &&
["sh", "bash", "dash", "ash", "ksh", "zsh"].contains(
executable_name(a.values[0]),
)
}
None => report(ins.line, "ML1006", "SHELL requires JSON exec form")
}
}
"ONBUILD" =>
if args.length() > 0 &&
["ONBUILD", "FROM", "MAINTAINER"].contains(args[0].to_upper()) {
report(
ins.line,
"DL3043",
"Instruction cannot be used as an ONBUILD trigger",
)
}
"RUN" => {
if posix_shell {
for diagnostic in shell_diagnostics(ins) {
out.push(diagnostic)
}
}
for diagnostic in run_rules(ins) {
out.push(diagnostic)
}
let script = run_script(ins)
if shell_pipeline(script) && !pipefail && !ins.arguments.has_prefix("[") {
report(
ins.line,
"DL4006",
"Shell pipeline may hide command failures; consider pipefail",
)
}
}
"CMD" | "ENTRYPOINT" => {
if !ins.arguments.has_prefix("[") {
report(
ins.line,
"DL3025",
"Prefer JSON exec form for signal forwarding",
)
}
let count = counts.get(ins.name).unwrap_or(0) + 1
counts[ins.name] = count
if count > 1 {
report(
ins.line,
if ins.name == "CMD" {
"DL4003"
} else {
"DL4004"
},
"Earlier " + ins.name + " in this stage is overridden",
)
}
}
"COPY" =>
for arg in args {
if arg.has_prefix("--from=") {
let name = arg[7:].to_owned().to_lower()
// An unknown name may legally be an external image, so it is not rejected.
if stages.get(name) is Some(index) {
if index == stage {
report(ins.line, "DL3023", "COPY cannot refer to its own stage")
}
} else {
let mut index = 0
let mut numeric = name != ""
for c in name.iter() {
if c < '0' || c > '9' {
numeric = false
} else if index < 100000 {
index = index * 10 + c.to_int() - 48
}
}
if numeric && index >= stage {
report(
ins.line,
"DL3022",
"COPY refers to unavailable stage index",
)
}
}
}
}
_ => ()
}
}
report_user()
ignore(analyze_stages(instructions)) catch {
StageCycle(line) =>
report(line, "ML1010", "Build stages have a circular dependency")
_ => ()
}
if stage < 0 {
report(1, "ML1005", "Dockerfile has no FROM stage")
}
for diagnostic in metadata_rules(instructions) {
out.push(diagnostic)
}
out.sort_by((a, b) => a.line.compare(b.line))
out
}