chore: adopt engineering standard v1.0.0
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:
parent
70205b6b93
commit
015c0c5378
25 changed files with 1846 additions and 0 deletions
71
.standards/policies/ci/adoption.rego
Normal file
71
.standards/policies/ci/adoption.rego
Normal 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")
|
||||
}
|
||||
106
.standards/policies/ci/deploy_rules.rego
Normal file
106
.standards/policies/ci/deploy_rules.rego
Normal 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
|
||||
}
|
||||
78
.standards/policies/ci/image_provenance.rego
Normal file
78
.standards/policies/ci/image_provenance.rego
Normal 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])
|
||||
}
|
||||
66
.standards/policies/ci/pipeline_contract.rego
Normal file
66
.standards/policies/ci/pipeline_contract.rego
Normal 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"]
|
||||
}
|
||||
72
.standards/policies/ci/secrets_in_env.rego
Normal file
72
.standards/policies/ci/secrets_in_env.rego
Normal 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])
|
||||
}
|
||||
136
.standards/policies/ci/tenant_separation.rego
Normal file
136
.standards/policies/ci/tenant_separation.rego
Normal 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
|
||||
}
|
||||
18
.standards/policies/conftest.toml
Normal file
18
.standards/policies/conftest.toml
Normal 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
|
||||
177
.standards/policies/containerfile/containerfile_hardening.rego
Normal file
177
.standards/policies/containerfile/containerfile_hardening.rego
Normal 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
|
||||
}
|
||||
283
.standards/policies/lib/util.rego
Normal file
283
.standards/policies/lib/util.rego
Normal 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 == ""
|
||||
}
|
||||
127
.standards/policies/quadlet/quadlet_security.rego
Normal file
127
.standards/policies/quadlet/quadlet_security.rego
Normal 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
58
.standards/policy-gate.sh
Normal 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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue