From 9c0dc7b4e16eb5374592456c56e50b0b7dc92f47 Mon Sep 17 00:00:00 2001 From: Kenearos Date: Wed, 1 Jul 2026 19:32:52 +0200 Subject: [PATCH] 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: 16. --- .editorconfig | 29 ++ .gitattributes | 43 +++ .github/ISSUE_TEMPLATE/bug_report.md | 52 ++++ .github/ISSUE_TEMPLATE/config.yml | 13 + .github/ISSUE_TEMPLATE/feature_request.md | 37 +++ .github/PULL_REQUEST_TEMPLATE.md | 44 +++ .github/workflows/ci.yml | 44 +++ .gitignore | 47 +++ .pre-commit-config.yaml | 61 ++++ .standards-version | 1 + .standards/policies/ci/adoption.rego | 71 +++++ .standards/policies/ci/deploy_rules.rego | 106 +++++++ .standards/policies/ci/image_provenance.rego | 78 +++++ .standards/policies/ci/pipeline_contract.rego | 66 ++++ .standards/policies/ci/secrets_in_env.rego | 72 +++++ .standards/policies/ci/tenant_separation.rego | 136 +++++++++ .standards/policies/conftest.toml | 18 ++ .../containerfile_hardening.rego | 177 +++++++++++ .standards/policies/lib/util.rego | 283 ++++++++++++++++++ .../policies/quadlet/quadlet_security.rego | 127 ++++++++ .standards/policy-gate.sh | 58 ++++ CODEOWNERS | 21 ++ CONTRIBUTING.md | 82 +++++ SECURITY.md | 86 ++++++ adr/0000-template.md | 69 +++++ sops/SOP-000-template.md | 72 +++++ 26 files changed, 1893 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .standards-version create mode 100644 .standards/policies/ci/adoption.rego create mode 100644 .standards/policies/ci/deploy_rules.rego create mode 100644 .standards/policies/ci/image_provenance.rego create mode 100644 .standards/policies/ci/pipeline_contract.rego create mode 100644 .standards/policies/ci/secrets_in_env.rego create mode 100644 .standards/policies/ci/tenant_separation.rego create mode 100644 .standards/policies/conftest.toml create mode 100644 .standards/policies/containerfile/containerfile_hardening.rego create mode 100644 .standards/policies/lib/util.rego create mode 100644 .standards/policies/quadlet/quadlet_security.rego create mode 100644 .standards/policy-gate.sh create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 adr/0000-template.md create mode 100644 sops/SOP-000-template.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..061a41e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# .editorconfig — rendered by adopt-standard.sh (verbatim; no placeholders). +# Mirrors the standards repo's own root .editorconfig. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{sh,bash,bats}] +indent_size = 2 + +[*.rego] +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false # markdown hard line breaks + +[Makefile] +indent_style = tab + +[*.{ps1,psm1,bat,cmd}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7b0546f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,43 @@ +# .gitattributes — rendered by adopt-standard.sh (verbatim; no placeholders). +# Mirrors the standards repo's own root .gitattributes. +# +# Normalize to LF in the repository for everything text. Working-copy line +# endings are pinned per type so MinGW/Git-Bash scripts (LF) and native +# Windows scripts (CRLF) both work. This overrides core.autocrlf. +* text=auto eol=lf + +# Unix shell / POSIX tooling — MUST stay LF in the working copy +*.sh text eol=lf +*.bash text eol=lf +*.bats text eol=lf +*.rego text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.md text eol=lf +*.env text eol=lf +Makefile text eol=lf +.sops.yaml text eol=lf + +# Native Windows scripts — CRLF in the working copy +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +# Encrypted secret files are text (diffable ciphertext), kept LF +*.enc.yaml text eol=lf +*.enc.json text eol=lf +*.sops.yaml text eol=lf +*.sops.json text eol=lf + +# Binary — never touch +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.gz binary +*.zip binary +*.tar binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f316bfb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,52 @@ +--- +name: Bug report +about: Report something that is broken or behaves incorrectly +title: "bug: " +labels: [bug, triage] +assignees: [] +--- + + + +## Summary + + + +## Steps to reproduce + +1. +2. +3. + +## Expected behaviour + + + +## Actual behaviour + + + +```text + +``` + +## Environment + +- Repo version / commit: +- Adopted standard version (`.standards-version`): +- OS / runtime: +- CI or local: + +## Impact + +- [ ] Blocks work / production +- [ ] Workaround exists +- [ ] Cosmetic / minor + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7930264 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +# Issue chooser config — rendered into /.github/ISSUE_TEMPLATE/ by +# adopt-standard.sh. Disables blank issues so every report uses a template, +# and routes security reports away from the public tracker. +blank_issues_enabled: false +contact_links: + - name: Security vulnerability (private) + url: https://github.com/its-consulting/Dienstplan-Pro/security/advisories/new + about: >- + Do NOT open a public issue for a security problem. Report privately per + SECURITY.md (no secrets/keys/tokens in the report; rotate first if leaked). + - name: Question / discussion + url: https://github.com/its-consulting/Dienstplan-Pro/discussions + about: For usage questions and open-ended discussion, use Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a5d82bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,37 @@ +--- +name: Feature request +about: Propose new functionality or a change to existing behaviour +title: "feat: " +labels: [enhancement, triage] +assignees: [] +--- + + + +## Problem / motivation + + + +## Proposed solution + + + +## Alternatives considered + + + +## Scope & impact + +- [ ] Backward compatible (`MINOR`) +- [ ] Breaking — consumers must act (`MAJOR`) +- [ ] Needs an **ADR** (decision about a rule/contract/default) +- [ ] Needs an **SOP** and/or **Policy** change +- [ ] Affects CI / deploy / secrets handling + +## Additional context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2fcc2cb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ + + +## What & why + + + +Closes # + +## Type of change + +- [ ] `feat` — new functionality +- [ ] `fix` — bug fix +- [ ] `docs` — documentation only +- [ ] `ci` / `refactor` / `test` / `chore` +- [ ] **Breaking change** (consumers must act — `!` / `BREAKING CHANGE:` in the commit) +- [ ] **Decision** — an ADR is included or updated (`docs/adr/NNNN-*.md`) + +## SOP-001 (branch & merge) checklist + +- [ ] Branched from `develop` with a short-lived `feature/` (no direct commits to `main`/`develop`). +- [ ] Commits follow **Conventional Commits**. +- [ ] Up to date with the base branch (rebased/merged); no merge conflicts. +- [ ] At least one **CODEOWNERS** reviewer is requested. +- [ ] `CHANGELOG.md` updated under `[Unreleased]` (if user-facing). + +## Policy & security gates (must be green) + +- [ ] `pre-commit run -a` passes locally (whitespace/EOF/YAML, **shellcheck**, **gitleaks**, **sops-encrypted-check**). +- [ ] CI `static-checks`, `test`, `build`, **`policy_check`** and **`security-scan`** stages pass. +- [ ] **No plaintext secrets** added — secrets are SOPS+age encrypted (`*.enc.*` / `*.sops.*` only). See `SECURITY.md`. +- [ ] Data traffic-light respected: nothing 🔴 RED (sensitive personal data / plaintext keys) committed. +- [ ] If this asserts a rule: an ADR records *why*, an SOP records *how*, and a Policy (+ `*_test.rego`) enforces it. + +## How was this verified? + + + +## Rollback plan + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f829adc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +# ci.yml — self-contained standards gate (VENDORED model). +# +# Rendered verbatim into /.github/workflows/ by adopt-standard.sh. The +# standard's Rego policies are vendored into this repo at .standards/policies/ +# (also placed by adopt-standard.sh, pinned to .standards-version), and the gate +# logic lives in .standards/policy-gate.sh. CI installs conftest and runs the +# policies LOCALLY — no dependency on a remote (possibly private) standards repo, +# no cross-account reusable-workflow access, no PAT. Re-run adopt-standard.sh to +# update the vendored policies when you bump the pin. +# +# The same gate runs identically on Forgejo/Woodpecker — see the standard's +# ci/woodpecker/ (ADR-0002 dual-target CI). Add repo-native jobs (lint/test/build) +# below the gate; those are language-specific and owned by the repo. +name: ci + +on: + push: + branches: [main, develop] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + standards-gate: + runs-on: ubuntu-latest + env: + CONFTEST_VERSION: "0.68.2" + steps: + - uses: actions/checkout@v4 + + - name: Install conftest (pinned) + run: | + set -euo pipefail + url="https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz" + curl -fsSL "$url" | sudo tar -xz -C /usr/local/bin conftest + conftest --version + + - name: Policy gate (vendored standards policies) + run: bash .standards/policy-gate.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71d9265 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# .gitignore — rendered by adopt-standard.sh (verbatim; no placeholders). +# Secret-safe defaults consistent with the standards repo's own root .gitignore. + +# ───────────────────────────────────────────────────────────── +# Secrets — NEVER commit decrypted material or raw keys. +# Only *.enc.* / *.sops.* (encrypted) and *.example files belong in git. +# ───────────────────────────────────────────────────────────── +*.dec +*.decrypted +*.plain.yaml +*.plain.json +*.plaintext +age.key +*.agekey +*.age.key +keys.txt +id_rsa +id_ed25519 +*.pem +*.key +!*.pub +.env +.env.* +!.env.example +!.env.*.example + +# OS / editor noise +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ + +# Tooling caches / build output +.cache/ +.conftest-cache/ +.pre-commit-cache/ +__pycache__/ +*.py[cod] +.venv/ +node_modules/ +dist/ +build/ +coverage/ +*.egg-info/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1e7dc1a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +# .pre-commit-config.yaml — rendered by adopt-standard.sh. +# Local mirror of the CI gates: hygiene + shell lint + secret scanning + policy. +# Install: pre-commit install | Run on everything: pre-commit run -a +# See ../SECURITY.md (no plaintext secrets) and SOP-001 (branch & merge). +minimum_pre_commit_version: "3.5.0" +fail_fast: false + +repos: + # ── Baseline file hygiene ───────────────────────────────────────────── + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + # Preserve markdown hard line breaks (two trailing spaces). + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + # ── Shell lint (POSIX / MinGW-safe scripts) ─────────────────────────── + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [--severity=warning] + + # ── Secret scanning — no plaintext secrets, ever ────────────────────── + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.4 + hooks: + - id: gitleaks + + # ── Local project hooks (no external pin; degrade gracefully) ───────── + - repo: local + hooks: + # Optional: validate config/manifests against the standard's Rego + # policies. Skipped automatically if `conftest` is not installed, so a + # missing optional binary never blocks a commit (graceful degradation). + - id: conftest + name: conftest (policy gate, optional) + entry: 'bash -c ''command -v conftest >/dev/null 2>&1 && conftest test --policy policies . || echo "conftest not installed - skipping policy gate"''' + language: system + pass_filenames: false + always_run: true + + # Refuse to commit any *.sops.yaml / *.enc.yaml that is NOT actually + # SOPS-encrypted (i.e. missing the `sops:` metadata block). This catches + # the classic "saved the plaintext under the encrypted name" mistake. + # Implementation note: `grep -L` lists files MISSING the marker (no shell + # loop, no nested double quotes) so it survives the YAML/shell quoting layers. + # The marker is `sops:` (SOPS-YAML) or `"sops"` (SOPS-JSON). + - id: sops-encrypted-check + name: sops-encrypted-check (no plaintext under encrypted names) + entry: 'bash -c ''bad=$(grep -L -E ''"''"''^sops:|"sops"''"''"'' "$@"); if [ -n "$bad" ]; then echo "ERROR: named encrypted but missing SOPS metadata:"; echo "$bad"; exit 1; fi'' --' + language: system + files: '\.(sops|enc)\.(yaml|json)$' diff --git a/.standards-version b/.standards-version new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/.standards-version @@ -0,0 +1 @@ +1.0.0 diff --git a/.standards/policies/ci/adoption.rego b/.standards/policies/ci/adoption.rego new file mode 100644 index 0000000..94d7d15 --- /dev/null +++ b/.standards/policies/ci/adoption.rego @@ -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") +} diff --git a/.standards/policies/ci/deploy_rules.rego b/.standards/policies/ci/deploy_rules.rego new file mode 100644 index 0000000..be1d8b6 --- /dev/null +++ b/.standards/policies/ci/deploy_rules.rego @@ -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 +} diff --git a/.standards/policies/ci/image_provenance.rego b/.standards/policies/ci/image_provenance.rego new file mode 100644 index 0000000..0e8cb40 --- /dev/null +++ b/.standards/policies/ci/image_provenance.rego @@ -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]) +} diff --git a/.standards/policies/ci/pipeline_contract.rego b/.standards/policies/ci/pipeline_contract.rego new file mode 100644 index 0000000..b9fb6b7 --- /dev/null +++ b/.standards/policies/ci/pipeline_contract.rego @@ -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"] +} diff --git a/.standards/policies/ci/secrets_in_env.rego b/.standards/policies/ci/secrets_in_env.rego new file mode 100644 index 0000000..1143268 --- /dev/null +++ b/.standards/policies/ci/secrets_in_env.rego @@ -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]) +} diff --git a/.standards/policies/ci/tenant_separation.rego b/.standards/policies/ci/tenant_separation.rego new file mode 100644 index 0000000..e6e2de0 --- /dev/null +++ b/.standards/policies/ci/tenant_separation.rego @@ -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-`. 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-` / `ops-` prefix or as a +# path segment `` 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 +} diff --git a/.standards/policies/conftest.toml b/.standards/policies/conftest.toml new file mode 100644 index 0000000..4fe556a --- /dev/null +++ b/.standards/policies/conftest.toml @@ -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 ` 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 ` 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 diff --git a/.standards/policies/containerfile/containerfile_hardening.rego b/.standards/policies/containerfile/containerfile_hardening.rego new file mode 100644 index 0000000..24e8941 --- /dev/null +++ b/.standards/policies/containerfile/containerfile_hardening.rego @@ -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 ` 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 ` 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 +} diff --git a/.standards/policies/lib/util.rego b/.standards/policies/lib/util.rego new file mode 100644 index 0000000..bd5996e --- /dev/null +++ b/.standards/policies/lib/util.rego @@ -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 == "" +} diff --git a/.standards/policies/quadlet/quadlet_security.rego b/.standards/policies/quadlet/quadlet_security.rego new file mode 100644 index 0000000..01f364d --- /dev/null +++ b/.standards/policies/quadlet/quadlet_security.rego @@ -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]) +} diff --git a/.standards/policy-gate.sh b/.standards/policy-gate.sh new file mode 100644 index 0000000..933da2c --- /dev/null +++ b/.standards/policy-gate.sh @@ -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