chore: adopt engineering standard v1.0.0
Some checks failed
ci / standards-gate (pull_request) Has been cancelled
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.
This commit is contained in:
Kenearos 2026-07-01 20:26:58 +02:00
parent 70205b6b93
commit 015c0c5378
25 changed files with 1846 additions and 0 deletions

View file

@ -0,0 +1,71 @@
# Standard adoption by pin: a consuming repo must declare WHICH version of this
# standards bundle it has adopted, so drift between repos is visible and pinned.
#
# Enforces: docs/adr/0007-adopt-by-pinned-version.md
# SOP: sops/SOP-004-environment-setup.md (scripts/adopt-standard.sh writes the pin)
# Input: repo metadata carrying the adopted standards version:
# {
# "repo": "mypods/api",
# "standards_version": "v1.4.0",
# "current_version": "v1.6.0" # optional: latest published standards version
# }
#
# Rules:
# deny - `standards_version` is missing or not valid SemVer (vX.Y.Z)
# warn - the pinned version is behind the provided `current_version`
package standards.ci.adoption
import rego.v1
import data.standards.lib
# Only evaluate inputs that are actually adoption descriptors. An adoption descriptor
# is one that carries the pin field, OR a repo-metadata object that is expected to
# carry it. We gate on `standards_version` / `repo` so unrelated inputs
# (k8s/compose/Quadlet) stay silent under conftest --all-namespaces.
_is_adoption_input if {
lib.has_key(input, "standards_version")
}
_is_adoption_input if {
lib.has_key(input, "repo")
}
# --- The pin must be present and valid SemVer -------------------------------
deny contains msg if {
_is_adoption_input
not lib.has_key(input, "standards_version")
msg := "repo declares no 'standards_version'; pin the adopted standards bundle to a SemVer 'vX.Y.Z' (ADR-0007, SOP-004)"
}
deny contains msg if {
_is_adoption_input
lib.has_key(input, "standards_version")
not lib.is_semver(input.standards_version)
msg := sprintf("standards_version '%v' is not valid SemVer 'vX.Y.Z'; adopt by pinning an immutable release (ADR-0007)", [input.standards_version])
}
# --- Advisory: the pin is behind the current published version --------------
warn contains msg if {
lib.has_key(input, "current_version")
lib.is_semver(input.standards_version)
lib.is_semver(input.current_version)
input.standards_version != input.current_version
semver.compare(_strip_v(input.standards_version), _strip_v(input.current_version)) < 0
msg := sprintf("standards_version '%v' is behind current '%v'; schedule an update via scripts/adopt-standard.sh (ADR-0007, SOP-004)", [input.standards_version, input.current_version])
}
# --- helpers ----------------------------------------------------------------
# _strip_v drops the leading `v` so OPA's semver.compare (which expects a bare
# SemVer core) can order the two pins.
_strip_v(tag) := out if {
startswith(tag, "v")
out := substring(tag, 1, -1)
}
_strip_v(tag) := tag if {
not startswith(tag, "v")
}

View file

