chore: adopt engineering standard v1.0.0 #11

Manually merged
Kenearos merged 1 commit from chore/adopt-standards-v1.0.0 into main 2026-07-02 07:04:24 +02:00
25 changed files with 1846 additions and 0 deletions

29
.editorconfig Normal file
View file

@ -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

43
.gitattributes vendored Normal file
View file

@ -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

52
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,52 @@
---
name: Bug report
about: Report something that is broken or behaves incorrectly
title: "bug: <short summary>"
labels: [bug, triage]
assignees: []
---
<!--
Rendered into <repo>/.github/ISSUE_TEMPLATE/ by adopt-standard.sh.
SECURITY: do NOT paste secrets, private keys, tokens, or production hostnames.
If this is a security vulnerability, do NOT file a public issue — see SECURITY.md.
-->
## Summary
<!-- One sentence: what is wrong? -->
## Steps to reproduce
1.
2.
3.
## Expected behaviour
<!-- What you expected to happen. -->
## Actual behaviour
<!-- What actually happened. Include error output (redact any secrets). -->
```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
<!-- Links, screenshots (no secrets), related issues. -->

13
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,13 @@
# Issue chooser config — rendered into <repo>/.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/Rentenversicherer/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/Rentenversicherer/discussions
about: For usage questions and open-ended discussion, use Discussions.

View file

@ -0,0 +1,37 @@
---
name: Feature request
about: Propose new functionality or a change to existing behaviour
title: "feat: <short summary>"
labels: [enhancement, triage]
assignees: []
---
<!--
Rendered into <repo>/.github/ISSUE_TEMPLATE/ by adopt-standard.sh.
If this proposes a change to a rule, contract, or default, expect to record an ADR
(docs/adr/NNNN-*.md) before it is implemented — see CONTRIBUTING.md.
-->
## Problem / motivation
<!-- What problem does this solve? Who feels the pain, and when? -->
## Proposed solution
<!-- What you'd like to happen. Be concrete. -->
## Alternatives considered
<!-- Other approaches and why they are worse/better. -->
## 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
<!-- Links, prior art, related issues/ADRs. -->

44
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,44 @@
<!--
PULL_REQUEST_TEMPLATE.md — rendered into <repo>/.github/ by adopt-standard.sh.
The checklist is tied to SOP-001 (branch & merge) and the CI policy gates.
Keep the items; tailor wording to your repo. Delete inapplicable sections, not checks.
-->
## What & why
<!-- One or two sentences: what does this change do, and why now? Link the issue. -->
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/<topic>` (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?
<!-- Commands run, environments tested, evidence. Be concrete and falsifiable. -->
## Rollback plan
<!-- If this is a risky change (deploy, migration, policy tightening): how to revert. -->

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
# ci.yml — self-contained standards gate (VENDORED model).
#
# Rendered verbatim into <repo>/.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

61
.pre-commit-config.yaml Normal file
View file

@ -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)$'

1
.standards-version Normal file
View file

@ -0,0 +1 @@
1.0.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

21
CODEOWNERS Normal file
View file

@ -0,0 +1,21 @@
# CODEOWNERS — rendered by adopt-standard.sh.
# Review is REQUIRED from an owner before merge (enforce via branch protection
# on develop/main). Replace its-consulting with your Forgejo/GitHub org handle.
# See SOP-001 (branch & merge) and CONTRIBUTING.md.
# Default — everything
* @its-consulting
# Governance & decisions — change deliberately
/docs/ @its-consulting
/docs/adr/ @its-consulting
/sops/ @its-consulting
# Enforcement & security-relevant paths — always require an owner
/.github/ @its-consulting
/ci/ @its-consulting
/policies/ @its-consulting
/secrets/ @its-consulting
/SECURITY.md @its-consulting
/CODEOWNERS @its-consulting
/.pre-commit-config.yaml @its-consulting

82
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,82 @@
# Contributing to `Rentenversicherer`
> Rendered by `adopt-standard.sh` from the standards repo, pinned at
> **`1.0.0`** (see [`.standards-version`](.standards-version)).
> Replace the `{{PLACEHOLDERS}}` and tailor as needed. This is the adopter-facing
> variant of the standard's own contribution guide.
This repo adopts our engineering standard. Contributions follow it: **decisions are
recorded, the local gate must pass, and an owner reviews via CODEOWNERS.**
---
## TL;DR
1. **Branch** off `develop` with a short-lived `feature/<topic>` branch (branch &
merge SOP — SOP-001 in the standard).
2. If you are **deciding** something architectural (a contract, default, or rule),
record an **ADR** first (`docs/adr/NNNN-kebab-title.md`, Nygard format).
3. Make the change.
4. Run the gate locally: **`pre-commit run -a`** and, if this repo has a `Makefile`
with a test target, **`make test`**. Both must pass.
5. Add a `CHANGELOG.md` entry under `[Unreleased]`.
6. Commit with **Conventional Commits**, open a PR, fill the PR template, get an
**owner review** ([`CODEOWNERS`](CODEOWNERS)).
---
## The local gate
CI runs the same checks; passing locally is the contract for opening a PR.
```bash
pre-commit install # one-time
pre-commit run -a # hygiene + shellcheck + gitleaks + sops-encrypted-check (+ optional conftest)
make test # if this repo defines it (validate/test/policy targets)
```
Tooling degrades gracefully: an optional binary that is not installed is skipped with
a notice rather than hard-failing — but install it if your change touches the area it
checks. **Never bypass `gitleaks` or `sops-encrypted-check`** (see
[`SECURITY.md`](SECURITY.md): no plaintext secrets, ever).
---
## Conventional Commits
```
<type>(<optional scope>): <imperative summary>
```
Allowed `type`s: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore` (plus
`adr`, `sop`, `policy` for standard-adjacent repos). Use `!` or a `BREAKING CHANGE:`
footer for changes consumers must act on. Examples:
```
feat(api): add idempotent retry to the payment client
fix(ci): pin reusable workflow to v1.0.0
docs: clarify staging deploy approval step
```
---
## Review via CODEOWNERS
Every PR requires review from an owner in [`CODEOWNERS`](CODEOWNERS) before merge
(enforce with branch protection on `develop`/`main`). Security-relevant paths
(`/.github/`, `/ci/`, `/policies/`, `/secrets/`, `/SECURITY.md`) always require an
owner's sign-off. The PR template is your checklist.
## CI wiring
This repo's CI is a **thin caller** that `uses:` the standard's reusable workflows
pinned at the adopted version (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)).
Do not copy pipeline logic into this repo — change the standard and bump the pin
instead.
---
## References
- [`SECURITY.md`](SECURITY.md) · [`CODEOWNERS`](CODEOWNERS) · [`.standards-version`](.standards-version)
- [`.pre-commit-config.yaml`](.pre-commit-config.yaml) · [`.github/workflows/ci.yml`](.github/workflows/ci.yml)

86
SECURITY.md Normal file
View file

@ -0,0 +1,86 @@
# Security Policy
> Rendered by `adopt-standard.sh` from the standards repo, pinned at
> **`1.0.0`**. Replace the `{{PLACEHOLDERS}}` and tailor as needed.
> This is the adopter-facing variant of the standard's own
> `SECURITY.md`; keep the no-plaintext-secret rule and the data traffic-light intact.
This policy applies to **`its-consulting/Rentenversicherer`**. It is derived from our engineering
standard (pinned in [`.standards-version`](.standards-version)).
---
## Reporting a vulnerability
**Do not open a public issue for a security problem** — a public issue widens
exposure before a fix exists (a private repo is *non-public*, not *secret-safe*).
1. Email **kenearos@googlemail.com** with the subject prefix `[SECURITY] Rentenversicherer`.
2. Include: affected file/component, the version/commit you observed it on, a minimal
reproduction, and the impact you foresee.
3. **Never paste a real secret, private key, token, or production hostname** into the
report. If a credential leaked, report *that it leaked and where* — then **rotate
it first**, before anything else.
Expect acknowledgement within **3 business days** and a triage decision within
**10 business days**. We prefer **coordinated disclosure**: please give us a
reasonable window to ship a fix before any public write-up.
---
## Secret policy — no plaintext secrets in git, ever
**No plaintext secret material is committed to this repository, not even "just for a
minute" and not even though the repo is private.**
- **Encrypted-only.** Machine-readable secrets are encrypted with **SOPS + age**
before they touch git. Only `*.enc.*` / `*.sops.*` ciphertext and `*.example`
placeholders belong in version control.
- **age keys, not the KeePass master password.** Use **dedicated age keys**.
**KeePass remains the human vault** (emergency codes, manual credentials, key
backups). Never reuse the KeePass master password for other crypto purposes.
- **SSH keys.** Private keys are **never** committed; public keys may be versioned.
- **A FIDO2 / hardware-token path** is supported for the age identity.
This is enforced, not honour-system:
| Layer | Mechanism |
|-------|-----------|
| Pre-commit (local) | `gitleaks` + `sops-encrypted-check` in [`.pre-commit-config.yaml`](.pre-commit-config.yaml); secret-safe [`.gitignore`](.gitignore) |
| CI `security-scan` | secret + dependency scanning |
| CI `policy_check` | Rego policy from the pinned standard |
If a secret is ever committed: treat it as compromised, **rotate it immediately**,
then scrub history. Rotation comes first.
---
## Data traffic-light
Classify every piece of content before committing:
| Light | Meaning | Rule |
|:-----:|---------|------|
| 🟢 **GREEN** | Technical content, **no** personal data. | OK in the repo. |
| 🟡 **YELLOW** | **Minimized** personal reference — roles / IDs, not real names. | Use **sparingly**. |
| 🔴 **RED** | Sensitive personal data, **plaintext secrets, private keys**. | **Never** in the repo. Encrypt secrets (SOPS+age) or keep personal data out entirely. |
---
## Supported versions
| Version | Supported |
|---------|-----------|
| Latest release of `Rentenversicherer` | ✅ Yes |
| Older releases | ❌ Upgrade first |
The standard this repo adopts follows SemVer; bump
[`.standards-version`](.standards-version) deliberately and read its CHANGELOG
migration notes.
---
## References
- [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`CODEOWNERS`](CODEOWNERS)
- [`.pre-commit-config.yaml`](.pre-commit-config.yaml) · [`.standards-version`](.standards-version)

69
adr/0000-template.md Normal file
View file

@ -0,0 +1,69 @@
---
title: <Short decision title, imperative>
status: Draft
owner: alex
review-date: YYYY-MM-DD
---
<!--
ADR template — Michael Nygard format. Used by `make adr-new` and rendered by
adopt-standard.sh into <repo>/docs/adr/NNNN-kebab-title.md.
Rules (see docs/00-overview.md §5.2, §5.4):
- File name: NNNN-kebab-title.md, NNNN zero-padded (0009, 0010, …).
- The H1 number matches NNNN; the title matches front-matter `title`.
- status lifecycle: Draft -> Accepted -> Superseded. Immutable once Accepted —
to change a decision, write a NEW ADR that Supersedes this one.
- If this decision asserts an enforceable rule, name its counterparts under
"Enforced by": the SOP that operationalizes it and the Policy (+ *_test.rego)
that enforces it. Keep the ADR <-> SOP <-> Policy triangle intact.
Delete this comment when you fill the template in.
-->
# NNNN. <Short decision title, imperative>
## Status
Draft — YYYY-MM-DD.
<!-- When accepted: "Accepted — YYYY-MM-DD." When replaced: "Superseded by
[ADR-XXXX](XXXX-kebab-title.md) — YYYY-MM-DD." -->
## Context
<!-- The forces at play: technical, business, team. What problem or pressure makes
a decision necessary now? State facts and constraints, not the solution.
Be concrete and falsifiable. -->
## Decision
<!-- The change we are actually making, in active voice: "We will …".
One decision per ADR. Spell out the chosen option clearly enough that someone
can implement it without guessing. -->
We will …
## Consequences
<!-- What becomes easier AND harder after this decision. List both. -->
- Positive: …
- Negative: …
- Neutral / follow-ups: …
## Alternatives considered
<!-- Options you rejected and the one-line reason each lost. Optional but recommended. -->
- **<Option>** — rejected because …
## Enforced by
<!-- Required when this ADR asserts an enforceable rule. Link the exact files:
- SOP: ../../sops/SOP-NNN-<topic>.md
- Policy: ../../policies/<domain>/<name>.rego (+ tests/<name>_test.rego)
- CI: ../../ci/github/reusable-*.yml | ../../ci/woodpecker/.woodpecker.*.yml
If purely advisory, write "Advisory — no machine enforcement." and say why. -->
## References
<!-- Related ADRs (supersedes/superseded-by), SOPs, external links. -->

72
sops/SOP-000-template.md Normal file
View file

@ -0,0 +1,72 @@
---
title: <Procedure title>
status: Draft
owner: alex
review-date: YYYY-MM-DD
---
<!--
SOP template — matches the section contract in docs/00-overview.md §5.5.
Used by `make sop-new` and rendered by adopt-standard.sh into
<repo>/sops/SOP-NNN-kebab-topic.md.
Rules:
- File name: SOP-NNN-kebab-topic.md, NNN zero-padded (SOP-007, SOP-008, …).
- Sections MUST appear in this order: Purpose, Scope, Roles, Procedure,
Enforced by, Failure modes & rollback, References.
- Every Procedure step is FALSIFIABLE: you can mechanically tell whether it
was done (a command ran, a file exists, a check is green).
- Keep the ADR <-> SOP <-> Policy triangle intact: name the ADR that motivates
this SOP and the Policy/CI that enforces its checkable steps.
Delete this comment when you fill the template in.
-->
# SOP-NNN: <Procedure title>
## Purpose
<!-- One paragraph: what outcome this procedure guarantees and why it exists. -->
## Scope
<!-- When this SOP applies and when it does NOT (repos, branches, environments,
mandanten). Be explicit about boundaries. -->
## Roles
<!-- Who does what. Use roles/IDs, not real names (data traffic-light: YELLOW). -->
| Role | Responsibility |
|------|----------------|
| Author | … |
| Reviewer (CODEOWNERS) | … |
| Approver | … |
## Procedure
<!-- Numbered, falsifiable steps. Each step states the action and its observable
"done" condition. Reference the exact command/file where possible. -->
1. **<Action>.** Done when: `<observable check, e.g. a command exits 0 / a file exists>`.
2. **<Action>.** Done when: `…`.
3. **<Action>.** Done when: `…`.
## Enforced by
<!-- Link the machine checks that gate the falsifiable steps above:
- Policy: ../policies/<domain>/<name>.rego (+ tests/<name>_test.rego)
- CI: ../ci/github/reusable-*.yml | ../ci/woodpecker/.woodpecker.*.yml
- Local: .pre-commit-config.yaml hooks
- ADR: ../docs/adr/NNNN-*.md (the decision this SOP operationalizes)
If a step is human-judgement only, say so and explain the compensating control. -->
## Failure modes & rollback
<!-- What can go wrong at each critical step, how it is detected, and the exact
rollback. Be concrete: "revert commit X", "re-run with --force-unlock", etc. -->
- **Failure:** … → **Detection:** … → **Rollback:**
## References
<!-- Related SOPs, ADRs, policies, external docs (repo-relative markdown links). -->