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.
283 lines
8.8 KiB
Rego
283 lines
8.8 KiB
Rego
# 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 == ""
|
|
}
|