@ -0,0 +1,106 @@
# Deploy gate: who is allowed to deploy WHAT, WHERE, and under WHICH conditions.
#
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
# docs/adr/0005-trunk-based-with-release-branches.md
# SOP: sops/SOP-002-release-process.md (the human release procedure delegates
# its "production deploy is gated" step to this policy)
# Input: a deploy/pipeline descriptor, e.g.
# {
# "environment": "prod", # target environment (prod|staging|dev)
# "branch": "main", # ref the pipeline runs on
# "event": "tag", # trigger event (tag|push|pull_request|manual)
# "tag": "v1.4.0", # release tag, when event == "tag"
# "review": {"approved": true, "approvals": 2},
# "actor": "alex"
# }
#
# Contract (identical for the GitHub Actions and Woodpecker adapters — see
# docs/00-overview.md §4): a production deploy is allowed ONLY from `main`, ONLY on a
# `tag` event, ONLY with an approved review. Everything else is denied with a message.
package standards.ci.deploy
import rego.v1
import data.standards.lib
# Treat these environment names as production.
prod_envs := {"prod", "production"}
is_prod if {
prod_envs[lower(input.environment)]
}
# Non-release branch prefixes that must never deploy to production.
nonrelease_prefixes := {"feature/", "feat/", "fix/", "hotfix/", "wip/", "dependabot/", "renovate/"}
# --- Production guardrails ---------------------------------------------------
# Prod deploys must originate from the trunk branch `main`.
deny contains msg if {
is_prod
input.branch != "main"
msg := sprintf("prod deploy must run on branch 'main', got '%v' (see SOP-002, ADR-0005)", [input.branch])
}
# Prod deploys must be triggered by a release tag event, not an ad-hoc push/PR/manual run.
deny contains msg if {
is_prod
input.event != "tag"
msg := sprintf("prod deploy must be triggered by a 'tag' event, got '%v' (see SOP-002)", [input.event])
}
# The release tag itself must follow the vX.Y.Z convention.
deny contains msg if {
is_prod
input.event == "tag"
not lib.is_semver(input.tag)
msg := sprintf("prod release tag '%v' is not SemVer 'vX.Y.Z' (see ADR-0005)", [input.tag])
}
# Prod deploys require a recorded, approved review.
deny contains msg if {
is_prod
not review_approved
msg := "prod deploy requires an approved review (review.approved == true)"
}
review_approved if {
input.review.approved == true
}
# Feature/throwaway branches must never reach production, regardless of other fields.
deny contains msg if {
is_prod
some prefix in nonrelease_prefixes
startswith(input.branch, prefix)
msg := sprintf("branch '%v' may not deploy to prod (non-release branch prefix)", [input.branch])
}
# --- Staging guardrails (lighter) -------------------------------------------
# Staging may deploy from main or develop, but never from a pull_request event
# (PR builds are untrusted and must not push to a shared environment).
deny contains msg if {
lower(input.environment) == "staging"
input.event == "pull_request"
msg := "staging deploy must not run on a 'pull_request' event (untrusted ref)"
}
# --- Advisory ---------------------------------------------------------------
# Warn when a prod deploy is approved by a single reviewer; SOP-002 recommends >=2.
warn contains msg if {
is_prod
review_approved
count_approvals < 2
msg := sprintf("prod deploy approved by only %v reviewer(s); SOP-002 recommends >= 2", [count_approvals])
}
count_approvals := n if {
n := input.review.approvals
}
count_approvals := 1 if {
not input.review.approvals
input.review.approved == true
}

View file

@ -0,0 +1,78 @@
# Image provenance: every image a pipeline pulls or deploys must come from a trusted
# registry and carry an immutable, convention-conformant tag.
#
# 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 (release publishes `image:vX.Y.Z`)
# Input: a descriptor carrying one or more image references plus the target env:
# {
# "environment": "prod",
# "images": ["ghcr.io/acme/api:v1.4.0", "docker.io/library/redis:7.2.4"]
# }
# A single image may also be given as: { "image": "ghcr.io/acme/api:v1.4.0", ... }.
#
# Rules:
# deny - image from a registry not on the allow-list (standards.lib.trusted_registries)
# deny - image tag is neither SemVer vX.Y.Z nor a digest/sha pin
# deny - moving tag (`latest`, `edge`, ...) used in a production environment
# warn - moving tag used in a non-prod environment (tolerated, flagged)
package standards.ci.image
import rego.v1
import data.standards.lib
prod_envs := {"prod", "production"}
is_prod if {
prod_envs[lower(input.environment)]
}
# Normalise: collect every image reference whether given as `images[]` or `image`.
images contains ref if {
ref := input.images[_]
}
images contains ref if {
ref := input.image
is_string(ref)
}
# --- Provenance: trusted registry only --------------------------------------
deny contains msg if {
some ref in images
not lib.trusted_registry(ref)
parts := lib.split_image(ref)
msg := sprintf("image '%v' comes from untrusted registry '%v' (allow-list: %v)", [ref, parts.registry, lib.trusted_registries])
}
# --- Tag convention: pinned (SemVer or digest) ------------------------------
deny contains msg if {
some ref in images
parts := lib.split_image(ref)
not lib.is_pinned_tag(parts.tag)
# `latest` gets its own, clearer message below; don't double-report it here.
not lib.is_mutable_tag(parts.tag)
msg := sprintf("image '%v' tag '%v' is not a pinned reference (expected vX.Y.Z or a sha digest)", [ref, parts.tag])
}
# --- Moving tags: hard-deny in prod, warn elsewhere -------------------------
deny contains msg if {
is_prod
some ref in images
parts := lib.split_image(ref)
lib.is_mutable_tag(parts.tag)
msg := sprintf("image '%v' uses moving tag '%v' in a production environment (pin to vX.Y.Z or a digest)", [ref, parts.tag])
}
warn contains msg if {
not is_prod
some ref in images
parts := lib.split_image(ref)
lib.is_mutable_tag(parts.tag)
msg := sprintf("image '%v' uses moving tag '%v'; acceptable in '%v' but pin before promoting to prod", [ref, parts.tag, input.environment])
}

View file

@ -0,0 +1,66 @@
# Pipeline contract: both CI adapters (GitHub Actions and Woodpecker) must implement
# the SAME canonical stage sequence, so a pipeline is portable between them.
#
# Enforces: docs/adr/0002-dual-target-ci.md
# SOP: sops/SOP-001-branch-and-merge.md (the merge gate runs this contract)
# Input: a pipeline descriptor listing the stages it defines:
# {
# "ci": "github",
# "stages": ["static-checks", "test", "build", "policy_check",
# "security-scan", "publish", "deploy-staging"]
# }
# Stage names may use the adapter's own spelling; `deploy` is satisfied by either
# `deploy-staging` or `deploy-prod`.
#
# Rules:
# deny - any REQUIRED canonical stage is missing from the descriptor
package standards.ci.pipeline
import rego.v1
# Only evaluate inputs that are actually pipeline descriptors (carry a `stages` list).
# Keeps the package silent under conftest --all-namespaces for unrelated inputs.
_is_pipeline_input if {
is_array(input.stages)
}
# The canonical, ordered set of stages every pipeline must implement. `deploy` is a
# logical stage satisfied by a concrete deploy-staging or deploy-prod stage.
required_stages := ["static-checks", "test", "build", "policy_check", "security-scan", "publish", "deploy"]
# The set of stage names the descriptor actually declares, lower-cased.
declared_stages contains s if {
some raw in input.stages
is_string(raw)
s := lower(raw)
}
# --- A required stage is missing --------------------------------------------
deny contains msg if {
_is_pipeline_input
some required in required_stages
not _stage_satisfied(required)
msg := sprintf("pipeline is missing required stage '%v'; both CI adapters must implement the canonical contract %v (ADR-0002, SOP-001)", [required, required_stages])
}
# --- helpers ----------------------------------------------------------------
# _stage_satisfied(req) is true when the descriptor declares the required stage.
# The logical `deploy` stage is satisfied by deploy-staging OR deploy-prod.
_stage_satisfied(req) if {
req != "deploy"
declared_stages[req]
}
_stage_satisfied("deploy") if {
declared_stages["deploy-staging"]
}
_stage_satisfied("deploy") if {
declared_stages["deploy-prod"]
}
_stage_satisfied("deploy") if {
declared_stages["deploy"]
}

View file

@ -0,0 +1,72 @@
# Secrets hygiene in pipeline / runtime environment blocks.
#
# Enforces: docs/adr/0003-secrets-sops-age-fido2.md
# docs/adr/0004-policy-as-code-opa-conftest.md
# SOP: sops/SOP-005-secrets-management.md (no plaintext secrets; SOPS+age + EnvironmentFile)
# Input: a descriptor with an environment map and optional metadata:
# {
# "environment": "staging",
# "env": { "LOG_LEVEL": "info", "API_TOKEN": "${API_TOKEN}" },
# "env_files": ["%h/.config/mypods/api.env"],
# "env_origin": { "DB_PASSWORD": "prod" } # optional: which env a secret was sourced from
# }
#
# Rules:
# deny - a secret-NAMED env var whose VALUE is an inlined plaintext secret
# (not a ${VAR}/%VAR% reference and not empty)
# deny - any env VALUE that matches a known secret shape (PEM key, token prefix)
# deny - a secret sourced from a 'prod' origin used in a non-prod ('staging'/'dev') env
# warn - secret-named vars present but no EnvironmentFile declared (should use SOPS+age file)
package standards.ci.secrets
import rego.v1
import data.standards.lib
prod_envs := {"prod", "production"}
is_prod if {
prod_envs[lower(input.environment)]
}
env := object.get(input, "env", {})
env_files := object.get(input, "env_files", [])
# --- Plaintext secret in a secret-named variable ----------------------------
deny contains msg if {
some k, val in env
lib.looks_like_secret_name(k)
is_string(val)
not lib.is_placeholder(val)
msg := sprintf("env '%v' looks like a secret but holds an inline value; reference it via SOPS+age/EnvironmentFile instead (SOP-005)", [k])
}
# --- Value that matches a known secret shape, regardless of the var name -----
deny contains msg if {
some k, val in env
lib.looks_like_secret_value(val)
not lib.is_placeholder(val)
msg := sprintf("env '%v' value matches a secret pattern (private key / token); never commit plaintext secrets (SOP-005)", [k])
}
# --- Cross-environment secret bleed: prod secret used in a lower env ---------
deny contains msg if {
not is_prod
origin := object.get(input, "env_origin", {})
some k, src in origin
lower(src) == "prod"
msg := sprintf("env '%v' is sourced from a PROD secret but used in '%v'; environments must not share secret material (ADR-0006, SOP-005)", [k, input.environment])
}
# --- Advisory: secret-named vars but no EnvironmentFile pattern --------------
warn contains msg if {
some k, _ in env
lib.looks_like_secret_name(k)
count(env_files) == 0
msg := sprintf("env '%v' is secret-shaped but no EnvironmentFile is declared; load secrets from a SOPS-decrypted env file (SOP-005)", [k])
}

