chore: adopt engineering standard v1.0.0 #1
25 changed files with 1846 additions and 0 deletions
29
.editorconfig
Normal file
29
.editorconfig
Normal 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
43
.gitattributes
vendored
Normal 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
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
13
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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/abenteuer-schmiede/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/abenteuer-schmiede/discussions
|
||||
about: For usage questions and open-ended discussion, use Discussions.
|
||||
37
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
44
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
44
.github/workflows/ci.yml
vendored
Normal 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
61
.pre-commit-config.yaml
Normal 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
1
.standards-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0
|
||||
71
.standards/policies/ci/adoption.rego
Normal file
71
.standards/policies/ci/adoption.rego
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Standard adoption by pin: a consuming repo must declare WHICH version of this
|
||||
# standards bundle it has adopted, so drift between repos is visible and pinned.
|
||||
#
|
||||
# Enforces: docs/adr/0007-adopt-by-pinned-version.md
|
||||
# SOP: sops/SOP-004-environment-setup.md (scripts/adopt-standard.sh writes the pin)
|
||||
# Input: repo metadata carrying the adopted standards version:
|
||||
# {
|
||||
# "repo": "mypods/api",
|
||||
# "standards_version": "v1.4.0",
|
||||
# "current_version": "v1.6.0" # optional: latest published standards version
|
||||
# }
|
||||
#
|
||||
# Rules:
|
||||
# deny - `standards_version` is missing or not valid SemVer (vX.Y.Z)
|
||||
# warn - the pinned version is behind the provided `current_version`
|
||||
package standards.ci.adoption
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
# Only evaluate inputs that are actually adoption descriptors. An adoption descriptor
|
||||
# is one that carries the pin field, OR a repo-metadata object that is expected to
|
||||
# carry it. We gate on `standards_version` / `repo` so unrelated inputs
|
||||
# (k8s/compose/Quadlet) stay silent under conftest --all-namespaces.
|
||||
_is_adoption_input if {
|
||||
lib.has_key(input, "standards_version")
|
||||
}
|
||||
|
||||
_is_adoption_input if {
|
||||
lib.has_key(input, "repo")
|
||||
}
|
||||
|
||||
# --- The pin must be present and valid SemVer -------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_adoption_input
|
||||
not lib.has_key(input, "standards_version")
|
||||
msg := "repo declares no 'standards_version'; pin the adopted standards bundle to a SemVer 'vX.Y.Z' (ADR-0007, SOP-004)"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
_is_adoption_input
|
||||
lib.has_key(input, "standards_version")
|
||||
not lib.is_semver(input.standards_version)
|
||||
msg := sprintf("standards_version '%v' is not valid SemVer 'vX.Y.Z'; adopt by pinning an immutable release (ADR-0007)", [input.standards_version])
|
||||
}
|
||||
|
||||
# --- Advisory: the pin is behind the current published version --------------
|
||||
|
||||
warn contains msg if {
|
||||
lib.has_key(input, "current_version")
|
||||
lib.is_semver(input.standards_version)
|
||||
lib.is_semver(input.current_version)
|
||||
input.standards_version != input.current_version
|
||||
semver.compare(_strip_v(input.standards_version), _strip_v(input.current_version)) < 0
|
||||
msg := sprintf("standards_version '%v' is behind current '%v'; schedule an update via scripts/adopt-standard.sh (ADR-0007, SOP-004)", [input.standards_version, input.current_version])
|
||||
}
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
# _strip_v drops the leading `v` so OPA's semver.compare (which expects a bare
|
||||
# SemVer core) can order the two pins.
|
||||
_strip_v(tag) := out if {
|
||||
startswith(tag, "v")
|
||||
out := substring(tag, 1, -1)
|
||||
}
|
||||
|
||||
_strip_v(tag) := tag if {
|
||||
not startswith(tag, "v")
|
||||
}
|
||||
106
.standards/policies/ci/deploy_rules.rego
Normal file
106
.standards/policies/ci/deploy_rules.rego
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Deploy gate: who is allowed to deploy WHAT, WHERE, and under WHICH conditions.
|
||||
#
|
||||
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# docs/adr/0005-trunk-based-with-release-branches.md
|
||||
# SOP: sops/SOP-002-release-process.md (the human release procedure delegates
|
||||
# its "production deploy is gated" step to this policy)
|
||||
# Input: a deploy/pipeline descriptor, e.g.
|
||||
# {
|
||||
# "environment": "prod", # target environment (prod|staging|dev)
|
||||
# "branch": "main", # ref the pipeline runs on
|
||||
# "event": "tag", # trigger event (tag|push|pull_request|manual)
|
||||
# "tag": "v1.4.0", # release tag, when event == "tag"
|
||||
# "review": {"approved": true, "approvals": 2},
|
||||
# "actor": "alex"
|
||||
# }
|
||||
#
|
||||
# Contract (identical for the GitHub Actions and Woodpecker adapters — see
|
||||
# docs/00-overview.md §4): a production deploy is allowed ONLY from `main`, ONLY on a
|
||||
# `tag` event, ONLY with an approved review. Everything else is denied with a message.
|
||||
package standards.ci.deploy
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
# Treat these environment names as production.
|
||||
prod_envs := {"prod", "production"}
|
||||
|
||||
is_prod if {
|
||||
prod_envs[lower(input.environment)]
|
||||
}
|
||||
|
||||
# Non-release branch prefixes that must never deploy to production.
|
||||
nonrelease_prefixes := {"feature/", "feat/", "fix/", "hotfix/", "wip/", "dependabot/", "renovate/"}
|
||||
|
||||
# --- Production guardrails ---------------------------------------------------
|
||||
|
||||
# Prod deploys must originate from the trunk branch `main`.
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
input.branch != "main"
|
||||
msg := sprintf("prod deploy must run on branch 'main', got '%v' (see SOP-002, ADR-0005)", [input.branch])
|
||||
}
|
||||
|
||||
# Prod deploys must be triggered by a release tag event, not an ad-hoc push/PR/manual run.
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
input.event != "tag"
|
||||
msg := sprintf("prod deploy must be triggered by a 'tag' event, got '%v' (see SOP-002)", [input.event])
|
||||
}
|
||||
|
||||
# The release tag itself must follow the vX.Y.Z convention.
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
input.event == "tag"
|
||||
not lib.is_semver(input.tag)
|
||||
msg := sprintf("prod release tag '%v' is not SemVer 'vX.Y.Z' (see ADR-0005)", [input.tag])
|
||||
}
|
||||
|
||||
# Prod deploys require a recorded, approved review.
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
not review_approved
|
||||
msg := "prod deploy requires an approved review (review.approved == true)"
|
||||
}
|
||||
|
||||
review_approved if {
|
||||
input.review.approved == true
|
||||
}
|
||||
|
||||
# Feature/throwaway branches must never reach production, regardless of other fields.
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
some prefix in nonrelease_prefixes
|
||||
startswith(input.branch, prefix)
|
||||
msg := sprintf("branch '%v' may not deploy to prod (non-release branch prefix)", [input.branch])
|
||||
}
|
||||
|
||||
# --- Staging guardrails (lighter) -------------------------------------------
|
||||
|
||||
# Staging may deploy from main or develop, but never from a pull_request event
|
||||
# (PR builds are untrusted and must not push to a shared environment).
|
||||
deny contains msg if {
|
||||
lower(input.environment) == "staging"
|
||||
input.event == "pull_request"
|
||||
msg := "staging deploy must not run on a 'pull_request' event (untrusted ref)"
|
||||
}
|
||||
|
||||
# --- Advisory ---------------------------------------------------------------
|
||||
|
||||
# Warn when a prod deploy is approved by a single reviewer; SOP-002 recommends >=2.
|
||||
warn contains msg if {
|
||||
is_prod
|
||||
review_approved
|
||||
count_approvals < 2
|
||||
msg := sprintf("prod deploy approved by only %v reviewer(s); SOP-002 recommends >= 2", [count_approvals])
|
||||
}
|
||||
|
||||
count_approvals := n if {
|
||||
n := input.review.approvals
|
||||
}
|
||||
|
||||
count_approvals := 1 if {
|
||||
not input.review.approvals
|
||||
input.review.approved == true
|
||||
}
|
||||
78
.standards/policies/ci/image_provenance.rego
Normal file
78
.standards/policies/ci/image_provenance.rego
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Image provenance: every image a pipeline pulls or deploys must come from a trusted
|
||||
# registry and carry an immutable, convention-conformant tag.
|
||||
#
|
||||
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# docs/adr/0008-container-runtime-podman-quadlet.md
|
||||
# SOP: sops/SOP-002-release-process.md (release publishes `image:vX.Y.Z`)
|
||||
# Input: a descriptor carrying one or more image references plus the target env:
|
||||
# {
|
||||
# "environment": "prod",
|
||||
# "images": ["ghcr.io/acme/api:v1.4.0", "docker.io/library/redis:7.2.4"]
|
||||
# }
|
||||
# A single image may also be given as: { "image": "ghcr.io/acme/api:v1.4.0", ... }.
|
||||
#
|
||||
# Rules:
|
||||
# deny - image from a registry not on the allow-list (standards.lib.trusted_registries)
|
||||
# deny - image tag is neither SemVer vX.Y.Z nor a digest/sha pin
|
||||
# deny - moving tag (`latest`, `edge`, ...) used in a production environment
|
||||
# warn - moving tag used in a non-prod environment (tolerated, flagged)
|
||||
package standards.ci.image
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
prod_envs := {"prod", "production"}
|
||||
|
||||
is_prod if {
|
||||
prod_envs[lower(input.environment)]
|
||||
}
|
||||
|
||||
# Normalise: collect every image reference whether given as `images[]` or `image`.
|
||||
images contains ref if {
|
||||
ref := input.images[_]
|
||||
}
|
||||
|
||||
images contains ref if {
|
||||
ref := input.image
|
||||
is_string(ref)
|
||||
}
|
||||
|
||||
# --- Provenance: trusted registry only --------------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
some ref in images
|
||||
not lib.trusted_registry(ref)
|
||||
parts := lib.split_image(ref)
|
||||
msg := sprintf("image '%v' comes from untrusted registry '%v' (allow-list: %v)", [ref, parts.registry, lib.trusted_registries])
|
||||
}
|
||||
|
||||
# --- Tag convention: pinned (SemVer or digest) ------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
some ref in images
|
||||
parts := lib.split_image(ref)
|
||||
not lib.is_pinned_tag(parts.tag)
|
||||
|
||||
# `latest` gets its own, clearer message below; don't double-report it here.
|
||||
not lib.is_mutable_tag(parts.tag)
|
||||
msg := sprintf("image '%v' tag '%v' is not a pinned reference (expected vX.Y.Z or a sha digest)", [ref, parts.tag])
|
||||
}
|
||||
|
||||
# --- Moving tags: hard-deny in prod, warn elsewhere -------------------------
|
||||
|
||||
deny contains msg if {
|
||||
is_prod
|
||||
some ref in images
|
||||
parts := lib.split_image(ref)
|
||||
lib.is_mutable_tag(parts.tag)
|
||||
msg := sprintf("image '%v' uses moving tag '%v' in a production environment (pin to vX.Y.Z or a digest)", [ref, parts.tag])
|
||||
}
|
||||
|
||||
warn contains msg if {
|
||||
not is_prod
|
||||
some ref in images
|
||||
parts := lib.split_image(ref)
|
||||
lib.is_mutable_tag(parts.tag)
|
||||
msg := sprintf("image '%v' uses moving tag '%v'; acceptable in '%v' but pin before promoting to prod", [ref, parts.tag, input.environment])
|
||||
}
|
||||
66
.standards/policies/ci/pipeline_contract.rego
Normal file
66
.standards/policies/ci/pipeline_contract.rego
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Pipeline contract: both CI adapters (GitHub Actions and Woodpecker) must implement
|
||||
# the SAME canonical stage sequence, so a pipeline is portable between them.
|
||||
#
|
||||
# Enforces: docs/adr/0002-dual-target-ci.md
|
||||
# SOP: sops/SOP-001-branch-and-merge.md (the merge gate runs this contract)
|
||||
# Input: a pipeline descriptor listing the stages it defines:
|
||||
# {
|
||||
# "ci": "github",
|
||||
# "stages": ["static-checks", "test", "build", "policy_check",
|
||||
# "security-scan", "publish", "deploy-staging"]
|
||||
# }
|
||||
# Stage names may use the adapter's own spelling; `deploy` is satisfied by either
|
||||
# `deploy-staging` or `deploy-prod`.
|
||||
#
|
||||
# Rules:
|
||||
# deny - any REQUIRED canonical stage is missing from the descriptor
|
||||
package standards.ci.pipeline
|
||||
|
||||
import rego.v1
|
||||
|
||||
# Only evaluate inputs that are actually pipeline descriptors (carry a `stages` list).
|
||||
# Keeps the package silent under conftest --all-namespaces for unrelated inputs.
|
||||
_is_pipeline_input if {
|
||||
is_array(input.stages)
|
||||
}
|
||||
|
||||
# The canonical, ordered set of stages every pipeline must implement. `deploy` is a
|
||||
# logical stage satisfied by a concrete deploy-staging or deploy-prod stage.
|
||||
required_stages := ["static-checks", "test", "build", "policy_check", "security-scan", "publish", "deploy"]
|
||||
|
||||
# The set of stage names the descriptor actually declares, lower-cased.
|
||||
declared_stages contains s if {
|
||||
some raw in input.stages
|
||||
is_string(raw)
|
||||
s := lower(raw)
|
||||
}
|
||||
|
||||
# --- A required stage is missing --------------------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_pipeline_input
|
||||
some required in required_stages
|
||||
not _stage_satisfied(required)
|
||||
msg := sprintf("pipeline is missing required stage '%v'; both CI adapters must implement the canonical contract %v (ADR-0002, SOP-001)", [required, required_stages])
|
||||
}
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
# _stage_satisfied(req) is true when the descriptor declares the required stage.
|
||||
# The logical `deploy` stage is satisfied by deploy-staging OR deploy-prod.
|
||||
_stage_satisfied(req) if {
|
||||
req != "deploy"
|
||||
declared_stages[req]
|
||||
}
|
||||
|
||||
_stage_satisfied("deploy") if {
|
||||
declared_stages["deploy-staging"]
|
||||
}
|
||||
|
||||
_stage_satisfied("deploy") if {
|
||||
declared_stages["deploy-prod"]
|
||||
}
|
||||
|
||||
_stage_satisfied("deploy") if {
|
||||
declared_stages["deploy"]
|
||||
}
|
||||
72
.standards/policies/ci/secrets_in_env.rego
Normal file
72
.standards/policies/ci/secrets_in_env.rego
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Secrets hygiene in pipeline / runtime environment blocks.
|
||||
#
|
||||
# Enforces: docs/adr/0003-secrets-sops-age-fido2.md
|
||||
# docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# SOP: sops/SOP-005-secrets-management.md (no plaintext secrets; SOPS+age + EnvironmentFile)
|
||||
# Input: a descriptor with an environment map and optional metadata:
|
||||
# {
|
||||
# "environment": "staging",
|
||||
# "env": { "LOG_LEVEL": "info", "API_TOKEN": "${API_TOKEN}" },
|
||||
# "env_files": ["%h/.config/mypods/api.env"],
|
||||
# "env_origin": { "DB_PASSWORD": "prod" } # optional: which env a secret was sourced from
|
||||
# }
|
||||
#
|
||||
# Rules:
|
||||
# deny - a secret-NAMED env var whose VALUE is an inlined plaintext secret
|
||||
# (not a ${VAR}/%VAR% reference and not empty)
|
||||
# deny - any env VALUE that matches a known secret shape (PEM key, token prefix)
|
||||
# deny - a secret sourced from a 'prod' origin used in a non-prod ('staging'/'dev') env
|
||||
# warn - secret-named vars present but no EnvironmentFile declared (should use SOPS+age file)
|
||||
package standards.ci.secrets
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
prod_envs := {"prod", "production"}
|
||||
|
||||
is_prod if {
|
||||
prod_envs[lower(input.environment)]
|
||||
}
|
||||
|
||||
env := object.get(input, "env", {})
|
||||
|
||||
env_files := object.get(input, "env_files", [])
|
||||
|
||||
# --- Plaintext secret in a secret-named variable ----------------------------
|
||||
|
||||
deny contains msg if {
|
||||
some k, val in env
|
||||
lib.looks_like_secret_name(k)
|
||||
is_string(val)
|
||||
not lib.is_placeholder(val)
|
||||
msg := sprintf("env '%v' looks like a secret but holds an inline value; reference it via SOPS+age/EnvironmentFile instead (SOP-005)", [k])
|
||||
}
|
||||
|
||||
# --- Value that matches a known secret shape, regardless of the var name -----
|
||||
|
||||
deny contains msg if {
|
||||
some k, val in env
|
||||
lib.looks_like_secret_value(val)
|
||||
not lib.is_placeholder(val)
|
||||
msg := sprintf("env '%v' value matches a secret pattern (private key / token); never commit plaintext secrets (SOP-005)", [k])
|
||||
}
|
||||
|
||||
# --- Cross-environment secret bleed: prod secret used in a lower env ---------
|
||||
|
||||
deny contains msg if {
|
||||
not is_prod
|
||||
origin := object.get(input, "env_origin", {})
|
||||
some k, src in origin
|
||||
lower(src) == "prod"
|
||||
msg := sprintf("env '%v' is sourced from a PROD secret but used in '%v'; environments must not share secret material (ADR-0006, SOP-005)", [k, input.environment])
|
||||
}
|
||||
|
||||
# --- Advisory: secret-named vars but no EnvironmentFile pattern --------------
|
||||
|
||||
warn contains msg if {
|
||||
some k, _ in env
|
||||
lib.looks_like_secret_name(k)
|
||||
count(env_files) == 0
|
||||
msg := sprintf("env '%v' is secret-shaped but no EnvironmentFile is declared; load secrets from a SOPS-decrypted env file (SOP-005)", [k])
|
||||
}
|
||||
136
.standards/policies/ci/tenant_separation.rego
Normal file
136
.standards/policies/ci/tenant_separation.rego
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Tenant (Mandant) separation: a deploy/manifest artifact belonging to one Mandant
|
||||
# must never reference another Mandant's resources, paths, or age recipients.
|
||||
#
|
||||
# Enforces: docs/adr/0006-multi-tenant-separation.md
|
||||
# SOP: sops/SOP-005-secrets-management.md (per-Mandant SOPS+age key sets)
|
||||
# Input: a deploy/manifest descriptor carrying the owning Mandant and the
|
||||
# resources it references:
|
||||
# {
|
||||
# "mandant": "gmbh-a",
|
||||
# "resources": ["secrets-gmbh-a/db.env", "ops-gmbh-a/deploy.yml"],
|
||||
# "paths": ["/srv/gmbh-a/data"],
|
||||
# "age_recipients": ["age1aaa...gmbh-a-key"]
|
||||
# }
|
||||
# `resources` and `paths` are referenced artifact paths; `age_recipients` are the
|
||||
# age public keys a SOPS-encrypted secret is sealed to.
|
||||
#
|
||||
# Model: every Mandant owns a namespace token `gmbh-<x>`. A reference is cross-tenant
|
||||
# when it names ANOTHER Mandant's namespace (e.g. a `gmbh-b` path in a `gmbh-a`
|
||||
# artifact) or an age recipient that is not in this Mandant's allowed recipient set.
|
||||
#
|
||||
# Rules (default-deny cross-tenant):
|
||||
# deny - the artifact declares no Mandant (cannot be attributed -> reject)
|
||||
# deny - a referenced resource/path names a foreign Mandant namespace
|
||||
# deny - an age recipient is not on this Mandant's allowed set
|
||||
package standards.ci.tenant
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
# Only evaluate inputs that are actually tenant descriptors (carry a Mandant marker).
|
||||
# This keeps the package quiet under conftest --all-namespaces for unrelated inputs
|
||||
# (k8s/compose/Quadlet objects), while still default-denying an unattributed artifact
|
||||
# that DOES look like a tenant descriptor (has resources/paths/age_recipients).
|
||||
_is_tenant_input if {
|
||||
lib.has_key(input, "mandant")
|
||||
}
|
||||
|
||||
_is_tenant_input if {
|
||||
lib.has_key(input, "resources")
|
||||
}
|
||||
|
||||
_is_tenant_input if {
|
||||
lib.has_key(input, "age_recipients")
|
||||
}
|
||||
|
||||
# All known Mandant namespace tokens. Grounded in ADR-0006's example tenants; the
|
||||
# set is the universe of foreign namespaces a reference may accidentally point at.
|
||||
known_mandanten := {"gmbh-a", "gmbh-b"}
|
||||
|
||||
# The owning Mandant of this artifact, lower-cased, or "" when absent/empty.
|
||||
# object.get with a default keeps this total even when the key is missing entirely,
|
||||
# so the `mandant == ""` default-deny below fires for an unattributed artifact.
|
||||
mandant := m if {
|
||||
raw := object.get(input, "mandant", "")
|
||||
lib.non_empty_string(raw)
|
||||
m := lower(raw)
|
||||
}
|
||||
|
||||
mandant := "" if {
|
||||
raw := object.get(input, "mandant", "")
|
||||
not lib.non_empty_string(raw)
|
||||
}
|
||||
|
||||
# Allowed age recipients for the owning Mandant (from input.allowed_age_recipients,
|
||||
# keyed by Mandant). When the artifact provides no allow-list we cannot vouch for any
|
||||
# recipient, so every declared recipient is treated as foreign (default-deny).
|
||||
allowed_recipients := r if {
|
||||
all := object.get(input, "allowed_age_recipients", {})
|
||||
r := object.get(all, mandant, [])
|
||||
}
|
||||
|
||||
# Every referenced path/resource string, collected from the supported fields.
|
||||
references contains ref if {
|
||||
ref := object.get(input, "resources", [])[_]
|
||||
}
|
||||
|
||||
references contains ref if {
|
||||
ref := object.get(input, "paths", [])[_]
|
||||
}
|
||||
|
||||
# --- Default-deny: an unattributed artifact ---------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_tenant_input
|
||||
mandant == ""
|
||||
msg := "artifact declares no 'mandant'; cross-tenant separation cannot be enforced — every Mandant artifact must be attributed (ADR-0006, SOP-005)"
|
||||
}
|
||||
|
||||
# --- Cross-tenant reference: a foreign Mandant namespace --------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_tenant_input
|
||||
mandant != ""
|
||||
some ref in references
|
||||
is_string(ref)
|
||||
some foreign in known_mandanten
|
||||
foreign != mandant
|
||||
_references_namespace(ref, foreign)
|
||||
msg := sprintf("mandant '%v' artifact references foreign tenant resource '%v' (matches '%v'); tenants must not share resources (ADR-0006, SOP-005)", [mandant, ref, foreign])
|
||||
}
|
||||
|
||||
# --- Cross-tenant age recipient: not on this Mandant's allowed set ----------
|
||||
|
||||
deny contains msg if {
|
||||
_is_tenant_input
|
||||
mandant != ""
|
||||
some rcpt in object.get(input, "age_recipients", [])
|
||||
is_string(rcpt)
|
||||
not _recipient_allowed(rcpt)
|
||||
msg := sprintf("mandant '%v' seals a secret to age recipient '%v' which is not in its allowed recipient set; each Mandant must use only its own age keys (ADR-0006, SOP-005)", [mandant, rcpt])
|
||||
}
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
# _references_namespace(ref, ns) is true when a path/resource string carries the
|
||||
# foreign Mandant namespace, either as a `secrets-<ns>` / `ops-<ns>` prefix or as a
|
||||
# path segment `<ns>` anywhere in the reference.
|
||||
_references_namespace(ref, ns) if {
|
||||
contains(lower(ref), sprintf("secrets-%v", [ns]))
|
||||
}
|
||||
|
||||
_references_namespace(ref, ns) if {
|
||||
contains(lower(ref), sprintf("ops-%v", [ns]))
|
||||
}
|
||||
|
||||
_references_namespace(ref, ns) if {
|
||||
parts := split(lower(ref), "/")
|
||||
parts[_] == ns
|
||||
}
|
||||
|
||||
# _recipient_allowed(rcpt) is true when the age recipient is on this Mandant's set.
|
||||
_recipient_allowed(rcpt) if {
|
||||
some allowed in allowed_recipients
|
||||
allowed == rcpt
|
||||
}
|
||||
18
.standards/policies/conftest.toml
Normal file
18
.standards/policies/conftest.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Conftest configuration for the `standards` Policy-as-Code package.
|
||||
#
|
||||
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# Followed by: sops/SOP-002-release-process.md, sops/SOP-005-secrets-management.md
|
||||
#
|
||||
# Config keys mirror conftest's CLI flags (hyphenated). Conftest auto-discovers a
|
||||
# `conftest.toml` in the policy directory / working directory, so running
|
||||
# `conftest test <manifest>` from `policies/` picks these up with no extra flags.
|
||||
#
|
||||
# `all-namespaces = true` makes conftest evaluate `deny`/`warn` rules from EVERY
|
||||
# Rego package it loads (standards.ci.*, standards.quadlet.*, standards.containerfile.*),
|
||||
# so a single `conftest test <manifest>` exercises every relevant policy. Without it,
|
||||
# conftest only looks at the `main` namespace and silently ignores our namespaced rules.
|
||||
#
|
||||
# `policy = "."` points conftest at this directory (run it from `policies/`), so the
|
||||
# lib/, ci/, quadlet/ and containerfile/ subtrees are all discovered.
|
||||
policy = "."
|
||||
all-namespaces = true
|
||||
177
.standards/policies/containerfile/containerfile_hardening.rego
Normal file
177
.standards/policies/containerfile/containerfile_hardening.rego
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Containerfile / Dockerfile hardening for the mypods image builds.
|
||||
#
|
||||
# Enforces: docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# docs/adr/0008-container-runtime-podman-quadlet.md
|
||||
# SOP: sops/SOP-002-release-process.md (images are built then published)
|
||||
# Input: a parsed Containerfile as an ordered list of instructions:
|
||||
# {
|
||||
# "instructions": [
|
||||
# {"cmd": "FROM", "value": "archlinux:latest"},
|
||||
# {"cmd": "ARG", "value": "API_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx"},
|
||||
# {"cmd": "USER", "value": "app"},
|
||||
# {"cmd": "RUN", "value": "pacman -Syu"}
|
||||
# ]
|
||||
# }
|
||||
# `cmd` is the instruction; `value` is the remainder of the line.
|
||||
# (A frontend such as `dockerfile_parse` or a small awk shim produces this shape;
|
||||
# conftest's built-in Dockerfile parser yields a compatible `Cmd`/`Value` form,
|
||||
# handled by the accessor helpers below.)
|
||||
#
|
||||
# Rules:
|
||||
# deny - a secret-looking value baked into ARG or ENV
|
||||
# warn - a FROM with an unpinned/moving base tag (no tag, or :latest)
|
||||
# warn - the final effective user is root (no non-root USER set)
|
||||
package standards.containerfile.hardening
|
||||
|
||||
import rego.v1
|
||||
|
||||
import data.standards.lib
|
||||
|
||||
# _is_containerfile gates every rule below so this package only fires on inputs that
|
||||
# are actually parsed Containerfiles. The expected shape carries an `instructions`
|
||||
# ARRAY (our {cmd,value} list, or conftest's Dockerfile parser output). Without this
|
||||
# guard, an unrelated YAML object (e.g. a k8s manifest with no `instructions`) would
|
||||
# spuriously trip the "never sets a non-root USER" warn under conftest --all-namespaces.
|
||||
_is_containerfile if {
|
||||
is_array(input.instructions)
|
||||
}
|
||||
|
||||
# Normalise instructions: accept either our {cmd,value} shape or conftest's
|
||||
# {Cmd, Value:[...]} Dockerfile-parser shape. Keyed by index to preserve order.
|
||||
instructions[i] := inst if {
|
||||
some i
|
||||
raw := input.instructions[i]
|
||||
inst := {"cmd": _cmd_of(raw), "value": _value_of(raw)}
|
||||
}
|
||||
|
||||
_cmd_of(raw) := c if {
|
||||
raw.cmd
|
||||
c := upper(raw.cmd)
|
||||
}
|
||||
|
||||
_cmd_of(raw) := c if {
|
||||
not raw.cmd
|
||||
c := upper(raw.Cmd)
|
||||
}
|
||||
|
||||
_value_of(raw) := v if {
|
||||
is_string(raw.value)
|
||||
v := raw.value
|
||||
}
|
||||
|
||||
_value_of(raw) := v if {
|
||||
not raw.value
|
||||
is_array(raw.Value)
|
||||
v := concat(" ", raw.Value)
|
||||
}
|
||||
|
||||
_value_of(raw) := v if {
|
||||
not raw.value
|
||||
is_string(raw.Value)
|
||||
v := raw.Value
|
||||
}
|
||||
|
||||
# --- Secrets baked into the image (ARG / ENV) -------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_containerfile
|
||||
some i
|
||||
inst := instructions[i]
|
||||
{"ARG", "ENV"}[inst.cmd]
|
||||
parts := split(inst.value, "=")
|
||||
count(parts) >= 2
|
||||
val := concat("=", array.slice(parts, 1, count(parts)))
|
||||
lib.looks_like_secret_value(trim_space(val))
|
||||
msg := sprintf("%v sets '%v' to a secret-looking value; never bake secrets into an image layer (SOP-005)", [inst.cmd, parts[0]])
|
||||
}
|
||||
|
||||
# Also catch a secret-NAMED ARG/ENV with a non-placeholder literal value.
|
||||
deny contains msg if {
|
||||
_is_containerfile
|
||||
some i
|
||||
inst := instructions[i]
|
||||
{"ARG", "ENV"}[inst.cmd]
|
||||
parts := split(inst.value, "=")
|
||||
count(parts) >= 2
|
||||
key := parts[0]
|
||||
lib.looks_like_secret_name(key)
|
||||
val := trim_space(concat("=", array.slice(parts, 1, count(parts))))
|
||||
not lib.is_placeholder(val)
|
||||
val != ""
|
||||
msg := sprintf("%v '%v' is secret-named with an inline value; pass secrets at runtime, not in the image (SOP-005)", [inst.cmd, key])
|
||||
}
|
||||
|
||||
# --- Unpinned base image -----------------------------------------------------
|
||||
|
||||
warn contains msg if {
|
||||
_is_containerfile
|
||||
some i
|
||||
inst := instructions[i]
|
||||
inst.cmd == "FROM"
|
||||
base := _from_image(inst.value)
|
||||
parts := lib.split_image(base)
|
||||
lib.is_mutable_tag(parts.tag)
|
||||
msg := sprintf("FROM '%v' uses an unpinned/moving base tag '%v'; pin to a SemVer or digest for reproducible builds", [base, parts.tag])
|
||||
}
|
||||
|
||||
# --- Running as root ---------------------------------------------------------
|
||||
|
||||
# Warn when no non-root USER is ever declared: the build runs as root by default.
|
||||
warn contains msg if {
|
||||
_is_containerfile
|
||||
not has_nonroot_user
|
||||
msg := "Containerfile never sets a non-root USER; the image runs as root by default — add `USER <non-root>` where the workload allows (ADR-0008)"
|
||||
}
|
||||
|
||||
# Warn when the LAST USER instruction puts the runtime user back to root.
|
||||
warn contains msg if {
|
||||
_is_containerfile
|
||||
last_user := _last_user
|
||||
last_user != ""
|
||||
lower(last_user) == "root"
|
||||
msg := "the final USER in the Containerfile is root; drop privileges before the image's default command runs (ADR-0008)"
|
||||
}
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
# _from_image(value) strips an `AS <stage>` suffix and `--platform=` flags from a FROM line.
|
||||
_from_image(value) := img if {
|
||||
toks := split(trim_space(value), " ")
|
||||
img := _first_non_flag(toks)
|
||||
}
|
||||
|
||||
_first_non_flag(toks) := t if {
|
||||
some i
|
||||
t := toks[i]
|
||||
not startswith(t, "--")
|
||||
|
||||
# the image ref is the first non-flag token
|
||||
count([x | some j; x := toks[j]; j < i; not startswith(x, "--")]) == 0
|
||||
}
|
||||
|
||||
# user_instructions: every USER value, keyed by its instruction index.
|
||||
user_instructions[idx] := val if {
|
||||
some idx
|
||||
inst := instructions[idx]
|
||||
inst.cmd == "USER"
|
||||
val := trim_space(inst.value)
|
||||
}
|
||||
|
||||
has_nonroot_user if {
|
||||
some idx
|
||||
val := user_instructions[idx]
|
||||
lower(val) != "root"
|
||||
val != ""
|
||||
}
|
||||
|
||||
# _last_user returns the value of the last USER instruction, or "" if none.
|
||||
_last_user := val if {
|
||||
idxs := [i | some i; user_instructions[i]]
|
||||
count(idxs) > 0
|
||||
max_idx := max(idxs)
|
||||
val := user_instructions[max_idx]
|
||||
}
|
||||
|
||||
_last_user := "" if {
|
||||
count([i | some i; user_instructions[i]]) == 0
|
||||
}
|
||||
283
.standards/policies/lib/util.rego
Normal file
283
.standards/policies/lib/util.rego
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# Shared helpers for the `standards` policy packages.
|
||||
#
|
||||
# Enforces (supports): docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# Used by: standards.ci.*, standards.quadlet.*, standards.containerfile.*
|
||||
#
|
||||
# Keep this package side-effect free: ONLY pure helper functions/rules, no `deny`/`warn`.
|
||||
# One concept per helper so the call sites in the policy files stay readable and testable.
|
||||
#
|
||||
# Written in Rego v1 (`import rego.v1`): rules use `contains`/`if`, which is what the
|
||||
# installed OPA 1.x and conftest's bundled OPA require. The Conftest contract is
|
||||
# unchanged — policies still expose `deny`/`warn` partial sets of message strings.
|
||||
package standards.lib
|
||||
|
||||
import rego.v1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Object / key helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# has_key(obj, k) is true when object `obj` contains key `k`.
|
||||
has_key(obj, k) if {
|
||||
_ := obj[k]
|
||||
}
|
||||
|
||||
# get_default(obj, k, fallback) returns obj[k] if present, else `fallback`.
|
||||
get_default(obj, k, _) := v if {
|
||||
has_key(obj, k)
|
||||
v := obj[k]
|
||||
}
|
||||
|
||||
get_default(obj, k, fallback) := v if {
|
||||
not has_key(obj, k)
|
||||
v := fallback
|
||||
}
|
||||
|
||||
# non_empty_string(x) is true when x is a string with length > 0.
|
||||
non_empty_string(x) if {
|
||||
is_string(x)
|
||||
count(x) > 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# String helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# to_lower_safe(x) lower-cases a string; passes non-strings through unchanged so
|
||||
# call sites can stay defensive about heterogeneous input.
|
||||
to_lower_safe(x) := out if {
|
||||
is_string(x)
|
||||
out := lower(x)
|
||||
}
|
||||
|
||||
to_lower_safe(x) := out if {
|
||||
not is_string(x)
|
||||
out := x
|
||||
}
|
||||
|
||||
# contains_any(haystack, needles) is true if any needle is a substring of haystack.
|
||||
contains_any(haystack, needles) if {
|
||||
some i
|
||||
contains(haystack, needles[i])
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Semver / tag helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# is_semver(tag) matches OUR release-tag convention `vX.Y.Z` (docs/00-overview.md:
|
||||
# git tags `vX.Y.Z`). Pre-release/build suffixes are allowed, e.g. v1.2.3-rc.1 or
|
||||
# v1.2.3+build.5. The leading `v` is REQUIRED — this is the form the deploy gate
|
||||
# enforces on a release tag. For accepting third-party pins like `redis:7.2.4`,
|
||||
# use is_pinned_tag (which also accepts a bare X.Y.Z).
|
||||
is_semver(tag) if {
|
||||
regex.match(`^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.\-]+)?$`, tag)
|
||||
}
|
||||
|
||||
# is_bare_semver(tag) matches a SemVer WITHOUT the leading `v`, e.g. `7.2.4`.
|
||||
# Upstream images (redis, postgres, ...) tag this way; such a tag is still an
|
||||
# immutable, reproducible pin even though it is not OUR `vX.Y.Z` release form.
|
||||
is_bare_semver(tag) if {
|
||||
regex.match(`^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.\-]+)?$`, tag)
|
||||
}
|
||||
|
||||
# is_sha_tag(tag) matches an immutable content tag: a (short or long) hex digest,
|
||||
# optionally `sha-` / `sha256-` / `sha256:` prefixed, e.g. `sha-1a2b3c4`,
|
||||
# `sha256:...`, `a1b2c3d4`.
|
||||
is_sha_tag(tag) if {
|
||||
regex.match(`^(sha-|sha256-|sha256:)?[0-9a-f]{7,64}$`, tag)
|
||||
}
|
||||
|
||||
# is_pinned_tag(tag) is true when the tag is an immutable, reproducible reference:
|
||||
# our `vX.Y.Z` release form, a bare upstream `X.Y.Z`, or a sha digest. Never a
|
||||
# moving tag like `latest`.
|
||||
is_pinned_tag(tag) if {
|
||||
is_semver(tag)
|
||||
}
|
||||
|
||||
is_pinned_tag(tag) if {
|
||||
is_bare_semver(tag)
|
||||
}
|
||||
|
||||
is_pinned_tag(tag) if {
|
||||
is_sha_tag(tag)
|
||||
}
|
||||
|
||||
# is_mutable_tag(tag) flags the well-known moving tags that break reproducibility.
|
||||
is_mutable_tag(tag) if {
|
||||
mutable := {"latest", "edge", "main", "master", "stable", "nightly", "dev"}
|
||||
mutable[lower(tag)]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image reference helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# split_image(ref) decomposes an image reference into {registry, repo, tag}.
|
||||
# Rules (Docker/OCI semantics):
|
||||
# - if the first path segment contains a "." or a ":" (or is "localhost"),
|
||||
# it is the registry; otherwise the registry defaults to "docker.io".
|
||||
# - the tag is the part after the LAST ":" that is not part of the registry host.
|
||||
# Digest pins (`@sha256:...`) are normalised so the digest is returned as the tag.
|
||||
split_image(ref) := parts if {
|
||||
# Digest form: repo@sha256:hex
|
||||
contains(ref, "@")
|
||||
name := split(ref, "@")[0]
|
||||
digest := split(ref, "@")[1]
|
||||
reg := _registry_of(name)
|
||||
repo := _repo_of(name, reg)
|
||||
parts := {"registry": reg, "repo": repo, "tag": digest}
|
||||
}
|
||||
|
||||
split_image(ref) := parts if {
|
||||
# Tagged form: [registry/]repo:tag (no digest)
|
||||
not contains(ref, "@")
|
||||
reg := _registry_of(ref)
|
||||
rest := _strip_registry(ref, reg)
|
||||
_has_tag(rest)
|
||||
repo := split(rest, ":")[0]
|
||||
tag := split(rest, ":")[1]
|
||||
parts := {"registry": reg, "repo": repo, "tag": tag}
|
||||
}
|
||||
|
||||
split_image(ref) := parts if {
|
||||
# Untagged form: [registry/]repo -> tag defaults to "latest" (Docker behaviour)
|
||||
not contains(ref, "@")
|
||||
reg := _registry_of(ref)
|
||||
rest := _strip_registry(ref, reg)
|
||||
not _has_tag(rest)
|
||||
parts := {"registry": reg, "repo": rest, "tag": "latest"}
|
||||
}
|
||||
|
||||
# _has_tag is true when the repo portion (registry already stripped) carries a `:tag`.
|
||||
_has_tag(rest) if {
|
||||
contains(rest, ":")
|
||||
}
|
||||
|
||||
# _first_segment returns the substring before the first "/".
|
||||
_first_segment(ref) := seg if {
|
||||
contains(ref, "/")
|
||||
seg := split(ref, "/")[0]
|
||||
}
|
||||
|
||||
_first_segment(ref) := seg if {
|
||||
not contains(ref, "/")
|
||||
seg := ref
|
||||
}
|
||||
|
||||
# _looks_like_host(seg) is true when a path segment is a registry host:
|
||||
# it contains a "." (domain) or ":" (port) or equals "localhost".
|
||||
_looks_like_host(seg) if {
|
||||
contains(seg, ".")
|
||||
}
|
||||
|
||||
_looks_like_host(seg) if {
|
||||
contains(seg, ":")
|
||||
}
|
||||
|
||||
_looks_like_host(seg) if {
|
||||
seg == "localhost"
|
||||
}
|
||||
|
||||
# _registry_of(ref) returns the registry host, defaulting to docker.io.
|
||||
_registry_of(ref) := reg if {
|
||||
seg := _first_segment(ref)
|
||||
_looks_like_host(seg)
|
||||
reg := seg
|
||||
}
|
||||
|
||||
_registry_of(ref) := reg if {
|
||||
seg := _first_segment(ref)
|
||||
not _looks_like_host(seg)
|
||||
reg := "docker.io"
|
||||
}
|
||||
|
||||
# _strip_registry(ref, reg) removes an explicit registry prefix, leaving repo[:tag].
|
||||
_strip_registry(ref, reg) := rest if {
|
||||
startswith(ref, concat("", [reg, "/"]))
|
||||
rest := substring(ref, count(reg) + 1, -1)
|
||||
}
|
||||
|
||||
_strip_registry(ref, reg) := rest if {
|
||||
not startswith(ref, concat("", [reg, "/"]))
|
||||
rest := ref
|
||||
}
|
||||
|
||||
# _repo_of(name, reg) returns the repo path with any registry prefix removed.
|
||||
_repo_of(name, reg) := repo if {
|
||||
repo := _strip_registry(name, reg)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trusted registry policy data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# trusted_registries is the allow-list of registries images may come from.
|
||||
# Grounded in the ecosystem (docs/00-overview.md §3): our own GHCR namespace,
|
||||
# the self-hosted Forgejo registry target, Docker Hub for vetted upstreams, and
|
||||
# `mypods/*` first-party images that are built locally (registry resolves to docker.io).
|
||||
trusted_registries := {
|
||||
"ghcr.io",
|
||||
"registry.forgejo.local",
|
||||
"docker.io",
|
||||
"localhost",
|
||||
}
|
||||
|
||||
# trusted_registry(ref) is true when the image reference resolves to a registry on
|
||||
# the allow-list above.
|
||||
trusted_registry(ref) if {
|
||||
parts := split_image(ref)
|
||||
trusted_registries[parts.registry]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret-shaped value detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# secret_name_pattern matches env var NAMES that conventionally hold secrets.
|
||||
secret_name_pattern := `(?i)(SECRET|PASSWORD|PASSWD|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|PASSPHRASE)`
|
||||
|
||||
# looks_like_secret_name(name) flags an env key whose name implies a secret.
|
||||
looks_like_secret_name(name) if {
|
||||
regex.match(secret_name_pattern, name)
|
||||
}
|
||||
|
||||
# looks_like_secret_value(val) flags a VALUE that looks like an inlined secret:
|
||||
# long high-entropy-ish strings, known token prefixes, or PEM private-key markers.
|
||||
looks_like_secret_value(val) if {
|
||||
is_string(val)
|
||||
regex.match(`-----BEGIN [A-Z ]*PRIVATE KEY-----`, val)
|
||||
}
|
||||
|
||||
looks_like_secret_value(val) if {
|
||||
is_string(val)
|
||||
|
||||
# Common token prefixes: GitHub (ghp_/gho_/ghs_), Slack (xox...), AWS (AKIA...).
|
||||
regex.match(`^(gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16})`, val)
|
||||
}
|
||||
|
||||
looks_like_secret_value(val) if {
|
||||
is_string(val)
|
||||
|
||||
# Long opaque string with no spaces -> likely a baked-in credential, not a flag.
|
||||
count(val) >= 24
|
||||
not contains(val, " ")
|
||||
regex.match(`^[A-Za-z0-9+/=_-]{24,}$`, val)
|
||||
}
|
||||
|
||||
# is_placeholder(val) recognises references/placeholders that are NOT plaintext
|
||||
# secrets: env-substitution (${VAR}, $VAR, %VAR%), Quadlet specifiers (%h), or the
|
||||
# empty string. Used to avoid false positives on indirection.
|
||||
is_placeholder(val) if {
|
||||
is_string(val)
|
||||
regex.match(`^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$`, val)
|
||||
}
|
||||
|
||||
is_placeholder(val) if {
|
||||
is_string(val)
|
||||
regex.match(`^%[A-Za-z_][A-Za-z0-9_]*%$`, val)
|
||||
}
|
||||
|
||||
is_placeholder(val) if {
|
||||
val == ""
|
||||
}
|
||||
127
.standards/policies/quadlet/quadlet_security.rego
Normal file
127
.standards/policies/quadlet/quadlet_security.rego
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Quadlet hardening: lint podctl/mypods `.container` units (systemd --user Quadlet)
|
||||
# for safe-by-default container settings.
|
||||
#
|
||||
# Enforces: docs/adr/0008-container-runtime-podman-quadlet.md
|
||||
# docs/adr/0004-policy-as-code-opa-conftest.md
|
||||
# SOP: sops/SOP-004-environment-setup.md (provisioning Quadlet units)
|
||||
# Input: a parsed Quadlet INI as a nested object. Section headers ([Container],
|
||||
# [Service], [Unit]) are top-level keys; each maps to an object of its
|
||||
# directives. Directives that may legally repeat (AddDevice, GroupAdd,
|
||||
# Volume, PublishPort, Environment) are arrays; single-valued directives
|
||||
# are scalars. Example (from podctl preset llama-gpt-oss-120b.container):
|
||||
# {
|
||||
# "Container": {
|
||||
# "ContainerName": "llama-gpt-oss-120b",
|
||||
# "Image": "docker.io/kyuz0/amd-strix-halo-toolboxes:vulkan-radv",
|
||||
# "Pull": "never",
|
||||
# "AddDevice": ["/dev/kfd", "/dev/dri"],
|
||||
# "GroupAdd": ["video", "render"]
|
||||
# },
|
||||
# "Service": { "Restart": "on-failure", "TimeoutStopSec": "30" }
|
||||
# }
|
||||
#
|
||||
# Rules:
|
||||
# deny - container is privileged (PrivilegedTrue / extreme cap grants)
|
||||
# deny - no Restart= in [Service] (units must be self-healing)
|
||||
# warn - User=root (rootless Podman is the target; running as root in-container is a smell)
|
||||
# warn - AddDevice present without any GroupAdd (device access usually needs a group, e.g. render/video)
|
||||
package standards.quadlet.security
|
||||
|
||||
import rego.v1
|
||||
|
||||
container := object.get(input, "Container", {})
|
||||
|
||||
service := object.get(input, "Service", {})
|
||||
|
||||
# _is_quadlet gates every rule below so this package only fires on inputs that are
|
||||
# actually parsed Quadlet units. A Quadlet INI is recognised by the presence of a
|
||||
# [Container] section (every .container unit has one) or a [Unit] section. Without
|
||||
# this guard, an unrelated YAML object (e.g. a k8s manifest with no [Service].Restart)
|
||||
# would spuriously trip the "missing Restart" deny under conftest --all-namespaces.
|
||||
_is_quadlet if {
|
||||
_has_key(input, "Container")
|
||||
}
|
||||
|
||||
_is_quadlet if {
|
||||
_has_key(input, "Unit")
|
||||
}
|
||||
|
||||
# --- Privileged containers are forbidden ------------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_quadlet
|
||||
val := object.get(container, "PrivilegedTrue", "")
|
||||
lower(format_int_or_string(val)) == "true"
|
||||
msg := "Quadlet runs a privileged container (PrivilegedTrue=true); privileged mode is forbidden (ADR-0008)"
|
||||
}
|
||||
|
||||
# Granting all capabilities is equivalent to privileged.
|
||||
deny contains msg if {
|
||||
_is_quadlet
|
||||
caps := object.get(container, "AddCapability", [])
|
||||
some c in _as_array(caps)
|
||||
upper(c) == "ALL"
|
||||
msg := "Quadlet grants AddCapability=ALL; this is equivalent to privileged and is forbidden (ADR-0008)"
|
||||
}
|
||||
|
||||
# --- A unit must declare a restart policy ------------------------------------
|
||||
|
||||
deny contains msg if {
|
||||
_is_quadlet
|
||||
not _has_key(service, "Restart")
|
||||
msg := "Quadlet [Service] has no Restart= directive; units must be self-healing (e.g. Restart=on-failure)"
|
||||
}
|
||||
|
||||
# Restart=no defeats the purpose; treat it as a violation.
|
||||
deny contains msg if {
|
||||
_is_quadlet
|
||||
lower(format_int_or_string(object.get(service, "Restart", ""))) == "no"
|
||||
msg := "Quadlet [Service] sets Restart=no; declare a real restart policy (e.g. on-failure / always)"
|
||||
}
|
||||
|
||||
# --- Advisory: running as root inside the container --------------------------
|
||||
|
||||
warn contains msg if {
|
||||
_is_quadlet
|
||||
lower(format_int_or_string(object.get(container, "User", ""))) == "root"
|
||||
msg := "Quadlet sets User=root inside the container; prefer a non-root User= where the image allows it (ADR-0008)"
|
||||
}
|
||||
|
||||
# --- Advisory: device access without a supplementary group -------------------
|
||||
|
||||
warn contains msg if {
|
||||
_is_quadlet
|
||||
devices := object.get(container, "AddDevice", [])
|
||||
count(_as_array(devices)) > 0
|
||||
groups := object.get(container, "GroupAdd", [])
|
||||
count(_as_array(groups)) == 0
|
||||
msg := "Quadlet uses AddDevice without any GroupAdd; device nodes (e.g. /dev/dri) usually require a supplementary group such as render/video"
|
||||
}
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
_has_key(obj, k) if {
|
||||
_ := obj[k]
|
||||
}
|
||||
|
||||
# _as_array(x) normalises a scalar-or-array directive into an array, so rules can
|
||||
# iterate uniformly whether a directive appeared once or many times.
|
||||
_as_array(x) := x if {
|
||||
is_array(x)
|
||||
}
|
||||
|
||||
_as_array(x) := [x] if {
|
||||
not is_array(x)
|
||||
}
|
||||
|
||||
# format_int_or_string(x) stringifies scalars (Quadlet values may be parsed as
|
||||
# numbers, e.g. TimeoutStopSec=30) so `lower`/`upper` never see a non-string.
|
||||
format_int_or_string(x) := out if {
|
||||
is_string(x)
|
||||
out := x
|
||||
}
|
||||
|
||||
format_int_or_string(x) := out if {
|
||||
not is_string(x)
|
||||
out := sprintf("%v", [x])
|
||||
}
|
||||
58
.standards/policy-gate.sh
Normal file
58
.standards/policy-gate.sh
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# policy-gate.sh — vendored standards policy gate.
|
||||
#
|
||||
# Runs the standard's Rego policies (vendored at .standards/policies/) against this
|
||||
# repo's manifests with conftest. Auto-detects artifact types and applies the right
|
||||
# parser + namespace so policies never cross-fire:
|
||||
# *.container -> --parser ini --namespace standards.quadlet.security
|
||||
# Containerfile*/Dockerfile* -> --parser dockerfile --namespace standards.containerfile.hardening
|
||||
# deploy/deploy-descriptor.json -> --namespace standards.ci.deploy / standards.ci.image
|
||||
#
|
||||
# Fails (exit 1) only on a policy `deny`; `warn`s are reported but do not block —
|
||||
# this is the "gates over gatekeepers" model (see the standard's docs/00-overview.md).
|
||||
# Part of the standard; do NOT edit by hand — re-run adopt-standard.sh to update.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob globstar
|
||||
|
||||
POL=".standards/policies"
|
||||
rc=0
|
||||
|
||||
if [[ ! -d "${POL}" ]]; then
|
||||
echo "policy-gate: ${POL} not found — run adopt-standard.sh to vendor the policies." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run() { # run <label> <conftest-args...>
|
||||
local label="$1"; shift
|
||||
echo "== ${label}"
|
||||
conftest test "$@" -p "${POL}" || rc=1
|
||||
}
|
||||
|
||||
# Quadlet systemd units.
|
||||
quadlets=( **/*.container )
|
||||
if (( ${#quadlets[@]} )); then
|
||||
run "quadlet policy (${#quadlets[@]} unit(s))" \
|
||||
"${quadlets[@]}" --parser ini --namespace standards.quadlet.security
|
||||
fi
|
||||
|
||||
# Containerfiles / Dockerfiles.
|
||||
cfiles=( Containerfile* **/Containerfile* Dockerfile* **/Dockerfile* )
|
||||
if (( ${#cfiles[@]} )); then
|
||||
run "containerfile policy (${#cfiles[@]} file(s))" \
|
||||
"${cfiles[@]}" --parser dockerfile --namespace standards.containerfile.hardening
|
||||
fi
|
||||
|
||||
# Optional deploy descriptor for the CI/deploy/image policies.
|
||||
if [[ -f deploy/deploy-descriptor.json ]]; then
|
||||
run "ci policy (deploy descriptor)" \
|
||||
deploy/deploy-descriptor.json \
|
||||
--namespace standards.ci.deploy --namespace standards.ci.image
|
||||
fi
|
||||
|
||||
if (( rc == 0 )); then
|
||||
echo "policy gate: PASS"
|
||||
else
|
||||
echo "policy gate: FAIL (a deny rule fired — see above)" >&2
|
||||
fi
|
||||
exit "${rc}"
|
||||
21
CODEOWNERS
Normal file
21
CODEOWNERS
Normal 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
82
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Contributing to `abenteuer-schmiede`
|
||||
|
||||
> 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
86
SECURITY.md
Normal 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/abenteuer-schmiede`**. 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] abenteuer-schmiede`.
|
||||
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 `abenteuer-schmiede` | ✅ 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
69
adr/0000-template.md
Normal 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
72
sops/SOP-000-template.md
Normal 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). -->
|
||||
Loading…
Add table
Add a link
Reference in a new issue