Dockerfiles
Dockerfiles parsed as BuildKit reads them: parser directives, continuation lines with comments inside, heredocs, flags, exec and shell forms, ENV and LABEL pairs. Stages and base images with ARGs filled in and how each is pinned, image references taken apart, and lint rules for latest tags, a root final user, ADD from URLs, apt-get habits, secrets in ENV and COPY from unknown stages.
Version 1.0.0 · 5 functions · 1 crate
Functions
What use dockerfile.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use dockerfile_parse, dockerfile_image_ref from dockerfile (Quillo 0.6).
dockerfile_parse(text: str) → anyor an errorParses a Dockerfile: { directives, instructions, warnings }, each instruction with its lines, flags, exec or shell form, key/value pairs and heredocs.
dockerfile_image_ref(reference: str) → anyor an errorParses an image reference: { registry, repository, tag, digest, digest_algorithm, official }, with Docker Hub's defaults filled in.
dockerfile_stages(text: str, args: any) → listor an errorStages in order: { index, name, image, from_stage, platform, pin, user, workdir, exposes }. args are --build-args, used in FROM lines over the file's own ARG defaults.
dockerfile_images(text: str, args: any) → listor an errorThe base images from outside the file, once each: { image, registry, repository, tag, digest, pin, stages }. pin is digest, version, floating, latest or none.
dockerfile_lint(text: str) → listor an errorLint findings [{ line, rule, level, message }]: latest or missing tags, root as the final user, ADD from a URL or for plain files, apt-get habits, a script piped to a shell, secrets in ENV, COPY from an unknown stage, repeated CMD, and more.
Get it
$ quillo install dockerfile # dockerfile.qwer into this folder$ quillo install dockerfile --example # and its example, dockerfile.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/dockerfile.qwer$ curl -O https://quillolang.org/plugins/files/dockerfile.qwe$ curl -O https://quillolang.org/plugins/files/dockerfile_sample.DockerfileThen, in your program, all of it or only what you call:
use dockerfile.qweruse dockerfile_parse, dockerfile_image_ref from dockerfile // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// A Dockerfile checked before it is built: what it holds, which images it pulls and how well they// are pinned, and what a reviewer would say about it.//// quillo run dockerfile.qwe then open http://localhost:8080/parse, /stages, /images, /lint, /refs and /brokenuse "dockerfile.qwer"let TEXT = read_file("dockerfile_sample.Dockerfile")route GET /parse { let d = dockerfile_parse(TEXT)? let list = d.instructions { directives: d.directives, count: len(list), continued: [list[5].line, list[5].end_line, list[5].args, list[5].form], heredoc: list[8].heredocs, copy_heredoc: [list[9].flags, list[9].heredocs[0].text, list[9].heredocs[0].expand], from_flags: list[2].flags, env: (list | filter((i) => i.instruction == "ENV")) | map((i) => i.pairs), label: (list | filter((i) => i.instruction == "LABEL"))[0].pairs, cmd: (list | filter((i) => i.instruction == "CMD")) | map((i) => [i.form, i.list ?? i.args]), }}route GET /stages = dockerfile_stages(TEXT, {})? | map((s) => [s.index, s.name, s.image, s.from_stage, s.pin, s.platform])route GET /images = { default: dockerfile_images(TEXT, {})?, build_arg: dockerfile_images(TEXT, { NODE_VERSION: "18" })?[0],}route GET /lint = dockerfile_lint(TEXT)? | map((x) => `${x.line} ${x.level} ${x.rule}`)route GET /messages = (dockerfile_lint(TEXT)? | filter((x) => x.rule == "copy-from-unknown" || x.rule == "root-user" || x.rule == "secret-in-image")) | map((x) => x.message)route GET /refs = { plain: dockerfile_image_ref("nginx")?, org: dockerfile_image_ref("bitnami/redis:7.2")?, port: dockerfile_image_ref("localhost:5000/a/b:1")?, ghcr: dockerfile_image_ref("ghcr.io/o/r@sha256:8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4")?.digest, upper: dockerfile_image_ref("Nginx").error, bad_tag: dockerfile_image_ref("nginx:a/b:c!").error, bad_digest: dockerfile_image_ref("nginx@sha256:zz").error,}route GET /tick { let t = dockerfile_parse("# escape=`\nFROM scratch\nRUN dir `\n c:\\ `\n /b\n")? let good = dockerfile_lint("FROM alpine:3.19.1\nUSER 1000\nCMD [\"app\"]\n")? { run: t.instructions[1].args, directive: t.directives, good: good, scratch: dockerfile_lint("FROM scratch\nCOPY a /a\n")? }}route GET /broken = { unknown: dockerfile_parse("FROM a\nFROB x\n").error, no_from: dockerfile_stages("RUN x\n", {}).error, before: dockerfile_lint("ARG A=1\nRUN x\nFROM a:1.2.3\n")?[0].rule, heredoc: dockerfile_parse("FROM a\nRUN <<EOF\necho\n").error, empty_run: dockerfile_parse("FROM a\nRUN\n").error, bad_from: dockerfile_stages("FROM a b c d\n", {}).error, later: (dockerfile_lint("FROM a:1.2 AS one\nCOPY --from=two /a /b\nFROM a:1.2 AS two\nUSER x\n")? | map((x) => x.rule)), twice: (dockerfile_lint("FROM a:1.2 AS x\nFROM b:1.2 AS X\nUSER x\n")? | map((x) => x.rule)),}$ quillo run dockerfile.qwe$ curl 'localhost:8080/parse'$ curl 'localhost:8080/stages'$ curl 'localhost:8080/images'$ curl 'localhost:8080/lint'$ curl 'localhost:8080/messages'$ curl 'localhost:8080/refs'$ curl 'localhost:8080/tick'$ curl 'localhost:8080/broken'Source
//! Dockerfiles parsed with continuation lines and heredocs: instructions, stages, base images and how well they are pinned, and lint rules for latest tags, root users, ADD from URLs and more.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::HashMap;const KEYWORDS: [&str; 18] = ["FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ADD", "COPY", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "ONBUILD", "STOPSIGNAL", "HEALTHCHECK", "SHELL"];struct Instr { line: usize, end_line: usize, name: String, args: String, flags: Vec<(String, String)>, json: Option<Vec<String>>, heredocs: Vec<Value>,}/// `# escape=` and the other parser directives: only in a run of comments at the very top.fn directives(lines: &[&str]) -> (char, Map<String, Value>) { let mut esc = '\\'; let mut out = Map::new(); for l in lines { let t = l.trim(); let Some(body) = t.strip_prefix('#') else { break }; let Some((k, v)) = body.split_once('=') else { break }; let k = k.trim().to_lowercase(); if !["escape", "syntax", "check"].contains(&k.as_str()) || out.contains_key(&k) { break; } if k == "escape" { esc = v.trim().chars().next().filter(|c| *c == '`').unwrap_or('\\'); } out.insert(k, json!(v.trim())); } (esc, out)}/// `<<EOF`, `<<-EOF`, `<<"EOF"`, `<<'EOF'` in a line: `(name, strip tabs, quoted)`.fn heredoc_names(line: &str) -> Vec<(String, bool, bool)> { let b: Vec<char> = line.chars().collect(); let mut out = vec![]; let mut i = 0; let mut quote: Option<char> = None; while i + 2 < b.len() { match quote { Some(q) => { if b[i] == q { quote = None; } i += 1; continue; } None if b[i] == '"' || b[i] == '\'' => { quote = Some(b[i]); i += 1; continue; } _ => {} } if b[i] == '<' && b[i + 1] == '<' { let mut j = i + 2; let strip = b.get(j) == Some(&'-'); if strip { j += 1; } let q = b.get(j).copied().filter(|c| *c == '"' || *c == '\''); if q.is_some() { j += 1; } let start = j; while j < b.len() && (b[j].is_alphanumeric() || b[j] == '_') { j += 1; } if j > start && !b[start].is_ascii_digit() { out.push((b[start..j].iter().collect(), strip, q.is_some())); i = j; continue; } } i += 1; } out}fn split_flags(text: &str) -> (Vec<(String, String)>, String) { let mut rest = text.trim_start(); let mut flags = vec![]; while let Some(r) = rest.strip_prefix("--") { let end = r.find(char::is_whitespace).unwrap_or(r.len()); let (k, v) = r[..end].split_once('=').unwrap_or((&r[..end], "")); flags.push((k.to_string(), v.to_string())); rest = r[end..].trim_start(); } (flags, rest.to_string())}fn parse(text: &str) -> Result<(Vec<Instr>, Map<String, Value>, Vec<String>), String> { let text = text.trim_start_matches('\u{feff}'); let lines: Vec<&str> = text.lines().collect(); let (esc, direct) = directives(&lines); let mut out: Vec<Instr> = vec![]; let mut warnings = vec![]; let mut i = 0; while i < lines.len() { let first = i; let t = lines[i].trim(); if t.is_empty() || t.starts_with('#') { i += 1; continue; } let mut logical = String::new(); loop { let l = lines[i].trim_end(); let (body, more) = match l.strip_suffix(esc) { Some(b) => (b, true), None => (l, false), }; logical.push_str(body); i += 1; if !more { break; } // Comments inside a continued instruction are dropped; blank lines too, with a warning from Docker. while i < lines.len() { let n = lines[i].trim(); if n.starts_with('#') { i += 1; } else if n.is_empty() { warnings.push(format!("line {}: an empty line inside a continued instruction", i + 1)); i += 1; } else { break; } } if i >= lines.len() { break; } } let logical_trim = logical.trim(); let (word, rest) = logical_trim.split_once(char::is_whitespace).unwrap_or((logical_trim, "")); let name = word.to_uppercase(); if !KEYWORDS.contains(&name.as_str()) { return Err(format!("line {}: unknown instruction `{}`", first + 1, word)); } let (flags, args) = if ["FROM", "RUN", "COPY", "ADD", "HEALTHCHECK"].contains(&name.as_str()) { split_flags(rest) } else { (vec![], rest.trim().to_string()) }; if args.is_empty() && !matches!(name.as_str(), "HEALTHCHECK") { return Err(format!("line {}: {} needs arguments", first + 1, name)); } let mut heredocs = vec![]; if ["RUN", "COPY", "ADD"].contains(&name.as_str()) { for (tag, strip, quoted) in heredoc_names(&args) { let start = i + 1; let mut body = vec![]; let mut closed = false; while i < lines.len() { let raw = lines[i]; i += 1; let cmp = if strip { raw.trim_start_matches('\t') } else { raw }; if cmp == tag { closed = true; break; } body.push(cmp.to_string()); } if !closed { return Err(format!("line {}: the heredoc <<{} is never closed", first + 1, tag)); } heredocs.push(json!({ "name": tag, "line": start, "text": body.join("\n") + if body.is_empty() { "" } else { "\n" }, "expand": !quoted })); } } let json_form = if args.starts_with('[') { serde_json::from_str::<Vec<String>>(&args).ok() } else { None }; out.push(Instr { line: first + 1, end_line: i, name, args, flags, json: json_form, heredocs }); } Ok((out, direct, warnings))}/// `a=1 b="two words"` or the old `KEY some value`.fn pairs(args: &str, legacy_ok: bool) -> Vec<(String, String)> { let c: Vec<char> = args.chars().collect(); let first_ws = c.iter().position(|x| x.is_whitespace()).unwrap_or(c.len()); let first_eq = c.iter().position(|x| *x == '=').unwrap_or(c.len()); if legacy_ok && first_ws < first_eq { let k: String = c[..first_ws].iter().collect(); let v: String = c[first_ws..].iter().collect(); return vec![(k, v.trim().to_string())]; } let mut out = vec![]; let mut i = 0; while i < c.len() { while i < c.len() && c[i].is_whitespace() { i += 1; } let mut k = String::new(); while i < c.len() && c[i] != '=' && !c[i].is_whitespace() { k.push(c[i]); i += 1; } if k.is_empty() { break; } let mut v = String::new(); if i < c.len() && c[i] == '=' { i += 1; let mut q: Option<char> = None; while i < c.len() { let x = c[i]; match q { Some(qq) if x == qq => q = None, Some('"') if x == '\\' && i + 1 < c.len() => { i += 1; v.push(c[i]); } Some(_) => v.push(x), None if x == '"' || x == '\'' => q = Some(x), None if x == '\\' && i + 1 < c.len() => { i += 1; v.push(c[i]); } None if x.is_whitespace() => break, None => v.push(x), } i += 1; } } out.push((k, v)); } out}fn instr_json(i: &Instr) -> Value { let mut flags = Map::new(); for (k, v) in &i.flags { flags.insert(k.clone(), json!(v)); } let mut m = Map::new(); m.insert("line".into(), json!(i.line)); m.insert("end_line".into(), json!(i.end_line)); m.insert("instruction".into(), json!(i.name)); m.insert("args".into(), json!(i.args)); if !flags.is_empty() { m.insert("flags".into(), Value::Object(flags)); } if let Some(j) = &i.json { m.insert("form".into(), json!("exec")); m.insert("list".into(), json!(j)); } else if ["RUN", "CMD", "ENTRYPOINT"].contains(&i.name.as_str()) { m.insert("form".into(), json!("shell")); } if ["ENV", "LABEL", "ARG"].contains(&i.name.as_str()) { let mut p = Map::new(); for (k, v) in pairs(&i.args, i.name == "ENV") { p.insert(k, json!(v)); } m.insert("pairs".into(), Value::Object(p)); } if !i.heredocs.is_empty() { m.insert("heredocs".into(), json!(i.heredocs)); } Value::Object(m)}/// Parses a Dockerfile: `{ directives, instructions, warnings }`, each instruction with its lines, flags, exec or shell form, key/value pairs and heredocs.pub fn dockerfile_parse(text: &str) -> Result<Value, String> { let (list, direct, warnings) = parse(text)?; Ok(json!({ "directives": direct, "instructions": list.iter().map(instr_json).collect::<Vec<_>>(), "warnings": warnings }))}/// Parses an image reference: `{ registry, repository, tag, digest, digest_algorithm, official }`, with Docker Hub's defaults filled in.pub fn dockerfile_image_ref(reference: &str) -> Result<Value, String> { image_ref(reference)}fn image_ref(reference: &str) -> Result<Value, String> { let r = reference.trim(); if r.is_empty() || r.chars().any(char::is_whitespace) { return Err(format!("`{}` is not an image reference", reference)); } let (rest, digest) = match r.split_once('@') { Some((a, d)) => { let (alg, hex) = d.split_once(':').ok_or(format!("`{}` is not a digest", d))?; if alg.is_empty() || hex.len() < 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(format!("`{}` is not a digest", d)); } (a, Some(d)) } None => (r, None), }; let (registry, path) = match rest.split_once('/') { Some((a, b)) if a.contains('.') || a.contains(':') || a == "localhost" => (Some(a), b), _ => (None, rest), }; let tail = path.rsplit('/').next().unwrap_or(path); let (repo, tag) = match tail.rsplit_once(':') { Some((_, t)) => (&path[..path.len() - t.len() - 1], Some(t)), None => (path, None), }; if repo.is_empty() || !repo.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-/".contains(&b)) { return Err(format!("`{}` is not a repository name", repo)); } if let Some(t) = tag { if t.is_empty() || t.len() > 128 || !t.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) { return Err(format!("`{}` is not a tag", t)); } } let hub = registry.map_or(true, |x| x == "docker.io" || x == "index.docker.io"); let official = hub && !repo.contains('/'); Ok(json!({ "registry": if hub { "docker.io" } else { registry.unwrap() }, "repository": if official { format!("library/{}", repo) } else { repo.to_string() }, "tag": tag, "digest": digest, "official": official, }))}/// `$A`, `${A}`, `${A:-x}`, `${A:+x}`, as Docker reads them in a FROM line.fn substitute(text: &str, vars: &HashMap<String, String>) -> String { let c: Vec<char> = text.chars().collect(); let mut out = String::new(); let mut i = 0; while i < c.len() { if c[i] != '$' { out.push(c[i]); i += 1; continue; } i += 1; if c.get(i) == Some(&'{') { let start = i + 1; let Some(end) = c[start..].iter().position(|x| *x == '}') else { out.push_str("${"); i += 1; continue }; let body: String = c[start..start + end].iter().collect(); i = start + end + 1; let (name, rule) = match body.find(":-").or(body.find(":+")) { Some(p) => (&body[..p], Some((&body[p..p + 2], &body[p + 2..]))), None => (body.as_str(), None), }; let val = vars.get(name).filter(|v| !v.is_empty()); match (rule, val) { (Some((":-", d)), None) => out.push_str(d), (Some((":+", d)), Some(_)) => out.push_str(d), (Some((":+", _)), None) => {} (_, Some(v)) => out.push_str(v), (_, None) => {} } } else { let start = i; while i < c.len() && (c[i].is_alphanumeric() || c[i] == '_') { i += 1; } let name: String = c[start..i].iter().collect(); if let Some(v) = vars.get(&name) { out.push_str(v); } } } out}struct Stage { index: usize, name: Option<String>, image: String, written: String, platform: Option<String>, line: usize, from_stage: Option<String>, from_scratch: bool, end: usize,}fn stages_of(list: &[Instr], given: &HashMap<String, String>) -> Result<Vec<Stage>, String> { let mut vars: HashMap<String, String> = HashMap::new(); let mut out: Vec<Stage> = vec![]; for (at, i) in list.iter().enumerate() { if out.is_empty() && i.name == "ARG" { for (k, v) in pairs(&i.args, false) { vars.insert(k, v); } } if i.name != "FROM" { continue; } let mut merged = vars.clone(); for (k, v) in given { merged.insert(k.clone(), v.clone()); } let words: Vec<&str> = i.args.split_whitespace().collect(); let written = *words.first().unwrap_or(&""); let name = match words.as_slice() { [_] => None, [_, a, n] if a.eq_ignore_ascii_case("as") => Some(n.to_string()), _ => return Err(format!("line {}: FROM is `FROM image [AS name]`", i.line)), }; let image = substitute(written, &merged); let earlier = out.iter().find(|s| s.name.as_deref().map_or(false, |n| n.eq_ignore_ascii_case(&image))).and_then(|s| s.name.clone()); let by_index = image.parse::<usize>().ok().filter(|n| *n < out.len()).map(|n| n.to_string()); if let Some(l) = out.last_mut() { l.end = at; } out.push(Stage { index: out.len(), name, written: written.to_string(), line: i.line, end: list.len(), platform: i.flags.iter().find(|f| f.0 == "platform").map(|f| f.1.clone()), from_stage: earlier.or(by_index), from_scratch: image == "scratch", image, }); } if out.is_empty() { return Err("no FROM: a Dockerfile needs one".to_string()); } Ok(out)}fn pin_of(s: &Stage) -> Value { if s.from_stage.is_some() { return json!("stage"); } if s.from_scratch { return json!("scratch"); } match image_ref(&s.image) { Ok(r) if r["digest"].is_string() => json!("digest"), Ok(r) => match r["tag"].as_str() { None => json!("none"), Some("latest") => json!("latest"), // A full version, 1.25.3 or 16.4-alpine, moves less than a floating 16 or stable. Some(t) if t.chars().next().map_or(false, |c| c.is_ascii_digit()) && t.split(['-', '_']).next().unwrap_or("").contains('.') => json!("version"), Some(_) => json!("floating"), }, Err(_) => json!("unknown"), }}fn stage_json(s: &Stage, list: &[Instr]) -> Value { let body = &list[s.line_index(list)..s.end]; json!({ "index": s.index, "name": s.name, "image": s.image, "written": s.written, "platform": s.platform, "line": s.line, "from_stage": s.from_stage, "pin": pin_of(s), "instructions": body.len() - 1, "user": body.iter().filter(|i| i.name == "USER").last().map(|i| i.args.clone()), "workdir": body.iter().filter(|i| i.name == "WORKDIR").last().map(|i| i.args.clone()), "exposes": body.iter().filter(|i| i.name == "EXPOSE").flat_map(|i| i.args.split_whitespace().map(str::to_string).collect::<Vec<_>>()).collect::<Vec<_>>(), })}impl Stage { fn line_index(&self, list: &[Instr]) -> usize { list.iter().position(|i| i.line == self.line).unwrap_or(0) }}/// Stages in order: `{ index, name, image, from_stage, platform, pin, user, workdir, exposes }`. `args` are `--build-arg`s, used in FROM lines over the file's own ARG defaults.pub fn dockerfile_stages(text: &str, args: serde_json::Value) -> Result<Vec<Value>, String> { let (list, _, _) = parse(text)?; let given: HashMap<String, String> = args.as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()).unwrap_or_default(); let st = stages_of(&list, &given)?; Ok(st.iter().map(|s| stage_json(s, &list)).collect())}/// The base images from outside the file, once each: `{ image, registry, repository, tag, digest, pin, stages }`. `pin` is digest, version, floating, latest or none.pub fn dockerfile_images(text: &str, args: serde_json::Value) -> Result<Vec<Value>, String> { let (list, _, _) = parse(text)?; let given: HashMap<String, String> = args.as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()).unwrap_or_default(); let st = stages_of(&list, &given)?; let mut out: Vec<Value> = vec![]; for s in st.iter().filter(|s| s.from_stage.is_none() && !s.from_scratch) { if let Some(e) = out.iter_mut().find(|e| e["image"] == json!(s.image)) { e["stages"].as_array_mut().unwrap().push(json!(s.index)); continue; } let r = image_ref(&s.image).map_err(|e| format!("line {}: {}", s.line, e))?; out.push(json!({ "image": s.image, "registry": r["registry"], "repository": r["repository"], "tag": r["tag"], "digest": r["digest"], "pin": pin_of(s), "stages": [s.index] })); } Ok(out)}fn add(out: &mut Vec<Value>, line: usize, rule: &str, level: &str, message: String) { out.push(json!({ "line": line, "rule": rule, "level": level, "message": message }));}fn lint_run(i: &Instr, out: &mut Vec<Value>) { let body = i.heredocs.iter().map(|h| h["text"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join("\n"); let cmd = format!("{} {}", i.args, body); let cmds: Vec<&str> = cmd.split(|c| c == '\n' || c == ';').flat_map(|x| x.split("&&")).flat_map(|x| x.split("||")).map(str::trim).collect(); let first = cmds.first().copied().unwrap_or(""); if first.starts_with("cd ") { add(out, i.line, "use-workdir", "warning", "`cd` lasts one RUN: use WORKDIR".to_string()); } if cmds.iter().flat_map(|c| c.split('|')).any(|c| c.trim().starts_with("sudo ")) { add(out, i.line, "no-sudo", "warning", "sudo in a build: the build is already root, or USER should change".to_string()); } let apt = |c: &&&str, w: &str| c.starts_with(&format!("apt-get {}", w)) || c.starts_with(&format!("apt {}", w)); let update = cmds.iter().any(|c| apt(&c, "update")); let install: Vec<&&str> = cmds.iter().filter(|c| apt(c, "install") || apt(c, "-y install") || apt(c, "-qq install")).collect(); if update && install.is_empty() { add(out, i.line, "apt-update-alone", "warning", "apt-get update alone is cached and goes stale: run it in the same RUN as install".to_string()); } for c in &install { if !c.split_whitespace().any(|w| w == "-y" || w == "--yes" || w == "-qqy" || w == "-yq" || w == "--assume-yes") { add(out, i.line, "apt-no-yes", "error", "apt-get install without -y waits for an answer that never comes".to_string()); } if !c.contains("--no-install-recommends") { add(out, i.line, "apt-recommends", "info", "apt-get install without --no-install-recommends brings in more than asked".to_string()); } } if cmds.iter().any(|c| apt(&c, "upgrade") || apt(&c, "dist-upgrade")) { add(out, i.line, "apt-upgrade", "warning", "apt-get upgrade in an image: use a newer base instead".to_string()); } if cmd.contains('|') { for segment in cmd.split("&&") { let fetch = segment.contains("curl ") || segment.contains("wget "); let shell = segment.split('|').skip(1).any(|p| { let w = p.trim().trim_start_matches("sudo ").split_whitespace().next().unwrap_or(""); ["sh", "bash", "zsh", "/bin/sh", "/bin/bash"].contains(&w) }); if fetch && shell { add(out, i.line, "curl-pipe-shell", "warning", "a script piped from the network into a shell: fetch it, check its digest, then run it".to_string()); } } } if cmds.iter().any(|c| c.starts_with("pip install") || c.starts_with("pip3 install")) && !cmd.contains("--no-cache-dir") { add(out, i.line, "pip-cache", "info", "pip install without --no-cache-dir keeps its cache in the image".to_string()); }}fn secret_name(k: &str) -> bool { let u = k.to_uppercase(); ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY"].iter().any(|w| u.contains(w))}/// Lint findings `[{ line, rule, level, message }]`: latest or missing tags, root as the final user, ADD from a URL or for plain files, apt-get habits, a script piped to a shell, secrets in ENV, COPY from an unknown stage, repeated CMD, and more.pub fn dockerfile_lint(text: &str) -> Result<Vec<Value>, String> { let (list, _, _) = parse(text)?; let stages = stages_of(&list, &HashMap::new())?; let mut out = vec![]; if let Some(i) = list.iter().find(|i| i.name != "ARG") { if i.name != "FROM" { add(&mut out, i.line, "from-first", "error", format!("{} comes before any FROM", i.name)); } } let mut names: Vec<String> = vec![]; for s in &stages { match pin_of(s).as_str() { Some("latest") => add(&mut out, s.line, "latest-tag", "warning", format!("{} moves: pin a version or digest", s.image)), Some("none") => add(&mut out, s.line, "no-tag", "warning", format!("{} has no tag, so it means latest: pin a version or digest", s.image)), _ => {} } if let Some(n) = &s.name { if names.iter().any(|x| x.eq_ignore_ascii_case(n)) { add(&mut out, s.line, "duplicate-stage", "error", format!("the stage name {} is used twice", n)); } names.push(n.clone()); } } for (at, s) in stages.iter().enumerate() { let body = &list[s.line_index(&list)..s.end]; for k in ["CMD", "ENTRYPOINT", "HEALTHCHECK"] { let found: Vec<&Instr> = body.iter().filter(|i| i.name == k).collect(); if found.len() > 1 { add(&mut out, found[1].line, "repeated", "warning", format!("{} is given {} times in a stage: only the last counts", k, found.len())); } } let earlier: Vec<String> = stages[..at].iter().flat_map(|e| e.name.iter().cloned().chain(std::iter::once(e.index.to_string()))).map(|x| x.to_lowercase()).collect(); for i in body { match i.name.as_str() { "ADD" => { let words: Vec<&str> = i.args.split_whitespace().collect(); let src = &words[..words.len().saturating_sub(1)]; if src.iter().any(|w| w.starts_with("http://") || w.starts_with("https://")) { add(&mut out, i.line, "add-url", "warning", "ADD from a URL: no checksum and no cache control; RUN curl with a digest check, or ADD --checksum".to_string()); } else if i.heredocs.is_empty() && !src.iter().any(|w| [".tar", ".tgz", ".gz", ".bz2", ".xz", ".zst"].iter().any(|e| w.ends_with(e)) || w.starts_with("git@")) { add(&mut out, i.line, "add-for-copy", "info", "ADD for plain files: COPY says what it does".to_string()); } } "COPY" => { if let Some((_, from)) = i.flags.iter().find(|f| f.0 == "from") { if !earlier.contains(&from.to_lowercase()) && !from.contains('/') && !from.contains(':') && stages.iter().any(|x| x.name.as_deref().map_or(false, |n| n.eq_ignore_ascii_case(from))) { add(&mut out, i.line, "copy-from-later", "error", format!("COPY --from={} names this stage or a later one", from)); } else if !earlier.contains(&from.to_lowercase()) && !from.contains('/') && !from.contains(':') { add(&mut out, i.line, "copy-from-unknown", "warning", format!("COPY --from={} is no stage here, so Docker will pull it as an image", from)); } } } "RUN" if i.json.is_none() => lint_run(i, &mut out), "MAINTAINER" => add(&mut out, i.line, "maintainer", "info", "MAINTAINER is deprecated: LABEL maintainer=...".to_string()), "CMD" | "ENTRYPOINT" if i.json.is_none() => add(&mut out, i.line, "shell-form", "info", format!("{} in shell form runs under sh, which does not pass signals on: use the [\"exec\", \"form\"]", i.name)), "ENV" | "ARG" => for (k, v) in pairs(&i.args, i.name == "ENV") { if secret_name(&k) && !v.is_empty() { add(&mut out, i.line, "secret-in-image", "error", format!("{} {} puts a secret into the image's history: use a build secret", i.name, k)); } }, "WORKDIR" if !i.args.starts_with('/') && !i.args.starts_with('$') && !i.args.starts_with('"') => add(&mut out, i.line, "relative-workdir", "warning", format!("WORKDIR {} depends on what came before: use an absolute path", i.args)), "EXPOSE" => for w in i.args.split_whitespace() { let p = w.split('/').next().unwrap_or(""); if !p.starts_with('$') && p.split('-').any(|x| x.parse::<u32>().map_or(true, |n| n == 0 || n > 65535)) { add(&mut out, i.line, "bad-port", "error", format!("EXPOSE {} is not a port", w)); } }, _ => {} } } } let last = stages.last().unwrap(); let body = &list[last.line_index(&list)..last.end]; match body.iter().filter(|i| i.name == "USER").last() { None if !last.from_scratch => add(&mut out, last.line, "root-user", "warning", "the final stage sets no USER, so it runs as root".to_string()), Some(u) if ["root", "0", "root:root", "0:0"].contains(&u.args.as_str()) => add(&mut out, u.line, "root-user", "warning", "the final USER is root".to_string()), _ => {} } out.sort_by_key(|x| x["line"].as_i64().unwrap_or(0)); Ok(out)}Related
docker.qwer · 1.0.0The Docker Engine API over its Unix socket with no client library: containers listed with filters, inspected, started, stopped and removed, logs with the multiplexed stream split into stdout and stderr, stats worked out as docker stats does, and images listed and pulled.3 views · 9 downloadsnginx configuration nginx_conf.qwer · 1.0.0nginx configuration parsed as nginx's own reader does, quotes, `${var}`, escapes and comments, printed back, includes followed with globs. Servers, locations and upstreams listed, which block a host and path reach by nginx's order, and the usual mistakes: the alias slash trap, add_header dropping the parent's, $uri in redirects, `if` in a location, old TLS, duplicate names.4 views · 9 downloadsThread 0 posts
On Dockerfiles: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.