View file

@ -0,0 +1,136 @@
# Tenant (Mandant) separation: a deploy/manifest artifact belonging to one Mandant
# must never reference another Mandant's resources, paths, or age recipients.
#
# Enforces: docs/adr/0006-multi-tenant-separation.md
# SOP: sops/SOP-005-secrets-management.md (per-Mandant SOPS+age key sets)
# Input: a deploy/manifest descriptor carrying the owning Mandant and the
# resources it references:
# {
# "mandant": "gmbh-a",
# "resources": ["secrets-gmbh-a/db.env", "ops-gmbh-a/deploy.yml"],
# "paths": ["/srv/gmbh-a/data"],
# "age_recipients": ["age1aaa...gmbh-a-key"]
# }
# `resources` and `paths` are referenced artifact paths; `age_recipients` are the
# age public keys a SOPS-encrypted secret is sealed to.
#
# Model: every Mandant owns a namespace token `gmbh-<x>`. A reference is cross-tenant
# when it names ANOTHER Mandant's namespace (e.g. a `gmbh-b` path in a `gmbh-a`
# artifact) or an age recipient that is not in this Mandant's allowed recipient set.
#
# Rules (default-deny cross-tenant):
# deny - the artifact declares no Mandant (cannot be attributed -> reject)
# deny - a referenced resource/path names a foreign Mandant namespace
# deny - an age recipient is not on this Mandant's allowed set
package standards.ci.tenant
import rego.v1
import data.standards.lib
# Only evaluate inputs that are actually tenant descriptors (carry a Mandant marker).
# This keeps the package quiet under conftest --all-namespaces for unrelated inputs
# (k8s/compose/Quadlet objects), while still default-denying an unattributed artifact
# that DOES look like a tenant descriptor (has resources/paths/age_recipients).
_is_tenant_input if {
lib.has_key(input, "mandant")
}
_is_tenant_input if {
lib.has_key(input, "resources")
}
_is_tenant_input if {
lib.has_key(input, "age_recipients")
}
# All known Mandant namespace tokens. Grounded in ADR-0006's example tenants; the
# set is the universe of foreign namespaces a reference may accidentally point at.
known_mandanten := {"gmbh-a", "gmbh-b"}
# The owning Mandant of this artifact, lower-cased, or "" when absent/empty.
# object.get with a default keeps this total even when the key is missing entirely,
# so the `mandant == ""` default-deny below fires for an unattributed artifact.
mandant := m if {
raw := object.get(input, "mandant", "")
lib.non_empty_string(raw)
m := lower(raw)
}
mandant := "" if {
raw := object.get(input, "mandant", "")
not lib.non_empty_string(raw)
}
# Allowed age recipients for the owning Mandant (from input.allowed_age_recipients,
# keyed by Mandant). When the artifact provides no allow-list we cannot vouch for any
# recipient, so every declared recipient is treated as foreign (default-deny).
allowed_recipients := r if {
all := object.get(input, "allowed_age_recipients", {})
r := object.get(all, mandant, [])
}
# Every referenced path/resource string, collected from the supported fields.
references contains ref if {
ref := object.get(input, "resources", [])[_]
}
references contains ref if {
ref := object.get(input, "paths", [])[_]
}
# --- Default-deny: an unattributed artifact ---------------------------------
deny contains msg if {
_is_tenant_input
mandant == ""
msg := "artifact declares no 'mandant'; cross-tenant separation cannot be enforced — every Mandant artifact must be attributed (ADR-0006, SOP-005)"
}
# --- Cross-tenant reference: a foreign Mandant namespace --------------------
deny contains msg if {
_is_tenant_input
mandant != ""
some ref in references
is_string(ref)
some foreign in known_mandanten
foreign != mandant
_references_namespace(ref, foreign)
msg := sprintf("mandant '%v' artifact references foreign tenant resource '%v' (matches '%v'); tenants must not share resources (ADR-0006, SOP-005)", [mandant, ref, foreign])
}
# --- Cross-tenant age recipient: not on this Mandant's allowed set ----------
deny contains msg if {
_is_tenant_input
mandant != ""
some rcpt in object.get(input, "age_recipients", [])
is_string(rcpt)
not _recipient_allowed(rcpt)
msg := sprintf("mandant '%v' seals a secret to age recipient '%v' which is not in its allowed recipient set; each Mandant must use only its own age keys (ADR-0006, SOP-005)", [mandant, rcpt])
}
# --- helpers ----------------------------------------------------------------
# _references_namespace(ref, ns) is true when a path/resource string carries the
# foreign Mandant namespace, either as a `secrets-<ns>` / `ops-<ns>` prefix or as a
# path segment `<ns>` anywhere in the reference.
_references_namespace(ref, ns) if {
contains(lower(ref), sprintf("secrets-%v", [ns]))
}
_references_namespace(ref, ns) if {
contains(lower(ref), sprintf("ops-%v", [ns]))
}
_references_namespace(ref, ns) if {
parts := split(lower(ref), "/")
parts[_] == ns
}
# _recipient_allowed(rcpt) is true when the age recipient is on this Mandant's set.
_recipient_allowed(rcpt) if {
some allowed in allowed_recipients
allowed == rcpt
}

