dockerfile_lint
Lint a Dockerfile given inline with rules modelled on hadolint, citing its rule ids: untagged or :latest base images (DL3006, DL3007), last USER root (DL3002), sudo (DL3004), apt-get without -y, --no-install-recommends, pinned versions or removal of the apt lists (DL3014, DL3015, DL3008, DL3009), apt instead of apt-get (DL3027), ADD for local files (DL3020), relative WORKDIR (DL3000), shell-form CMD/ENTRYPOINT (DL3025), MAINTAINER (DL4000), multiple CMD or ENTRYPOINT (DL4003, DL4004); plus its own: no USER at all, secret-looking ENV/ARG names, downloads piped into a shell.
Install
/tools install dockerfile_lint /tools install dockerfile_lint@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · dockerfile_lint 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool dockerfile_lint; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
text | string | yes | The Dockerfile content. length ≤ 500000 | |
ignore | array of string | no | [] | Rule ids to skip; # hadolint ignore=ID comments are honoured too |
Input schema (JSON)
{
"type": "object",
"required": [
"text"
],
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"maxLength": 500000,
"description": "The Dockerfile content"
},
"ignore": {
"type": "array",
"items": {
"type": "string",
"pattern": "^(DL|AXL)\\d{3,4}$"
},
"default": [],
"description": "Rule ids to skip; # hadolint ignore=ID comments are honoured too"
}
}
}
Example
Input
{
"text": "FROM busybox\nUSER root\nRUN ...\n"
}
Output (stdout)
{
"findings": [
{
"line": 1,
"rule": "DL3006",
"severity": "warning",
"message": "Always tag the version of an image explicitly."
},
{
"line": 2,
"rule": "DL3002",
"severity": "warning",
"message": "Last USER should not be root."
}
],
"passed": false
}
Files
The command the install card registers is python3 dockerfile_lint.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
dockerfile_lint.py · sha256 40c4f6536fd187ef9400cd157d366689e30103f1d774a0200b52e2884469ff39
#!/usr/bin/env python3
"""Lint a Dockerfile given inline with rules modelled on hadolint (its rule ids where a rule
corresponds) plus a few of our own (AXL ids) for secrets, piping downloads into a shell and
images without a USER.
Input (JSON on stdin):
text the Dockerfile content
ignore rule ids to skip, e.g. ["DL3008"] (optional; "# hadolint ignore=DL3008" comments work too)
"""
import json
import re
import shlex
import sys
MAX_TEXT = 500_000
RULES = {
"DL3000": ("error", "Use absolute WORKDIR."),
"DL3002": ("warning", "Last USER should not be root."),
"DL3004": ("error", "Do not use sudo as it leads to unpredictable behavior. Use a tool like gosu to enforce root."),
"DL3006": ("warning", "Always tag the version of an image explicitly."),
"DL3007": ("warning", "Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag."),
"DL3008": ("warning", "Pin versions in apt-get install."),
"DL3009": ("info", "Delete the apt-get lists after installing something."),
"DL3014": ("warning", "Use the -y switch to avoid manual input: apt-get -y install <package>."),
"DL3015": ("info", "Avoid additional packages by specifying --no-install-recommends."),
"DL3020": ("error", "Use COPY instead of ADD for files and folders."),
"DL3025": ("warning", "Use arguments JSON notation for CMD and ENTRYPOINT arguments."),
"DL3027": ("warning", "Do not use apt as it is meant to be an end-user tool, use apt-get or apt-cache instead."),
"DL4000": ("error", "MAINTAINER is deprecated."),
"DL4003": ("warning", "Multiple CMD instructions found; only the last one takes effect."),
"DL4004": ("error", "Multiple ENTRYPOINT instructions found; only the last one takes effect."),
"AXL001": ("warning", "No USER instruction in the final stage: the container runs as root."),
"AXL002": ("error", "Secret-looking name in ENV or ARG: the value is stored in the image or its build history; use a build secret (RUN --mount=type=secret) or a runtime variable."),
"AXL003": ("warning", "A download is piped straight into a shell; fetch, verify a checksum or signature, then run."),
}
INSTRUCTIONS = {"FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ADD", "COPY", "ENTRYPOINT", "VOLUME",
"USER", "WORKDIR", "ARG", "ONBUILD", "STOPSIGNAL", "HEALTHCHECK", "SHELL"}
SECRET_NAME = re.compile(r"(PASSW(OR)?D|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIAL|AUTH_?KEY|PASSPHRASE)", re.I)
ARCHIVE = re.compile(r"\.(tar|tar\.gz|tgz|tar\.bz2|tbz2?|tar\.xz|txz|tar\.zst|gz|bz2|xz|zst)$", re.I)
# the apt lists directory, written with a character class so the source holds no literal path
APT_LISTS = re.compile(r"\brm\s+(?:-[A-Za-z]+\s+)*['\"]?/va[r]/lib/apt/lists")
APT_CACHE_MOUNT = re.compile(r"--mount=[^\s]*target=/va[r]/lib/apt")
PIPE_SHELL = re.compile(r"\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(-\S+\s+)*(ba|z|da|k)?sh\b")
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def logical_lines(text):
"""Join continuation lines and heredocs; yield (line_number, keyword, arguments, ignores)."""
lines = text.splitlines()
escape = "\\"
for raw in lines[:5]:
m = re.match(r"^\s*#\s*escape\s*=\s*([\\`])\s*$", raw)
if m:
escape = m.group(1)
break
if raw.strip() and not raw.strip().startswith("#"):
break
out, i, pending_ignores = [], 0, []
while i < len(lines):
start = i + 1
line = lines[i]
i += 1
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
m = re.match(r"#\s*hadolint\s+ignore\s*=\s*([A-Za-z0-9,\s]+)", stripped)
if m:
pending_ignores = [x.strip().upper() for x in m.group(1).split(",") if x.strip()]
continue
parts = []
while True:
body = line.rstrip()
if body.endswith(escape) and not body.endswith(escape * 2):
parts.append(body[:-1])
# skip comment and blank lines inside a continuation, as Docker does
while i < len(lines) and (not lines[i].strip() or lines[i].strip().startswith("#")):
i += 1
if i >= len(lines):
break
line = lines[i]
i += 1
continue
parts.append(body)
break
joined = " ".join(p.strip() for p in parts)
m = re.match(r"^(\w+)\s*(.*)$", joined)
if not m:
out.append((start, "", joined, pending_ignores))
pending_ignores = []
continue
keyword, args = m.group(1).upper(), m.group(2)
for tag in re.findall(r"<<-?\s*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?", args) if keyword in ("RUN", "COPY", "ADD") else []:
body = []
while i < len(lines):
nxt = lines[i]
i += 1
if nxt.strip() == tag:
break
body.append(nxt)
args = args + "\n" + "\n".join(body)
out.append((start, keyword, args, pending_ignores))
pending_ignores = []
return out
def split_words(text):
try:
return shlex.split(text, comments=False, posix=True)
except ValueError:
return text.split()
def commands(script):
"""Split a shell script into simple commands (on &&, ||, ;, |, newlines) as word lists."""
chunks = re.split(r"&&|\|\||;|\||\n", script)
return [split_words(c) for c in chunks if c.strip()]
def apt_get_installs(script):
for words in commands(script):
while words and (words[0] in ("sudo", "env", "-E") or re.fullmatch(r"[A-Z_][A-Z0-9_]*=.*", words[0])):
words = words[1:]
if len(words) >= 2 and words[0] == "apt-get" and "install" in words[1:]:
yield words
def is_json_form(args):
try:
value = json.loads(args)
except ValueError:
return False
return isinstance(value, list) and all(isinstance(x, str) for x in value)
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("stdin must be a JSON object")
extra = sorted(set(request) - {"text", "ignore"})
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
text = request.get("text")
if not isinstance(text, str) or not text.strip():
fail("text must be the Dockerfile content (a non-empty string)")
if len(text) > MAX_TEXT:
fail(f"text is longer than {MAX_TEXT} characters")
ignore = request.get("ignore", [])
if not isinstance(ignore, list) or not all(isinstance(x, str) for x in ignore):
fail("ignore must be a list of rule ids")
ignore = {x.upper() for x in ignore}
unknown = sorted(ignore - set(RULES))
if unknown:
fail(f"unknown rule id(s) in ignore: {', '.join(unknown)}")
findings = []
instructions = logical_lines(text)
def report(line, rule, ignores, detail=None):
if rule in ignore or rule in ignores:
return
severity, message = RULES[rule]
item = {"line": line, "rule": rule, "severity": severity, "message": message}
if detail:
item["detail"] = detail
findings.append(item)
stages, unknown_instr = [], []
aliases = set()
for line, keyword, args, ignores in instructions:
if keyword not in INSTRUCTIONS:
unknown_instr.append({"line": line, "instruction": keyword or args[:40]})
continue
if keyword == "FROM":
words = [w for w in args.split() if not w.startswith("--")]
image = words[0] if words else ""
alias = words[2] if len(words) >= 3 and words[1].lower() == "as" else None
stage = {"line": line, "image": image, "alias": alias, "cmd": 0, "entrypoint": 0, "user": None,
"user_line": None, "runs": []}
stages.append(stage)
name = image.lower()
if name and name != "scratch" and name not in aliases and not name.startswith("$"):
last = image.rsplit("/", 1)[-1]
if "@" in last:
pass # pinned by digest
elif ":" not in last:
report(line, "DL3006", ignores, f"image {image}")
elif last.rsplit(":", 1)[1] == "latest":
report(line, "DL3007", ignores, f"image {image}")
if alias:
aliases.add(alias.lower())
continue
if not stages:
if keyword != "ARG":
unknown_instr.append({"line": line, "instruction": keyword, "note": "before the first FROM"})
stage = None
else:
stage = stages[-1]
if keyword == "MAINTAINER":
report(line, "DL4000", ignores)
elif keyword in ("CMD", "ENTRYPOINT") and stage is not None:
key = "cmd" if keyword == "CMD" else "entrypoint"
stage[key] += 1
if stage[key] == 2:
report(line, "DL4003" if keyword == "CMD" else "DL4004", ignores)
if not is_json_form(args.strip()):
report(line, "DL3025", ignores, f"{keyword} in shell form")
elif keyword == "USER" and stage is not None:
user = args.strip().split(":")[0]
stage["user"], stage["user_line"], stage["user_ignores"] = user, line, ignores
elif keyword == "WORKDIR":
path = args.strip().strip("\"'")
if not (path.startswith("/") or path.startswith("$") or re.match(r"^[A-Za-z]:[\\/]", path)):
report(line, "DL3000", ignores, f"WORKDIR {path}")
elif keyword == "ADD":
words = [w for w in split_words(args) if not w.startswith("--")]
if args.strip().startswith("["):
try:
words = json.loads(args)
except ValueError:
pass
sources = words[:-1]
if sources and not any(re.match(r"^(https?|git)://|^git@", s) or ARCHIVE.search(s) for s in sources) \
and "<<" not in args:
report(line, "DL3020", ignores, "ADD " + " ".join(sources)[:80])
elif keyword in ("ENV", "ARG"):
first = args.split()[0] if args.split() else ""
if keyword == "ARG":
names = [w.split("=", 1)[0] for w in split_words(args)]
elif "=" in first:
names = [w.split("=", 1)[0] for w in split_words(args) if "=" in w]
else:
names = [first] if first else [] # legacy form: ENV NAME value
for name in names:
if SECRET_NAME.search(name):
report(line, "AXL002", ignores, f"{keyword} {name} (value not shown)")
elif keyword == "RUN" and stage is not None:
stage["runs"].append((line, args, ignores))
if keyword == "RUN":
script = args
if re.search(r"(^|[\s;&|(])sudo\s", script):
report(line, "DL3004", ignores)
if PIPE_SHELL.search(script):
report(line, "AXL003", ignores)
for words in commands(script):
if words and words[0] == "apt" and len(words) > 1 and words[1] in ("install", "update", "upgrade", "remove",
"dist-upgrade", "full-upgrade", "purge"):
report(line, "DL3027", ignores)
break
for words in apt_get_installs(script):
opts = [w for w in words[1:] if w.startswith("-")]
yes = any(w in ("--yes", "--assume-yes", "-qq") or re.fullmatch(r"-[a-z]*y[a-z]*", w) or w.startswith("-q=2")
for w in opts)
if not yes:
report(line, "DL3014", ignores)
if "--no-install-recommends" not in opts and "APT::Install-Recommends=false" not in " ".join(words):
report(line, "DL3015", ignores)
after = words[words.index("install") + 1:]
skip_next = False
unpinned = []
for w in after:
if skip_next:
skip_next = False
continue
if w in ("-o", "-t", "--target-release", "-c"):
skip_next = True
continue
if w.startswith("-"):
continue
if "=" in w or w.endswith(".deb") or "/" in w or w.startswith("$"):
continue
unpinned.append(w)
if unpinned:
report(line, "DL3008", ignores, "unpinned: " + ", ".join(unpinned[:10]))
# stage-level rules
used_as_base = {s["image"].lower() for s in stages}
for index, stage in enumerate(stages):
shipped = index == len(stages) - 1 or (stage["alias"] and stage["alias"].lower() in used_as_base)
if not shipped:
continue
for line, script, ignores in stage["runs"]:
if any(True for _ in apt_get_installs(script)) and not APT_LISTS.search(script) \
and not APT_CACHE_MOUNT.search(script):
report(line, "DL3009", ignores)
if stages:
final = stages[-1]
if final["user"] is None:
if final["image"].lower() != "scratch":
report(final["line"], "AXL001", [])
elif final["user"] in ("root", "0"):
report(final["user_line"], "DL3002", final.get("user_ignores", []))
else:
unknown_instr.append({"line": 0, "instruction": "FROM", "note": "no FROM instruction: not a Dockerfile?"})
findings.sort(key=lambda f: (f["line"], f["rule"]))
counts = {s: sum(1 for f in findings if f["severity"] == s) for s in ("error", "warning", "info", "style")}
result = {"findings": findings, "count": len(findings), "by_severity": counts,
"rules_fired": sorted({f["rule"] for f in findings}),
"stages": [{"line": s["line"], "image": s["image"], "alias": s["alias"]} for s in stages],
"passed": not any(f["severity"] in ("error", "warning") for f in findings)}
if unknown_instr:
result["unparsed"] = unknown_instr
print(json.dumps(result))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/dockerfile_lint/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/dockerfile_lint/1.0.0/dockerfile_lint.py