Some checks failed
ci / standards-gate (push) Has been cancelled
Adopt the org engineering standard (its-consulting/standards @ v1.0.0).
Adds baseline governance/CI/policy files rendered from the standard's
templates and pins .standards-version=1.0.0. Vendored OPA/Rego policies
under .standards/policies/ so CI runs the gate locally (no cross-repo dep).
Placeholders ({{ORG}}/{{REPO}}/{{OWNER_HANDLE}}/{{MAINTAINER_EMAIL}}) filled in.
Existing files that differ were left untouched by the adopter.
Automated rollout. Files created: 15.
177 lines
5.3 KiB
Rego
177 lines
5.3 KiB
Rego
# Containerfile / Dockerfile hardening for the mypods image builds.
|
|
#
|
|
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
|
|
# docs/adr/0008-container-runtime-podman-quadlet.md
|
|
# SOP: sops/SOP-002-release-process.md (images are built then published)
|
|
# Input: a parsed Containerfile as an ordered list of instructions:
|
|
# {
|
|
# "instructions": [
|
|
# {"cmd": "FROM", "value": "archlinux:latest"},
|
|
# {"cmd": "ARG", "value": "API_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx"},
|
|
# {"cmd": "USER", "value": "app"},
|
|
# {"cmd": "RUN", "value": "pacman -Syu"}
|
|
# ]
|
|
# }
|
|
# `cmd` is the instruction; `value` is the remainder of the line.
|
|
# (A frontend such as `dockerfile_parse` or a small awk shim produces this shape;
|
|
# conftest's built-in Dockerfile parser yields a compatible `Cmd`/`Value` form,
|
|
# handled by the accessor helpers below.)
|
|
#
|
|
# Rules:
|
|
# deny - a secret-looking value baked into ARG or ENV
|
|
# warn - a FROM with an unpinned/moving base tag (no tag, or :latest)
|
|
# warn - the final effective user is root (no non-root USER set)
|
|
package standards.containerfile.hardening
|
|
|
|
import rego.v1
|
|
|
|
import data.standards.lib
|
|
|
|
# _is_containerfile gates every rule below so this package only fires on inputs that
|
|
# are actually parsed Containerfiles. The expected shape carries an `instructions`
|
|
# ARRAY (our {cmd,value} list, or conftest's Dockerfile parser output). Without this
|
|
# guard, an unrelated YAML object (e.g. a k8s manifest with no `instructions`) would
|
|
# spuriously trip the "never sets a non-root USER" warn under conftest --all-namespaces.
|
|
_is_containerfile if {
|
|
is_array(input.instructions)
|
|
}
|
|
|
|
# Normalise instructions: accept either our {cmd,value} shape or conftest's
|
|
# {Cmd, Value:[...]} Dockerfile-parser shape. Keyed by index to preserve order.
|
|
instructions[i] := inst if {
|
|
some i
|
|
raw := input.instructions[i]
|
|
inst := {"cmd": _cmd_of(raw), "value": _value_of(raw)}
|
|
}
|
|
|
|
_cmd_of(raw) := c if {
|
|
raw.cmd
|
|
c := upper(raw.cmd)
|
|
}
|
|
|
|
_cmd_of(raw) := c if {
|
|
not raw.cmd
|
|
c := upper(raw.Cmd)
|
|
}
|
|
|
|
_value_of(raw) := v if {
|
|
is_string(raw.value)
|
|
v := raw.value
|
|
}
|
|
|
|
_value_of(raw) := v if {
|
|
not raw.value
|
|
is_array(raw.Value)
|
|
v := concat(" ", raw.Value)
|
|
}
|
|
|
|
_value_of(raw) := v if {
|
|
not raw.value
|
|
is_string(raw.Value)
|
|
v := raw.Value
|
|
}
|
|
|
|
# --- Secrets baked into the image (ARG / ENV) -------------------------------
|
|
|
|
deny contains msg if {
|
|
_is_containerfile
|
|
some i
|
|
inst := instructions[i]
|
|
{"ARG", "ENV"}[inst.cmd]
|
|
parts := split(inst.value, "=")
|
|
count(parts) >= 2
|
|
val := concat("=", array.slice(parts, 1, count(parts)))
|
|
lib.looks_like_secret_value(trim_space(val))
|
|
msg := sprintf("%v sets '%v' to a secret-looking value; never bake secrets into an image layer (SOP-005)", [inst.cmd, parts[0]])
|
|
}
|
|
|
|
# Also catch a secret-NAMED ARG/ENV with a non-placeholder literal value.
|
|
deny contains msg if {
|
|
_is_containerfile
|
|
some i
|
|
inst := instructions[i]
|
|
{"ARG", "ENV"}[inst.cmd]
|
|
parts := split(inst.value, "=")
|
|
count(parts) >= 2
|
|
key := parts[0]
|
|
lib.looks_like_secret_name(key)
|
|
val := trim_space(concat("=", array.slice(parts, 1, count(parts))))
|
|
not lib.is_placeholder(val)
|
|
val != ""
|
|
msg := sprintf("%v '%v' is secret-named with an inline value; pass secrets at runtime, not in the image (SOP-005)", [inst.cmd, key])
|
|
}
|
|
|
|
# --- Unpinned base image -----------------------------------------------------
|
|
|
|
warn contains msg if {
|
|
_is_containerfile
|
|
some i
|
|
inst := instructions[i]
|
|
inst.cmd == "FROM"
|
|
base := _from_image(inst.value)
|
|
parts := lib.split_image(base)
|
|
lib.is_mutable_tag(parts.tag)
|
|
msg := sprintf("FROM '%v' uses an unpinned/moving base tag '%v'; pin to a SemVer or digest for reproducible builds", [base, parts.tag])
|
|
}
|
|
|
|
# --- Running as root ---------------------------------------------------------
|
|
|
|
# Warn when no non-root USER is ever declared: the build runs as root by default.
|
|
warn contains msg if {
|
|
_is_containerfile
|
|
not has_nonroot_user
|
|
msg := "Containerfile never sets a non-root USER; the image runs as root by default — add `USER <non-root>` where the workload allows (ADR-0008)"
|
|
}
|
|
|
|
# Warn when the LAST USER instruction puts the runtime user back to root.
|
|
warn contains msg if {
|
|
_is_containerfile
|
|
last_user := _last_user
|
|
last_user != ""
|
|
lower(last_user) == "root"
|
|
msg := "the final USER in the Containerfile is root; drop privileges before the image's default command runs (ADR-0008)"
|
|
}
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
|
|
# _from_image(value) strips an `AS <stage>` suffix and `--platform=` flags from a FROM line.
|
|
_from_image(value) := img if {
|
|
toks := split(trim_space(value), " ")
|
|
img := _first_non_flag(toks)
|
|
}
|
|
|
|
_first_non_flag(toks) := t if {
|
|
some i
|
|
t := toks[i]
|
|
not startswith(t, "--")
|
|
|
|
# the image ref is the first non-flag token
|
|
count([x | some j; x := toks[j]; j < i; not startswith(x, "--")]) == 0
|
|
}
|
|
|
|
# user_instructions: every USER value, keyed by its instruction index.
|
|
user_instructions[idx] := val if {
|
|
some idx
|
|
inst := instructions[idx]
|
|
inst.cmd == "USER"
|
|
val := trim_space(inst.value)
|
|
}
|
|
|
|
has_nonroot_user if {
|
|
some idx
|
|
val := user_instructions[idx]
|
|
lower(val) != "root"
|
|
val != ""
|
|
}
|
|
|
|
# _last_user returns the value of the last USER instruction, or "" if none.
|
|
_last_user := val if {
|
|
idxs := [i | some i; user_instructions[i]]
|
|
count(idxs) > 0
|
|
max_idx := max(idxs)
|
|
val := user_instructions[max_idx]
|
|
}
|
|
|
|
_last_user := "" if {
|
|
count([i | some i; user_instructions[i]]) == 0
|
|
}
|