View file

@ -0,0 +1,18 @@
# Conftest configuration for the `standards` Policy-as-Code package.
#
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
# Followed by: sops/SOP-002-release-process.md, sops/SOP-005-secrets-management.md
#
# Config keys mirror conftest's CLI flags (hyphenated). Conftest auto-discovers a
# `conftest.toml` in the policy directory / working directory, so running
# `conftest test <manifest>` from `policies/` picks these up with no extra flags.
#
# `all-namespaces = true` makes conftest evaluate `deny`/`warn` rules from EVERY
# Rego package it loads (standards.ci.*, standards.quadlet.*, standards.containerfile.*),
# so a single `conftest test <manifest>` exercises every relevant policy. Without it,
# conftest only looks at the `main` namespace and silently ignores our namespaced rules.
#
# `policy = "."` points conftest at this directory (run it from `policies/`), so the
# lib/, ci/, quadlet/ and containerfile/ subtrees are all discovered.
policy = "."
all-namespaces = true

View file

@ -0,0 +1,177 @@
# 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
}

View file

@ -0,0 +1,283 @@
# Shared helpers for the `standards` policy packages.
#
# Enforces (supports): docs/adr/0004-policy-as-code-opa-conftest.md
# Used by: standards.ci.*, standards.quadlet.*, standards.containerfile.*
#
# Keep this package side-effect free: ONLY pure helper functions/rules, no `deny`/`warn`.
# One concept per helper so the call sites in the policy files stay readable and testable.
#
# Written in Rego v1 (`import rego.v1`): rules use `contains`/`if`, which is what the
# installed OPA 1.x and conftest's bundled OPA require. The Conftest contract is
# unchanged — policies still expose `deny`/`warn` partial sets of message strings.
package standards.lib
import rego.v1
# ---------------------------------------------------------------------------
# Object / key helpers
# ---------------------------------------------------------------------------
# has_key(obj, k) is true when object `obj` contains key `k`.
has_key(obj, k) if {
_ := obj[k]
}
# get_default(obj, k, fallback) returns obj[k] if present, else `fallback`.
get_default(obj, k, _) := v if {
has_key(obj, k)
v := obj[k]
}
get_default(obj, k, fallback) := v if {
not has_key(obj, k)
v := fallback
}
# non_empty_string(x) is true when x is a string with length > 0.
non_empty_string(x) if {
is_string(x)
count(x) > 0
}
# ---------------------------------------------------------------------------
# String helpers
# ---------------------------------------------------------------------------
# to_lower_safe(x) lower-cases a string; passes non-strings through unchanged so
# call sites can stay defensive about heterogeneous input.
to_lower_safe(x) := out if {
is_string(x)
out := lower(x)
}
to_lower_safe(x) := out if {
not is_string(x)
out := x
}
# contains_any(haystack, needles) is true if any needle is a substring of haystack.
contains_any(haystack, needles) if {
some i
contains(haystack, needles[i])
}
# ---------------------------------------------------------------------------
# Semver / tag helpers
# ---------------------------------------------------------------------------
# is_semver(tag) matches OUR release-tag convention `vX.Y.Z` (docs/00-overview.md:
# git tags `vX.Y.Z`). Pre-release/build suffixes are allowed, e.g. v1.2.3-rc.1 or
# v1.2.3+build.5. The leading `v` is REQUIRED — this is the form the deploy gate
# enforces on a release tag. For accepting third-party pins like `redis:7.2.4`,
# use is_pinned_tag (which also accepts a bare X.Y.Z).
is_semver(tag) if {
regex.match(`^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.\-]+)?$`, tag)
}
# is_bare_semver(tag) matches a SemVer WITHOUT the leading `v`, e.g. `7.2.4`.
# Upstream images (redis, postgres, ...) tag this way; such a tag is still an
# immutable, reproducible pin even though it is not OUR `vX.Y.Z` release form.
is_bare_semver(tag) if {
regex.match(`^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.\-]+)?$`, tag)
}
# is_sha_tag(tag) matches an immutable content tag: a (short or long) hex digest,
# optionally `sha-` / `sha256-` / `sha256:` prefixed, e.g. `sha-1a2b3c4`,
# `sha256:...`, `a1b2c3d4`.
is_sha_tag(tag) if {
regex.match(`^(sha-|sha256-|sha256:)?[0-9a-f]{7,64}$`, tag)
}
# is_pinned_tag(tag) is true when the tag is an immutable, reproducible reference:
# our `vX.Y.Z` release form, a bare upstream `X.Y.Z`, or a sha digest. Never a
# moving tag like `latest`.
is_pinned_tag(tag) if {
is_semver(tag)
}
is_pinned_tag(tag) if {
is_bare_semver(tag)
}
is_pinned_tag(tag) if {
is_sha_tag(tag)
}
# is_mutable_tag(tag) flags the well-known moving tags that break reproducibility.
is_mutable_tag(tag) if {
mutable := {"latest", "edge", "main", "master", "stable", "nightly", "dev"}
mutable[lower(tag)]
}
# ---------------------------------------------------------------------------
# Image reference helpers
# ---------------------------------------------------------------------------
# split_image(ref) decomposes an image reference into {registry, repo, tag}.
# Rules (Docker/OCI semantics):
# - if the first path segment contains a "." or a ":" (or is "localhost"),
# it is the registry; otherwise the registry defaults to "docker.io".
# - the tag is the part after the LAST ":" that is not part of the registry host.
# Digest pins (`@sha256:...`) are normalised so the digest is returned as the tag.
split_image(ref) := parts if {
# Digest form: repo@sha256:hex
contains(ref, "@")
name := split(ref, "@")[0]
digest := split(ref, "@")[1]
reg := _registry_of(name)
repo := _repo_of(name, reg)
parts := {"registry": reg, "repo": repo, "tag": digest}
}
split_image(ref) := parts if {
# Tagged form: [registry/]repo:tag (no digest)
not contains(ref, "@")
reg := _registry_of(ref)
rest := _strip_registry(ref, reg)
_has_tag(rest)
repo := split(rest, ":")[0]
tag := split(rest, ":")[1]
parts := {"registry": reg, "repo": repo, "tag": tag}
}
split_image(ref) := parts if {
# Untagged form: [registry/]repo -> tag defaults to "latest" (Docker behaviour)
not contains(ref, "@")
reg := _registry_of(ref)
rest := _strip_registry(ref, reg)
not _has_tag(rest)
parts := {"registry": reg, "repo": rest, "tag": "latest"}
}
# _has_tag is true when the repo portion (registry already stripped) carries a `:tag`.
_has_tag(rest) if {
contains(rest, ":")
}
# _first_segment returns the substring before the first "/".
_first_segment(ref) := seg if {
contains(ref, "/")
seg := split(ref, "/")[0]
}
_first_segment(ref) := seg if {
not contains(ref, "/")
seg := ref
}
# _looks_like_host(seg) is true when a path segment is a registry host:
# it contains a "." (domain) or ":" (port) or equals "localhost".
_looks_like_host(seg) if {
contains(seg, ".")
}
_looks_like_host(seg) if {
contains(seg, ":")
}
_looks_like_host(seg) if {
seg == "localhost"
}
# _registry_of(ref) returns the registry host, defaulting to docker.io.
_registry_of(ref) := reg if {
seg := _first_segment(ref)
_looks_like_host(seg)
reg := seg
}
_registry_of(ref) := reg if {
seg := _first_segment(ref)
not _looks_like_host(seg)
reg := "docker.io"
}
# _strip_registry(ref, reg) removes an explicit registry prefix, leaving repo[:tag].
_strip_registry(ref, reg) := rest if {
startswith(ref, concat("", [reg, "/"]))
rest := substring(ref, count(reg) + 1, -1)
}
_strip_registry(ref, reg) := rest if {
not startswith(ref, concat("", [reg, "/"]))
rest := ref
}
# _repo_of(name, reg) returns the repo path with any registry prefix removed.
_repo_of(name, reg) := repo if {
repo := _strip_registry(name, reg)
}
# ---------------------------------------------------------------------------
# Trusted registry policy data
# ---------------------------------------------------------------------------
# trusted_registries is the allow-list of registries images may come from.
# Grounded in the ecosystem (docs/00-overview.md §3): our own GHCR namespace,
# the self-hosted Forgejo registry target, Docker Hub for vetted upstreams, and
# `mypods/*` first-party images that are built locally (registry resolves to docker.io).
trusted_registries := {
"ghcr.io",
"registry.forgejo.local",
"docker.io",
"localhost",
}
# trusted_registry(ref) is true when the image reference resolves to a registry on
# the allow-list above.
trusted_registry(ref) if {
parts := split_image(ref)
trusted_registries[parts.registry]
}
# ---------------------------------------------------------------------------
# Secret-shaped value detection
# ---------------------------------------------------------------------------
# secret_name_pattern matches env var NAMES that conventionally hold secrets.
secret_name_pattern := `(?i)(SECRET|PASSWORD|PASSWD|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|PASSPHRASE)`
# looks_like_secret_name(name) flags an env key whose name implies a secret.
looks_like_secret_name(name) if {
regex.match(secret_name_pattern, name)
}
# looks_like_secret_value(val) flags a VALUE that looks like an inlined secret:
# long high-entropy-ish strings, known token prefixes, or PEM private-key markers.
looks_like_secret_value(val) if {
is_string(val)
regex.match(`-----BEGIN [A-Z ]*PRIVATE KEY-----`, val)
}
looks_like_secret_value(val) if {
is_string(val)
# Common token prefixes: GitHub (ghp_/gho_/ghs_), Slack (xox...), AWS (AKIA...).
regex.match(`^(gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16})`, val)
}
looks_like_secret_value(val) if {
is_string(val)
# Long opaque string with no spaces -> likely a baked-in credential, not a flag.
count(val) >= 24
not contains(val, " ")
regex.match(`^[A-Za-z0-9+/=_-]{24,}$`, val)
}
# is_placeholder(val) recognises references/placeholders that are NOT plaintext
# secrets: env-substitution (${VAR}, $VAR, %VAR%), Quadlet specifiers (%h), or the
# empty string. Used to avoid false positives on indirection.
is_placeholder(val) if {
is_string(val)
regex.match(`^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$`, val)
}
is_placeholder(val) if {
is_string(val)
regex.match(`^%[A-Za-z_][A-Za-z0-9_]*%$`, val)
}
is_placeholder(val) if {
val == ""
}

View file

@ -0,0 +1,127 @@
# Quadlet hardening: lint podctl/mypods `.container` units (systemd --user Quadlet)
# for safe-by-default container settings.
#
# Enforces: docs/adr/0008-container-runtime-podman-quadlet.md
# docs/adr/0004-policy-as-code-opa-conftest.md
# SOP: sops/SOP-004-environment-setup.md (provisioning Quadlet units)
# Input: a parsed Quadlet INI as a nested object. Section headers ([Container],
# [Service], [Unit]) are top-level keys; each maps to an object of its
# directives. Directives that may legally repeat (AddDevice, GroupAdd,
# Volume, PublishPort, Environment) are arrays; single-valued directives
# are scalars. Example (from podctl preset llama-gpt-oss-120b.container):
# {
# "Container": {
# "ContainerName": "llama-gpt-oss-120b",
# "Image": "docker.io/kyuz0/amd-strix-halo-toolboxes:vulkan-radv",
# "Pull": "never",
# "AddDevice": ["/dev/kfd", "/dev/dri"],
# "GroupAdd": ["video", "render"]
# },
# "Service": { "Restart": "on-failure", "TimeoutStopSec": "30" }
# }
#
# Rules:
# deny - container is privileged (PrivilegedTrue / extreme cap grants)
# deny - no Restart= in [Service] (units must be self-healing)
# warn - User=root (rootless Podman is the target; running as root in-container is a smell)
# warn - AddDevice present without any GroupAdd (device access usually needs a group, e.g. render/video)
package standards.quadlet.security
import rego.v1
container := object.get(input, "Container", {})
service := object.get(input, "Service", {})
# _is_quadlet gates every rule below so this package only fires on inputs that are
# actually parsed Quadlet units. A Quadlet INI is recognised by the presence of a
# [Container] section (every .container unit has one) or a [Unit] section. Without
# this guard, an unrelated YAML object (e.g. a k8s manifest with no [Service].Restart)
# would spuriously trip the "missing Restart" deny under conftest --all-namespaces.
_is_quadlet if {
_has_key(input, "Container")
}
_is_quadlet if {
_has_key(input, "Unit")
}
# --- Privileged containers are forbidden ------------------------------------
deny contains msg if {
_is_quadlet
val := object.get(container, "PrivilegedTrue", "")
lower(format_int_or_string(val)) == "true"
msg := "Quadlet runs a privileged container (PrivilegedTrue=true); privileged mode is forbidden (ADR-0008)"
}
# Granting all capabilities is equivalent to privileged.
deny contains msg if {
_is_quadlet
caps := object.get(container, "AddCapability", [])
some c in _as_array(caps)
upper(c) == "ALL"
msg := "Quadlet grants AddCapability=ALL; this is equivalent to privileged and is forbidden (ADR-0008)"
}
# --- A unit must declare a restart policy ------------------------------------
deny contains msg if {
_is_quadlet
not _has_key(service, "Restart")
msg := "Quadlet [Service] has no Restart= directive; units must be self-healing (e.g. Restart=on-failure)"
}
# Restart=no defeats the purpose; treat it as a violation.
deny contains msg if {
_is_quadlet
lower(format_int_or_string(object.get(service, "Restart", ""))) == "no"
msg := "Quadlet [Service] sets Restart=no; declare a real restart policy (e.g. on-failure / always)"
}
# --- Advisory: running as root inside the container --------------------------
warn contains msg if {
_is_quadlet
lower(format_int_or_string(object.get(container, "User", ""))) == "root"
msg := "Quadlet sets User=root inside the container; prefer a non-root User= where the image allows it (ADR-0008)"
}
# --- Advisory: device access without a supplementary group -------------------
warn contains msg if {
_is_quadlet
devices := object.get(container, "AddDevice", [])
count(_as_array(devices)) > 0
groups := object.get(container, "GroupAdd", [])
count(_as_array(groups)) == 0
msg := "Quadlet uses AddDevice without any GroupAdd; device nodes (e.g. /dev/dri) usually require a supplementary group such as render/video"
}
# --- helpers ----------------------------------------------------------------
_has_key(obj, k) if {
_ := obj[k]
}
# _as_array(x) normalises a scalar-or-array directive into an array, so rules can
# iterate uniformly whether a directive appeared once or many times.
_as_array(x) := x if {
is_array(x)
}
_as_array(x) := [x] if {
not is_array(x)
}
# format_int_or_string(x) stringifies scalars (Quadlet values may be parsed as
# numbers, e.g. TimeoutStopSec=30) so `lower`/`upper` never see a non-string.
format_int_or_string(x) := out if {
is_string(x)
out := x
}
format_int_or_string(x) := out if {
not is_string(x)
out := sprintf("%v", [x])
}

58
.standards/policy-gate.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# policy-gate.sh — vendored standards policy gate.
#
# Runs the standard's Rego policies (vendored at .standards/policies/) against this
# repo's manifests with conftest. Auto-detects artifact types and applies the right
# parser + namespace so policies never cross-fire:
# *.container -> --parser ini --namespace standards.quadlet.security
# Containerfile*/Dockerfile* -> --parser dockerfile --namespace standards.containerfile.hardening
# deploy/deploy-descriptor.json -> --namespace standards.ci.deploy / standards.ci.image
#
# Fails (exit 1) only on a policy `deny`; `warn`s are reported but do not block —
# this is the "gates over gatekeepers" model (see the standard's docs/00-overview.md).
# Part of the standard; do NOT edit by hand — re-run adopt-standard.sh to update.
set -euo pipefail
shopt -s nullglob globstar
POL=".standards/policies"
rc=0
if [[ ! -d "${POL}" ]]; then
echo "policy-gate: ${POL} not found — run adopt-standard.sh to vendor the policies." >&2
exit 2
fi
run() { # run <label> <conftest-args...>
local label="$1"; shift
echo "== ${label}"
conftest test "$@" -p "${POL}" || rc=1
}
# Quadlet systemd units.
quadlets=( **/*.container )
if (( ${#quadlets[@]} )); then
run "quadlet policy (${#quadlets[@]} unit(s))" \
"${quadlets[@]}" --parser ini --namespace standards.quadlet.security
fi
# Containerfiles / Dockerfiles.
cfiles=( Containerfile* **/Containerfile* Dockerfile* **/Dockerfile* )
if (( ${#cfiles[@]} )); then
run "containerfile policy (${#cfiles[@]} file(s))" \
"${cfiles[@]}" --parser dockerfile --namespace standards.containerfile.hardening
fi
# Optional deploy descriptor for the CI/deploy/image policies.
if [[ -f deploy/deploy-descriptor.json ]]; then
run "ci policy (deploy descriptor)" \
deploy/deploy-descriptor.json \
--namespace standards.ci.deploy --namespace standards.ci.image
fi
if (( rc == 0 )); then
echo "policy gate: PASS"
else
echo "policy gate: FAIL (a deny rule fired — see above)" >&2
fi
exit "${rc}"