
Typo3 Site Conformance
- 3 installs
- 1 repo stars
- Updated August 3, 2026
- netresearch/typo3-site-conformance-skill
Scores and hardens a deployable TYPO3 site/project repo against a gold standard covering structure, container topology, CI supply-chain gating, secrets, and deploy config.
About
Assesses a TYPO3 project repo (composer type:project plus Docker Compose) against seven rule families using a bundled Python checker that prints PASS/FAIL per rule. A developer uses it when reviewing or bootstrapping a deployable TYPO3 site's container, CI, supply-chain, and secret-handling conformance.
- Bundled checker/check.py scores a repo against machine-readable rules.json
- Seven rule families: STRUCT, CONTAINER, CI, DEPLOY, DEP, SEC, DOC
Typo3 Site Conformance by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,119 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/typo3-site-conformance-skill --skill typo3-site-conformanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 1 |
| Last updated | August 3, 2026 |
| Repository | netresearch/typo3-site-conformance-skill ↗ |
What it does
Scores and hardens a deployable TYPO3 site/project repo against a gold standard covering structure, container topology, CI supply-chain gating, secrets, and deploy config.
Files
TYPO3 Site / Project Conformance
Score and harden a deployable TYPO3 site distribution against the Netresearch gold standard. This is the site/project counterpart to typo3-conformance (which scopes to extensions).
When to use
- A repo with
composer.json"type": "project"and a root Compose file. - Reviewing container topology, Concourse CI, supply-chain gating, secret
handling, or TYPO3 site config (config/system, config/sites).
- Bootstrapping a new customer site from the gold skeleton.
Extension repos (ext_emconf.php, Classes/, TER) → use `typo3-conformance`. Generic supply-chain hardening → `enterprise-readiness`; Docker/Compose → `docker-development`; Concourse → `concourse-ci`.
The ruleset lives here (this skill is the source of truth)
The canonical rule catalogue and the executable checker are bundled in this skill — it needs no external checkout to run:
- Rules:
checker/rules.json(machine-readable; generated by
checker/gen_rules.py).
- Checker:
checker/check.py—python3 checker/check.py <repo-path>
(only pyyaml required). Scores a target repo and prints PASS/FAIL per rule.
Downstream artifacts derive from this catalogue, they are not its source: typo3-14-gold is a runnable reference implementation that scores 100 %; typo3-project-standard is the human-readable companion narrative. Both are Netresearch-internal and optional — propose rule changes here.
The seven rule families
| Family | Intent |
|---|---|
STRUCT | TYPO3-native layout: config/ at composer-project root, no build/config, config/sites/*/config.yaml, committed composer.lock, .gitignore excludes vendor/var/public + live-env files |
CONTAINER | compose.yaml (not docker-compose.yml); images pinned (third-party by @sha256 digest, first-party Netresearch-registry images by explicit version tag); no :latest/alpine:edge; healthchecks + deploy.resources.limits + restart on persistent services; no direct docker.sock mount |
CI | composer audit → Trivy gate → SBOM → cosign; CI task images pinned; fly download checksum-verified; secret detection; test gate; updates via MR |
DEPLOY | Valkey (auth + eviction + no persistence); ofelia scheduler via socket-proxy; weekly restore-verification; logs to stdout/stderr |
DEP | declared PHP platform constraint; no dev-branch constraints; minimum-stability: stable; committed lock |
SEC | no committed secrets (settings.php/additional.php secret-free, env-driven); no committed live-env files; no debug/host wildcards |
DOC | AGENTS.md + CLAUDE.md→symlink; README documents setup/env/make |
Workflow
1. Gate. Confirm type: project + root Compose. Otherwise N/A (extension → typo3-conformance). 2. Score. Run python3 checker/check.py <repo>, or evaluate the families above. ERROR blocks; WARN should fix; INFO advisory. 3. Scope. Architecture/estate/runtime rules (three-repo split, uptime, php-fpm status, ci-colocation) are advisory — report, don't gate. 4. Fix → re-score. Keep repo-scope rules at 100 %.
See references/migration-from-reference.md for transforming a legacy support/typo3-NN/app-style repo (app/ wrapper, build/config, committed secrets, Redis, :latest) into a conformant one.
#!/usr/bin/env python3
"""Gold-standard TYPO3 v14 conformance checker.
Scores a site project against the deduped 73-rule ruleset (rules.json). The gold
template must score 100 % on every *repo*-scope rule; *advisory*-scope rules
(architecture / estate / runtime / base-image) are reported but not scored.
Usage: python3 tools/conformance/check.py [PROJECT_ROOT]
Exit 0 iff no repo-scope rule fails.
"""
from __future__ import annotations
import json
import os
import pathlib
import re
import subprocess
import sys
import yaml
# --------------------------------------------------------------------------- #
# Permissive YAML loader (compose overrides use the !reset compose-spec tag).
# --------------------------------------------------------------------------- #
class _Loader(yaml.SafeLoader):
pass
_Loader.add_multi_constructor("!", lambda loader, suffix, node: None)
# --------------------------------------------------------------------------- #
# Context: load every artefact once.
# --------------------------------------------------------------------------- #
JOB_SERVICES = {"app", "setup", "backup"} # one-shot / idle runners (exempt)
class Ctx:
def __init__(self, root: pathlib.Path) -> None:
self.root = root
self.compose = self._yaml("compose.yaml") or {}
self.override_text = self._text("compose.override.yaml")
self.override = self._yaml("compose.override.yaml", _Loader) or {}
self.composer = self._json("composer.json") or {}
self.settings = self._text("config/system/settings.php")
self.additional = self._text("config/system/additional.php")
self.dockerfile = self._text("Dockerfile")
self.gitlabci = self._text(".gitlab-ci.yml")
self.pipeline = self._text("ci/pipeline.yml")
self.pipeline_code = _strip_yaml_comments(self.pipeline)
self.gitignore = self._text(".gitignore")
self.envdist = self._parse_env(".env.dist")
self.ci_text = self._collect_ci_text()
self.services = self.compose.get("services", {}) or {}
# ---- loaders ----
def _text(self, rel: str) -> str:
p = self.root / rel
return p.read_text(encoding="utf-8") if p.is_file() else ""
def _yaml(self, rel: str, loader=yaml.SafeLoader):
p = self.root / rel
if not p.is_file():
return None
try:
return yaml.load(p.read_text(encoding="utf-8"), Loader=loader)
except yaml.YAMLError:
return None
def _json(self, rel: str):
p = self.root / rel
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
def _parse_env(self, rel: str) -> dict[str, str]:
env: dict[str, str] = {}
for line in self._text(rel).splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip()
val = re.sub(
r"\$\{(\w+)(?::-[^}]*)?\}", lambda m: env.get(m.group(1), ""), val
)
env[key] = val
return env
def _collect_ci_text(self) -> dict[str, str]:
out = {}
ci = self.root / "ci"
if ci.is_dir():
for p in ci.rglob("*.yml"):
out[str(p.relative_to(self.root))] = p.read_text(encoding="utf-8")
return out
# ---- helpers ----
def exists(self, rel: str) -> bool:
return (self.root / rel).exists()
def git_tracked(self, rel: str):
"""True/False whether `rel` is tracked in git; None if git is unavailable
(then callers fall back to the .gitignore-text heuristic). The CI gate
installs git so the strong check runs there — see .gitlab-ci.yml."""
try:
r = subprocess.run(
["git", "-C", str(self.root), "ls-files", "--error-unmatch", rel],
capture_output=True,
text=True,
timeout=10,
)
return r.returncode == 0
except (FileNotFoundError, OSError, subprocess.SubprocessError):
return None
def resolve_image(self, img: str) -> str:
if not img:
return ""
return re.sub(
r"\$\{(\w+)(?::-([^}]*))?\}",
lambda m: self.envdist.get(m.group(1), m.group(2) or ""),
img,
)
def service_image(self, name: str) -> str:
svc = self.services.get(name, {}) or {}
return self.resolve_image(svc.get("image", ""))
def persistent_services(self) -> list[str]:
return [n for n in self.services if n not in JOB_SERVICES]
def cache_service(self):
for name, svc in self.services.items():
img = self.resolve_image((svc or {}).get("image", ""))
if "valkey" in img or re.match(r"redis(:|$|/)", img):
return name, svc
return None, None
def pinned(image: str) -> bool:
"""An image is pinned if it carries a digest or an explicit, non-floating tag."""
if "@sha256:" in image:
return True
ref = image.split("@")[0]
name = ref.rsplit("/", 1)[-1]
if ":" not in name:
return False # bare image, implicit :latest
tag = name.rsplit(":", 1)[1]
return tag not in ("latest", "edge", "")
# A committed credential is any password/secret/key/token-shaped key whose value
# is a non-empty STRING LITERAL (either quote style) rather than an environment
# reference. Detect by value SHAPE, not by an exact key token or single quotes —
# `'transport_smtp_password' => "literal"` and a non-hex encryptionKey must both
# be caught. settings.php must be 100 % environment-sourced for every secret.
_SECRET_KEY = re.compile(
r"""['"](\w*(?:password|secret|encryptionkey|installtoolpassword|api[_-]?key|token))['"]"""
r"""\s*=>\s*(['"])(.*?)\2""",
re.IGNORECASE | re.DOTALL,
)
def settings_secret_free(text: str) -> bool:
for m in _SECRET_KEY.finditer(text):
val = m.group(3).strip()
if not val:
continue # empty default — fine
if val.startswith("%env") or val.startswith("$"):
continue # environment-sourced, not committed
return False
return True
def _strip_yaml_comments(text: str) -> str:
"""Drop full-line and inline `#` comments so substring checks cannot be
satisfied by a magic word that only appears in a comment."""
out = []
for line in text.splitlines():
if re.match(r"\s*#", line):
continue
out.append(re.sub(r"\s+#.*$", "", line))
return "\n".join(out)
def additional_prod_safe(text: str) -> tuple[bool, str]:
"""additional.php may enable dev-only switches ONLY behind an
`isDevelopment()` guard — never a constant-true guard or at top level."""
dev_flags = [
r"\['displayErrors'\]\s*=\s*1\b",
r"\['debug'\]\s*=\s*true\b",
r"\['devIPmask'\]\s*=\s*'\*'", # literal wildcard, not $env(...,'*')
]
if not any(re.search(p, text) for p in dev_flags):
return True, "no dev-mode flags in additional.php"
if re.search(r"if\s*\(\s*(?:true|1)\s*\)", text):
return False, "dev flags behind constant-true guard"
if "isDevelopment()" not in text:
return False, "dev flags not guarded by isDevelopment()"
return True, "dev flags guarded by isDevelopment()"
# --------------------------------------------------------------------------- #
# Checks: code -> function(ctx) -> (bool|"ADVISORY", detail)
# --------------------------------------------------------------------------- #
def c_struct001(x):
return (
not x.exists("build/config/system/settings.php")
and x.exists("config/system/settings.php"),
"config/ at root, no build/config",
)
def c_struct002(x):
return (settings_secret_free(x.settings), "no secret values in settings.php")
def c_struct003(x):
return (
x.exists("config/system/additional.php") and "$_SERVER" in x.additional,
"additional.php sources $_SERVER",
)
def c_struct004(x):
return (
x.composer.get("type") == "project" and x.exists("composer.lock"),
"type:project + committed composer.lock",
)
def c_struct005(x):
return (
len(list((x.root / "config/sites").glob("*/config.yaml"))) > 0,
"config/sites/*/config.yaml present",
)
def c_struct007(x):
g = x.gitignore
ok = (
"/vendor" in g
and "/var/" in g
and "/public/" in g
and bool(re.search(r"\.(prod|stage)\.env|\.env\.(production|staging)", g))
)
return (ok, ".gitignore excludes vendor/var/public + live-env")
def c_struct008(x):
return (not x.exists("build/config"), "no legacy build/config")
def _live_env_files(x):
pats = re.compile(r"(\.env\.(production|staging|live)$|\.(prod|stage)\.env$)")
found = []
for dirpath, dirnames, filenames in os.walk(x.root):
if ".git" in dirnames:
dirnames.remove(".git")
for f in filenames:
if pats.search(f):
found.append(f)
return found
def c_struct006(x):
found = _live_env_files(x)
if found:
return (False, f"found {found}")
# Also catch a bare `.env` (the most common live-secret file) tracked in git.
if x.git_tracked(".env") is True:
return (False, ".env is git-tracked")
return (True, "no committed live-env files")
def c_ciimg001(x):
return ("alpine:edge" not in x.dockerfile, "no alpine:edge")
def c_ciimg002(x):
name, svc = x.cache_service()
img = x.resolve_image((svc or {}).get("image", "")) if svc else ""
return (img.startswith("valkey/valkey"), f"cache image = {img or 'none'}")
def c_ciimg003(x):
app, db = x.service_image("app"), x.service_image("db")
return (pinned(app) and pinned(db), f"app={app or '-'} db pinned={pinned(db)}")
def c_ciimg004(x):
return c_struct002(x)
def c_ciimg005(x):
s = x.settings
ok = (
re.search(r"'displayErrors'\s*=>\s*0", s)
and re.search(r"'debug'\s*=>\s*false", s)
and not re.search(r"'devIPmask'\s*=>\s*'\*'", s)
and not re.search(r"'trustedHostsPattern'\s*=>\s*'\.\*'", s)
)
prod_ok, detail = additional_prod_safe(x.additional)
return (bool(ok) and prod_ok, "no debug/wildcard defaults; " + detail)
def c_ciimg006(x):
return (":latest" not in x.service_image("ofelia"), "ofelia not :latest")
def c_ciimg007(x):
targets = set(x.persistent_services()) | {"backup"}
missing = [
n
for n in targets
if n in x.services
and not (
((x.services[n] or {}).get("deploy", {}) or {}).get("resources", {}) or {}
)
.get("limits", {})
.get("memory")
]
return (
not missing,
"resource limits on all long-running services"
if not missing
else f"missing limits: {missing}",
)
def c_ciimg008(x):
for n in ("db", "valkey"):
if not (x.services.get(n, {}) or {}).get("healthcheck"):
return (False, f"{n} missing healthcheck")
return (True, "db & valkey have healthchecks")
def c_ciimg009(x):
offenders = []
for n, svc in x.services.items():
for v in (svc or {}).get("volumes", []) or []:
if (
isinstance(v, str)
and "/var/run/docker.sock" in v
and n != "socket-proxy"
):
offenders.append(n)
return (
not offenders,
"docker.sock only via socket-proxy"
if not offenders
else f"direct mount: {offenders}",
)
def c_ciimg010(x):
dev = x.override.get("services", {}) or {}
bad = []
for n in ("pma", "mailpit"):
img = x.resolve_image((dev.get(n, {}) or {}).get("image", ""))
if img and not pinned(img):
bad.append(n)
return (not bad, "dev images pinned" if not bad else f"unpinned dev images: {bad}")
def c_ciimg011(x):
return (
x.exists("compose.yaml")
and x.exists("compose.override.yaml")
and not x.exists("docker-compose.yml"),
"canonical compose.yaml",
)
def c_ciimg012(x):
for line in x._text(".env.dist").splitlines():
s = line.strip()
if not s.startswith("#") and re.match(r"COMPOSE_FILE\s*=", s):
return (False, "COMPOSE_FILE overlay chain present")
return (True, "no COMPOSE_FILE overlay chain")
def c_ciimg013(x):
has_g = "g+s" in x.dockerfile
bad = bool(re.search(r"chmod[^\n]*\bug\+s", x.dockerfile)) or bool(
re.search(r"chmod[^\n]*\bu\+s\b", x.dockerfile)
)
return (has_g and not bad, "SGID (g+s) on writable dirs")
def c_ciimg014(x):
text = x._text(".env.dist") + x.pipeline
return (
":82" not in re.sub(r"[0-9]:82\b", "", text)
and "=:82" not in text
and 'tag: "82"' not in text,
"no PHP 8.2 runtime",
)
def c_ciimg015(x):
d = x.dockerfile
ok = all(
f"org.opencontainers.image.{k}" in d for k in ("version", "source", "vendor")
)
return (ok, "OCI labels (version/source/vendor)")
def c_dro001(x):
missing = [
n
for n in x.persistent_services()
if not (x.services[n] or {}).get("healthcheck")
]
return (
not missing,
"all persistent services healthchecked"
if not missing
else f"no healthcheck: {missing}",
)
def c_dro002(x):
for n, svc in x.services.items():
dep = (svc or {}).get("depends_on")
if not dep:
continue
if isinstance(dep, list):
return (False, f"{n} uses list-form depends_on")
for target, spec in dep.items():
cond = (spec or {}).get("condition") if isinstance(spec, dict) else None
if not cond:
return (False, f"{n}->{target} missing condition")
if target in JOB_SERVICES:
if cond not in ("service_completed_successfully", "service_started"):
return (False, f"{n}->{target} bad job condition {cond}")
elif cond != "service_healthy":
return (False, f"{n}->{target} not service_healthy ({cond})")
return (True, "depends_on conditions correct")
def c_dro003(x):
bad = [
n
for n in x.persistent_services()
if (x.services[n] or {}).get("restart")
not in ("unless-stopped", "on-failure", "always")
]
return (
not bad,
"restart policy on persistent services"
if not bad
else f"missing restart: {bad}",
)
def c_dro015(x):
cfg = x._text("ofelia/config.ini")
return ("webhook" in cfg.lower(), "ofelia failure webhook configured")
def c_dro020(x):
return (
x.exists("compose.yaml") and not x.exists("docker-compose.yml"),
"compose.yaml canonical",
)
def c_ci001(x):
return (
not re.search(r"ofelia[^\n]*:latest", x._text("compose.yaml")),
"ofelia not :latest",
)
def c_ci002(x):
return (
"mount=type=secret" in x.dockerfile
and not re.search(r"^ARG\s+COMPOSER_AUTH", x.dockerfile, re.M),
"COMPOSER_AUTH via BuildKit secret",
)
def c_dro013(x):
return (
any(
"restore-verify" in t or ("restore" in t and "verify" in t)
for t in x.ci_text.values()
),
"restore-verify job present",
)
def c_sc001(x):
return ("composer audit" in x.pipeline_code, "composer audit in pipeline")
def c_sc002(x):
return (
"trivy" in x.pipeline_code and "--exit-code 1" in x.pipeline_code,
"trivy gate --exit-code 1",
)
def c_sc003(x):
return (
bool(re.search(r"cyclonedx|spdx|syft", x.pipeline_code)),
"SBOM generation in pipeline",
)
def c_sc007(x):
"""Each Concourse task image_resource is pinned (version tag or digest comment).
Resources (push targets) and anchors resolved separately."""
for fname, text in x.ci_text.items():
lines = text.splitlines()
# index anchor blocks for alias resolution
anchors = _anchor_blocks(lines)
i = 0
while i < len(lines):
m = re.match(r"^(\s*)image_resource:\s*(\*\S+|\&\S+)?\s*$", lines[i])
if m:
indent = len(m.group(1))
ref = (m.group(2) or "").strip()
block = lines[max(0, i - 2) : i]
k = i + 1
while k < len(lines) and (
not lines[k].strip()
or (len(lines[k]) - len(lines[k].lstrip())) > indent
):
block.append(lines[k])
k += 1
btext = "\n".join(block)
if ref.startswith("*"):
btext += "\n" + anchors.get(ref[1:], "")
if not _block_pinned(btext):
return (
False,
f"{fname}: unpinned image_resource near line {i + 1}",
)
i = k
continue
i += 1
return (True, "all task image_resources pinned")
def c_sc008(x):
return ("sha256sum -c" in x.gitlabci, "fly download checksum-verified")
def c_sc009(x):
has_update = "update-packages" in x.pipeline
via_mr = x.exists("renovate.json") or any(
"merge_request" in t for t in x.ci_text.values()
)
return ((not has_update) or via_mr, "dependency updates via MR / Renovate")
def c_sc010(x):
return (
"Secret-Detection.gitlab-ci.yml" in x.gitlabci,
"GitLab secret detection enabled",
)
def c_sc011(x):
return (
bool(re.search(r"phpunit|functional|smoke", x.pipeline_code)),
"test gate present",
)
def c_sc012(x):
return (x.exists("composer.lock"), "composer.lock committed")
def c_sc013(x):
text = x._text("compose.yaml") + "\n" + x.override_text
bad = re.search(r"image:\s*redis\s*$", text, re.M) or re.search(
r"image:\s*\S+:latest\s*$", text, re.M
)
return (not bad, "runtime images pinned (no bare redis / :latest)")
def c_deploy002(x):
return (not x.exists("ansible"), "no colocated ansible/")
def _cache_cmd(x):
_, svc = x.cache_service()
cmd = (svc or {}).get("command", [])
return " ".join(cmd) if isinstance(cmd, list) else str(cmd)
def c_dro004(x):
return ("--requirepass" in _cache_cmd(x), "cache requires auth")
def c_dro005(x):
cmd = _cache_cmd(x)
return (
"--save" in cmd and "--appendonly yes" not in cmd,
"cache persistence disabled",
)
def c_dro006(x):
cmd = _cache_cmd(x)
return ("--maxmemory" in cmd and "allkeys-lru" in cmd, "cache eviction bounded")
def c_dro008(x):
return c_ciimg002(x)
def c_dro009(x):
bad = [n for n in x.persistent_services() if not pinned(x.service_image(n))]
return (not bad, "persistent images pinned" if not bad else f"unpinned: {bad}")
def c_dro010(x):
froms = re.findall(r"^FROM\s+(\S+)", x.dockerfile, re.M)
if not froms:
return (False, "no FROM line")
last = froms[-1]
return (pinned(last) and ":edge" not in last, f"final FROM pinned ({last})")
def c_dro012(x):
s = x.settings
bad = (
re.search(r"'displayErrors'\s*=>\s*1", s)
or re.search(r"'debug'\s*=>\s*true", s)
or re.search(r"'trustedHostsPattern'\s*=>\s*'\.\*'", s)
)
prod_ok, detail = additional_prod_safe(x.additional)
return (not bad and prod_ok, "no dev-mode flags; " + detail)
def c_dro014(x):
sched = "scheduler:run" in x._text("compose.yaml")
cron = bool(
re.search(
r"(entrypoint|command)[^\n]*\b(crond|dcron)\b", x._text("compose.yaml")
)
)
return (sched and not cron, "ofelia scheduler, no in-image dcron")
def c_dro016(x):
s = x.settings
if "FileWriter" not in s:
return (True, "no FileWriter")
# Every FileWriter must log to a php:// stream, never a disk path.
for m in re.finditer(r"FileWriter::class\s*=>\s*\[(.*?)\]", s, re.S):
block = m.group(1)
lf = re.search(r"'logFile'\s*=>\s*'([^']+)'", block)
if not lf or not lf.group(1).startswith("php://"):
return (False, "FileWriter not pointed at php:// stream")
return (True, "logs routed to php://stderr")
def c_dep001(x):
c = x.composer
return (
bool(c.get("require", {}).get("php"))
and bool((c.get("config", {}).get("platform", {}) or {}).get("php")),
"php require + platform set",
)
def c_dep002(x):
req = json.dumps(x.composer.get("require", {})) + json.dumps(
x.composer.get("require-dev", {})
)
return (not re.search(r'"dev-[a-z0-9_\-]+"', req), "no dev-branch constraints")
def c_dep003(x):
return (
x.composer.get("minimum-stability", "stable") == "stable",
"minimum-stability stable",
)
def c_dep004(x):
return (x.exists("composer.lock"), "composer.lock present")
def c_dro007(x):
ofelia = x.services.get("ofelia", {}) or {}
vols = ofelia.get("volumes", []) or []
no_sock = not any("docker.sock" in str(v) for v in vols)
env = ofelia.get("environment", []) or []
env_text = " ".join(env) if isinstance(env, list) else json.dumps(env)
via_proxy = "socket-proxy" in env_text and "tcp://" in env_text
proxy = any(
"docker-socket-proxy" in x.resolve_image((s or {}).get("image", ""))
for s in x.services.values()
)
return (no_sock and via_proxy and proxy, "ofelia via socket-proxy, no direct sock")
def c_dro011(x):
# Strong check: a git-tracked .env (gitignored yet force-added) with live
# secrets is the exact attack (CONF-02). Fall back to the .gitignore-text
# heuristic only when git is unavailable.
if x.git_tracked(".env") is True:
return (False, ".env is git-tracked (committed-secret risk)")
base = (
settings_secret_free(x.settings)
and ".env" in x.gitignore
and x.exists(".env.dist")
)
note = (
" [git unavailable: .gitignore-text fallback]"
if x.git_tracked(".env") is None
else ""
)
return (base, "no committed creds; .env ignored; .env.dist schema" + note)
def c_sc004(x):
return ("cosign" in x.pipeline_code, "cosign signing in pipeline")
def c_sc005(x):
return c_ci002(x)
def c_sc006(x):
return c_struct002(x)
def c_sec001(x):
text = x._text("compose.yaml")
return (
not re.search(r"image:\s*redis\s*$", text, re.M)
and not re.search(r"image:\s*redis:latest", text),
"no bare/latest redis image",
)
def c_sec002(x):
return (
not re.search(r"'installToolPassword'\s*=>\s*'\$argon", x.settings),
"no hardcoded installToolPassword",
)
def c_sec003(x):
return ("TYPO3_ENCRYPTION_KEY" in x.additional, "encryptionKey env-driven")
def c_sec004(x):
return c_struct006(x)
def c_sec005(x):
s_ok = not re.search(r"'devIPmask'\s*=>\s*'\*'", x.settings)
prod_ok, _ = additional_prod_safe(x.additional)
return (s_ok and prod_ok, "devIPmask not wildcard (settings + additional)")
def c_sec006(x):
return (
not re.search(r"'trustedHostsPattern'\s*=>\s*'\.\*'", x.settings),
"trustedHostsPattern not wildcard",
)
def c_doc001(x):
return (x.exists("AGENTS.md"), "AGENTS.md present")
def c_doc002(x):
p = x.root / "CLAUDE.md"
return (
p.is_symlink() and os.readlink(p) == "AGENTS.md",
"CLAUDE.md -> AGENTS.md symlink",
)
def c_doc003(x):
r = x._text("README.md")
return (
"make install" in r and ("COMPOSER_AUTH" in r or ".env" in r),
"README documents setup/env/make",
)
def _anchor_blocks(lines: list[str]) -> dict[str, str]:
out, i = {}, 0
while i < len(lines):
m = re.match(r"^(\w[\w\-]*):\s*\&(\S+)", lines[i])
if m:
indent = len(lines[i]) - len(lines[i].lstrip())
block, k = [lines[i]], i + 1
while k < len(lines) and (
not lines[k].strip()
or (len(lines[k]) - len(lines[k].lstrip())) > indent
):
block.append(lines[k])
k += 1
out[m.group(2)] = "\n".join(block)
i = k
continue
i += 1
return out
def _block_pinned(btext: str) -> bool:
# Strip comments first: a digest in a `# ...@sha256:` comment beside
# `tag: latest` is NOT a pin (AUTO-03). The digest must be effective — in the
# repository ref or a native `version: { digest: sha256:... }` field.
code = _strip_yaml_comments(btext)
if re.search(r"sha256:[0-9a-f]{64}", code):
return True
tag = re.search(r'tag:\s*"?([\w.\-]+)"?', code)
return bool(tag) and tag.group(1) not in ("latest", "edge")
CHECKS = {
"STRUCT-001": c_struct001,
"STRUCT-002": c_struct002,
"STRUCT-003": c_struct003,
"STRUCT-004": c_struct004,
"STRUCT-005": c_struct005,
"STRUCT-006": c_struct006,
"STRUCT-007": c_struct007,
"STRUCT-008": c_struct008,
"CI-IMG-001": c_ciimg001,
"CI-IMG-002": c_ciimg002,
"CI-IMG-003": c_ciimg003,
"CI-IMG-004": c_ciimg004,
"CI-IMG-005": c_ciimg005,
"CI-IMG-006": c_ciimg006,
"CI-IMG-007": c_ciimg007,
"CI-IMG-008": c_ciimg008,
"CI-IMG-009": c_ciimg009,
"CI-IMG-010": c_ciimg010,
"CI-IMG-011": c_ciimg011,
"CI-IMG-012": c_ciimg012,
"CI-IMG-013": c_ciimg013,
"CI-IMG-014": c_ciimg014,
"CI-IMG-015": c_ciimg015,
"DRO-001": c_dro001,
"DRO-002": c_dro002,
"DRO-003": c_dro003,
"DRO-015": c_dro015,
"DRO-020": c_dro020,
"CI-001": c_ci001,
"CI-002": c_ci002,
"DRO-013": c_dro013,
"SC-001": c_sc001,
"SC-002": c_sc002,
"SC-003": c_sc003,
"SC-007": c_sc007,
"SC-008": c_sc008,
"SC-009": c_sc009,
"SC-010": c_sc010,
"SC-011": c_sc011,
"SC-012": c_sc012,
"SC-013": c_sc013,
"DEPLOY-002": c_deploy002,
"DRO-004": c_dro004,
"DRO-005": c_dro005,
"DRO-006": c_dro006,
"DRO-008": c_dro008,
"DRO-009": c_dro009,
"DRO-010": c_dro010,
"DRO-012": c_dro012,
"DRO-014": c_dro014,
"DRO-016": c_dro016,
"DEP-001": c_dep001,
"DEP-002": c_dep002,
"DEP-003": c_dep003,
"DEP-004": c_dep004,
"DRO-007": c_dro007,
"DRO-011": c_dro011,
"SC-004": c_sc004,
"SC-005": c_sc005,
"SC-006": c_sc006,
"SEC-001": c_sec001,
"SEC-002": c_sec002,
"SEC-003": c_sec003,
"SEC-004": c_sec004,
"SEC-005": c_sec005,
"SEC-006": c_sec006,
"DOC-001": c_doc001,
"DOC-002": c_doc002,
"DOC-003": c_doc003,
}
ADVISORY_NOTE = {
"DRO-019": "template is intentionally a single repo; production sites split app/deploy/infra",
"DEPLOY-001": "template colocates ci/ to demonstrate; production sites use a deploy repo",
"DRO-017": "php-fpm status endpoint is configured in the shared t3re image, not this repo",
"DRO-018": "uptime monitoring is registered per deployed site, not in the template",
}
GREEN, RED, YEL, DIM, RST = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m"
def main() -> int:
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
rules = json.loads((pathlib.Path(__file__).with_name("rules.json")).read_text())[
"rules"
]
ctx = Ctx(root)
max_possible = penalty = 0
failures = []
rows = []
for rule in rules:
code, sev, scope, weight = (
rule["code"],
rule["severity"],
rule["scope"],
rule["weight"],
)
if scope == "advisory":
rows.append((code, sev, "ADVISORY", ADVISORY_NOTE.get(code, "")))
continue
fn = CHECKS.get(code)
if fn is None:
rows.append((code, sev, "SKIP", "no check implemented"))
continue
ok, detail = fn(ctx)
max_possible += weight
if ok:
rows.append((code, sev, "PASS", detail))
else:
penalty += weight
failures.append(code)
rows.append((code, sev, "FAIL", detail))
score = round(100 * (max_possible - penalty) / max_possible) if max_possible else 0
print(f"\n TYPO3 14 Gold — conformance report ({root.name})\n")
for code, sev, status, detail in rows:
color = {"PASS": GREEN, "FAIL": RED, "ADVISORY": DIM, "SKIP": YEL}[status]
print(f" {color}{status:<8}{RST} {code:<11} {DIM}{sev:<7}{RST} {detail}")
band = GREEN if score >= 90 else (YEL if score >= 70 else RED)
print(
f"\n repo-scope score: {band}{score}%{RST} "
f"({(max_possible - penalty)}/{max_possible} pts, {len(failures)} failing)"
)
if failures:
print(f" {RED}FAILING:{RST} {', '.join(failures)}")
print()
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Generate rules.json — the deduped 73-rule gold-standard conformance catalogue.
Source of truth: the published conformance ruleset
(https://pages.nrdev.de/typo3/typo3-project-standard → Conformance Ruleset).
Each rule carries a `scope`:
- "repo" : statically checkable against this repository; the gold project
must pass 100 % of these.
- "advisory" : architectural / estate / runtime / base-image properties that
cannot be asserted from a single template repo. Reported with a
by-design note, excluded from the gold-project score.
"""
import json
import pathlib
# (code, category, severity, scope, requirement)
RULES = [
# --- STRUCT ---------------------------------------------------------------
(
"STRUCT-001",
"STRUCT",
"error",
"repo",
"TYPO3 config directory must be at project root as config/, not build/config/",
),
(
"STRUCT-002",
"STRUCT",
"error",
"repo",
"config/system/settings.php must contain no plaintext secrets",
),
(
"STRUCT-003",
"STRUCT",
"error",
"repo",
"config/system/additional.php must exist and source env values from $_SERVER",
),
(
"STRUCT-004",
"STRUCT",
"warning",
"repo",
"composer.lock must be committed for project-type composer.json",
),
(
"STRUCT-005",
"STRUCT",
"warning",
"repo",
"config/sites/ must contain at least one site config.yaml",
),
(
"STRUCT-006",
"SEC",
"error",
"repo",
"No committed live-environment files (.env.production etc.)",
),
(
"STRUCT-007",
"STRUCT",
"warning",
"repo",
".gitignore must exclude vendor/, var/, public/ and live-env patterns",
),
(
"STRUCT-008",
"STRUCT",
"info",
"repo",
"build/ directory must not exist after migration to config/",
),
(
"DRO-019",
"STRUCT",
"info",
"advisory",
"New sites should use the three-repo layout (app / deploy / infra)",
),
# --- CONTAINER ------------------------------------------------------------
(
"CI-IMG-001",
"CONTAINER",
"error",
"repo",
"Carrier base image must not use alpine:edge",
),
(
"CI-IMG-002",
"CONTAINER",
"error",
"repo",
"Cache service must use Valkey, not Redis",
),
(
"CI-IMG-003",
"CONTAINER",
"error",
"repo",
"First-party app and db images must use immutable version tags, not :latest",
),
(
"CI-IMG-004",
"CONTAINER",
"error",
"repo",
"No secrets committed to settings.php or any build artefact",
),
(
"CI-IMG-005",
"CONTAINER",
"error",
"repo",
"Production config must not set debug-mode defaults",
),
(
"CI-IMG-006",
"CONTAINER",
"warning",
"repo",
"ofelia must use a pinned version tag, not :latest",
),
(
"CI-IMG-007",
"CONTAINER",
"warning",
"repo",
"All long-running services must define deploy.resources.limits",
),
(
"CI-IMG-008",
"CONTAINER",
"warning",
"repo",
"Stateful services must define healthcheck blocks",
),
(
"CI-IMG-009",
"CONTAINER",
"warning",
"repo",
"docker.sock must not be mounted directly; use a socket proxy",
),
(
"CI-IMG-010",
"CONTAINER",
"warning",
"repo",
"Development-only images must carry pinned tags",
),
(
"CI-IMG-011",
"CONTAINER",
"warning",
"repo",
"Compose file must use canonical filename compose.yaml",
),
(
"CI-IMG-012",
"CONTAINER",
"warning",
"repo",
"COMPOSE_FILE env-var overlay pattern must not be used",
),
(
"CI-IMG-013",
"CONTAINER",
"info",
"repo",
"Carrier Dockerfile should use SGID not SUID on writable directories",
),
(
"CI-IMG-014",
"CONTAINER",
"info",
"repo",
"No PHP 8.2 (EOL Dec 2026) runtime without a documented upgrade plan",
),
(
"CI-IMG-015",
"CONTAINER",
"info",
"repo",
"Production Dockerfile should embed OCI image labels",
),
(
"DRO-001",
"CONTAINER",
"error",
"repo",
"All persistent services must define a healthcheck",
),
(
"DRO-002",
"CONTAINER",
"error",
"repo",
"depends_on must use condition: service_healthy for persistent deps",
),
(
"DRO-003",
"CONTAINER",
"error",
"repo",
"All persistent services must set restart: unless-stopped",
),
(
"DRO-015",
"CONTAINER",
"warning",
"repo",
"ofelia scheduler failure must have webhook notification configured",
),
(
"DRO-017",
"CONTAINER",
"info",
"advisory",
"PHP-FPM status endpoint should be enabled for metrics scraping",
),
(
"DRO-020",
"CONTAINER",
"info",
"repo",
"Compose canonical filename should be compose.yaml",
),
# --- CI -------------------------------------------------------------------
(
"CI-001",
"CI",
"warning",
"repo",
"ofelia scheduler image must reference a pinned version tag",
),
(
"CI-002",
"CI",
"warning",
"repo",
"COMPOSER_AUTH should use BuildKit --mount=type=secret, not ARG",
),
("DRO-013", "CI", "error", "repo", "A weekly restore-verification job must exist"),
(
"SC-001",
"CI",
"error",
"repo",
"composer audit must run before every Docker build",
),
(
"SC-002",
"CI",
"error",
"repo",
"Vulnerability scan (Trivy CRITICAL+HIGH) must gate image push",
),
(
"SC-003",
"CI",
"error",
"repo",
"SBOM must be generated for every production image build",
),
(
"SC-007",
"CI",
"error",
"repo",
"All CI task image references must be immutably pinned",
),
(
"SC-008",
"CI",
"error",
"repo",
"fly CLI downloaded in GitLab CI must have its checksum verified",
),
(
"SC-009",
"CI",
"error",
"repo",
"Dependency updates must go via merge requests, not direct to main",
),
(
"SC-010",
"CI",
"warning",
"repo",
"GitLab native secret detection must be enabled",
),
(
"SC-011",
"CI",
"warning",
"repo",
"The test gate in the Concourse pipeline must not be disabled",
),
(
"SC-012",
"CI",
"warning",
"repo",
"composer.lock must be committed in project-type repositories",
),
(
"SC-013",
"CI",
"warning",
"repo",
"Compose runtime service images must be pinned to specific versions",
),
# --- DEPLOY ---------------------------------------------------------------
(
"DEPLOY-001",
"DEPLOY",
"warning",
"advisory",
"CI pipeline files should live in a separate deploy repo",
),
(
"DEPLOY-002",
"DEPLOY",
"warning",
"repo",
"Ansible playbooks should live in a separate deploy repo",
),
("DRO-004", "DEPLOY", "error", "repo", "Valkey/Redis must require authentication"),
(
"DRO-005",
"DEPLOY",
"error",
"repo",
"Cache service must disable persistence (--save '' and no AOF)",
),
(
"DRO-006",
"DEPLOY",
"error",
"repo",
"Cache service must set maxmemory and allkeys-lru eviction",
),
("DRO-008", "DEPLOY", "error", "repo", "Cache image must use Valkey, not Redis"),
(
"DRO-009",
"DEPLOY",
"error",
"repo",
"Cache image must be pinned to a stable version tag, not :latest",
),
(
"DRO-010",
"DEPLOY",
"error",
"repo",
"Dockerfile final stage must not use FROM alpine:edge",
),
(
"DRO-012",
"DEPLOY",
"warning",
"repo",
"Development-mode flags must not be committed in settings.php",
),
(
"DRO-014",
"DEPLOY",
"error",
"repo",
"Scheduler standard must be ofelia; in-image dcron disabled",
),
(
"DRO-016",
"DEPLOY",
"warning",
"repo",
"TYPO3 log output must go to stdout/stderr, not an on-disk FileWriter",
),
(
"DRO-018",
"DEPLOY",
"warning",
"advisory",
"Each site must be registered in external uptime monitoring",
),
# --- DEP ------------------------------------------------------------------
(
"DEP-001",
"DEP",
"error",
"repo",
"PHP platform constraint must be declared in composer.json",
),
(
"DEP-002",
"DEP",
"error",
"repo",
"Dev-branch constraints must not appear in composer.json",
),
(
"DEP-003",
"DEP",
"warning",
"repo",
"minimum-stability must be declared as 'stable'",
),
(
"DEP-004",
"DEP",
"warning",
"repo",
"composer.lock must be committed for type:project",
),
# --- SEC ------------------------------------------------------------------
(
"DRO-007",
"SEC",
"error",
"repo",
"ofelia must not mount docker.sock directly; use a socket proxy",
),
(
"DRO-011",
"SEC",
"error",
"repo",
"Credentials must not be committed; .env git-ignored; .env.dist schema",
),
(
"SC-004",
"SEC",
"error",
"repo",
"Container images must be signed with cosign after push",
),
(
"SC-005",
"SEC",
"error",
"repo",
"COMPOSER_AUTH must not be passed as a Docker build ARG",
),
(
"SC-006",
"SEC",
"error",
"repo",
"Secrets must not be committed in build configuration files",
),
(
"SEC-001",
"SEC",
"error",
"repo",
"redis service image must specify an explicit version tag",
),
(
"SEC-002",
"SEC",
"error",
"repo",
"installToolPassword must not be hardcoded in settings.php",
),
(
"SEC-003",
"SEC",
"error",
"repo",
"encryptionKey must be env-driven in additional.php",
),
(
"SEC-004",
"SEC",
"error",
"repo",
"Production env files must not be committed to git",
),
("SEC-005", "SEC", "warning", "repo", "devIPmask wildcard must not be committed"),
(
"SEC-006",
"SEC",
"warning",
"repo",
"trustedHostsPattern wildcard must not be committed",
),
# --- DOC ------------------------------------------------------------------
("DOC-001", "DOC", "error", "repo", "AGENTS.md must exist at repository root"),
(
"DOC-002",
"DOC",
"error",
"repo",
"CLAUDE.md must exist as a symlink to AGENTS.md",
),
(
"DOC-003",
"DOC",
"warning",
"repo",
"README.md must document setup, env vars and make targets",
),
]
WEIGHTS = {"error": 10, "warning": 5, "info": 1}
def main() -> None:
rules = [
{
"code": code,
"category": category,
"severity": severity,
"weight": WEIGHTS[severity],
"scope": scope,
"requirement": requirement,
}
for (code, category, severity, scope, requirement) in RULES
]
out = {
"version": "1.0.0",
"reference": "https://pages.nrdev.de/typo3/typo3-project-standard",
"weights": WEIGHTS,
"rules": rules,
}
path = pathlib.Path(__file__).with_name("rules.json")
path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")
print(f"wrote {len(rules)} rules to {path}")
if __name__ == "__main__":
main()
Conformance checker
check.py scores a TYPO3 site/project repository against the gold-standard conformance ruleset (rules.json) — the deduped 73-rule catalogue that is canonical in this skill (the typo3-site-conformance source of truth).
python3 check.py /path/to/target-repo # only pyyaml is requiredExit code is 0 iff no repo-scope rule fails. The gold template is kept at 100 %.
Scope model
Every rule carries a scope:
- `repo` (69 rules) — statically checkable against the repository tree. The
gold template must pass all of them; these form the scored denominator.
- `advisory` (4 rules) — architectural / estate / runtime / shared-image
properties that a single template repository cannot assert. They are reported with a by-design note and excluded from the score:
| code | why it is advisory |
|---|---|
DRO-019 | three-repo split is a site layout; the template is deliberately one repo |
DEPLOY-001 | the template colocates ci/ to demonstrate it; sites split it out |
DRO-017 | the php-fpm status endpoint lives in the shared t3re image, not here |
DRO-018 | uptime monitoring is registered per deployed site |
Scoring
error = 10 pts, warning = 5, info = 1. The denominator is the sum of applicable (repo-scope) weights; the score is round(100 × (max − penalty) / max). Bands: ≥90 green, 70–89 yellow, <50 red.
Refinements vs. the published grep-checks
The published catalogue expresses several checks as one-line greps that are imprecise. check.py implements their intent; each refinement is deliberate and documented here:
- `STRUCT-002` / `CI-IMG-004` / `SC-006` — flag a committed secret by
value shape: any *password / *secret / encryptionKey / installToolPassword / *token key whose value is a non-empty string literal (either quote style) rather than an environment reference. A non-hex key and a double-quoted installToolPassword are caught; mere key names like passwordHashing are not.
- `CI-IMG-005` / `DRO-012` / `SEC-005` — also scan
additional.php: the
dev-only switches (displayErrors=1, debug=true, devIPmask='*') must stay behind the isDevelopment() guard. A constant-true guard (if (true)) or an ungated assignment fails.
- `SC-007` — a Concourse task
image_resourcecounts as pinned only when the
digest is effective — in the repository ref or a native version: { digest: sha256:… } field — or the tag is explicit and non-floating. A @sha256 digest in a comment beside tag: latest does NOT count (comments are stripped before the check). Output resources: (push targets such as app-image:latest) are out of scope.
- `DRO-016` — an on-disk
FileWriteris the violation; aFileWriter
pointed at a php://stderr/php://stdout stream is container-native and passes.
- `CI-IMG-003` / `DRO-009` — image references are resolved through
.env.dist before the pin check, so ${APP_IMAGE} / ${T3RE_IMAGE_VERSION} are evaluated, not treated as literal text.
Service classification
One-shot / idle runners (app, setup, backup) are exempt from the healthcheck/restart rules (DRO-001/003); backup is still a long-running service for the resource-limit rule (CI-IMG-007). Everything else is persistent.
A heuristic, not a security control
check.py is a static structural heuristic, not a security boundary. A 100 % score is necessary, not sufficient — it proves the gold-standard structure is present, not that a repository is secure. It is deliberately hardened against the obvious evasions (a git-tracked .env, double-quoted or non-hex committed secrets, a constant-true dev guard, supply-chain keywords that live only in comments), and the CI gate installs git so the committed-secret rules enforce there. Known limits that a determined author can still slip past — do not rely on the gate alone:
- CI supply chain (
SC-001/002/003/004/011) is asserted by substring on the
comment-stripped pipeline text, not by parsing the job graph: a step present in the YAML but not wired into the build plan would still pass.
- Service classification (
DRO-001/003) exempts the namesapp/setup/backup
by convention; a long-running daemon given one of those names escapes the healthcheck/restart rules.
- External-catalogue parity is asserted, not enforced (see below).
These limits are documented inline above; the heuristic is a structural gate, not a security boundary.
Regenerating the ruleset
rules.json is produced by gen_rules.py, which embeds the 73-rule catalogue inline. This skill is the source of truth: edit the catalogue in gen_rules.py, regenerate rules.json, and the downstream reference implementation (typo3-14-gold) and the human-readable companion (typo3-project-standard, Netresearch-internal) follow.
python3 gen_rules.py{
"version": "1.0.0",
"reference": "https://pages.nrdev.de/typo3/typo3-project-standard",
"weights": {
"error": 10,
"warning": 5,
"info": 1
},
"rules": [
{
"code": "STRUCT-001",
"category": "STRUCT",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "TYPO3 config directory must be at project root as config/, not build/config/"
},
{
"code": "STRUCT-002",
"category": "STRUCT",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "config/system/settings.php must contain no plaintext secrets"
},
{
"code": "STRUCT-003",
"category": "STRUCT",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "config/system/additional.php must exist and source env values from $_SERVER"
},
{
"code": "STRUCT-004",
"category": "STRUCT",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "composer.lock must be committed for project-type composer.json"
},
{
"code": "STRUCT-005",
"category": "STRUCT",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "config/sites/ must contain at least one site config.yaml"
},
{
"code": "STRUCT-006",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "No committed live-environment files (.env.production etc.)"
},
{
"code": "STRUCT-007",
"category": "STRUCT",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": ".gitignore must exclude vendor/, var/, public/ and live-env patterns"
},
{
"code": "STRUCT-008",
"category": "STRUCT",
"severity": "info",
"weight": 1,
"scope": "repo",
"requirement": "build/ directory must not exist after migration to config/"
},
{
"code": "DRO-019",
"category": "STRUCT",
"severity": "info",
"weight": 1,
"scope": "advisory",
"requirement": "New sites should use the three-repo layout (app / deploy / infra)"
},
{
"code": "CI-IMG-001",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Carrier base image must not use alpine:edge"
},
{
"code": "CI-IMG-002",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Cache service must use Valkey, not Redis"
},
{
"code": "CI-IMG-003",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "First-party app and db images must use immutable version tags, not :latest"
},
{
"code": "CI-IMG-004",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "No secrets committed to settings.php or any build artefact"
},
{
"code": "CI-IMG-005",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Production config must not set debug-mode defaults"
},
{
"code": "CI-IMG-006",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "ofelia must use a pinned version tag, not :latest"
},
{
"code": "CI-IMG-007",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "All long-running services must define deploy.resources.limits"
},
{
"code": "CI-IMG-008",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Stateful services must define healthcheck blocks"
},
{
"code": "CI-IMG-009",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "docker.sock must not be mounted directly; use a socket proxy"
},
{
"code": "CI-IMG-010",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Development-only images must carry pinned tags"
},
{
"code": "CI-IMG-011",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Compose file must use canonical filename compose.yaml"
},
{
"code": "CI-IMG-012",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "COMPOSE_FILE env-var overlay pattern must not be used"
},
{
"code": "CI-IMG-013",
"category": "CONTAINER",
"severity": "info",
"weight": 1,
"scope": "repo",
"requirement": "Carrier Dockerfile should use SGID not SUID on writable directories"
},
{
"code": "CI-IMG-014",
"category": "CONTAINER",
"severity": "info",
"weight": 1,
"scope": "repo",
"requirement": "No PHP 8.2 (EOL Dec 2026) runtime without a documented upgrade plan"
},
{
"code": "CI-IMG-015",
"category": "CONTAINER",
"severity": "info",
"weight": 1,
"scope": "repo",
"requirement": "Production Dockerfile should embed OCI image labels"
},
{
"code": "DRO-001",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "All persistent services must define a healthcheck"
},
{
"code": "DRO-002",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "depends_on must use condition: service_healthy for persistent deps"
},
{
"code": "DRO-003",
"category": "CONTAINER",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "All persistent services must set restart: unless-stopped"
},
{
"code": "DRO-015",
"category": "CONTAINER",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "ofelia scheduler failure must have webhook notification configured"
},
{
"code": "DRO-017",
"category": "CONTAINER",
"severity": "info",
"weight": 1,
"scope": "advisory",
"requirement": "PHP-FPM status endpoint should be enabled for metrics scraping"
},
{
"code": "DRO-020",
"category": "CONTAINER",
"severity": "info",
"weight": 1,
"scope": "repo",
"requirement": "Compose canonical filename should be compose.yaml"
},
{
"code": "CI-001",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "ofelia scheduler image must reference a pinned version tag"
},
{
"code": "CI-002",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "COMPOSER_AUTH should use BuildKit --mount=type=secret, not ARG"
},
{
"code": "DRO-013",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "A weekly restore-verification job must exist"
},
{
"code": "SC-001",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "composer audit must run before every Docker build"
},
{
"code": "SC-002",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Vulnerability scan (Trivy CRITICAL+HIGH) must gate image push"
},
{
"code": "SC-003",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "SBOM must be generated for every production image build"
},
{
"code": "SC-007",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "All CI task image references must be immutably pinned"
},
{
"code": "SC-008",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "fly CLI downloaded in GitLab CI must have its checksum verified"
},
{
"code": "SC-009",
"category": "CI",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Dependency updates must go via merge requests, not direct to main"
},
{
"code": "SC-010",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "GitLab native secret detection must be enabled"
},
{
"code": "SC-011",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "The test gate in the Concourse pipeline must not be disabled"
},
{
"code": "SC-012",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "composer.lock must be committed in project-type repositories"
},
{
"code": "SC-013",
"category": "CI",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Compose runtime service images must be pinned to specific versions"
},
{
"code": "DEPLOY-001",
"category": "DEPLOY",
"severity": "warning",
"weight": 5,
"scope": "advisory",
"requirement": "CI pipeline files should live in a separate deploy repo"
},
{
"code": "DEPLOY-002",
"category": "DEPLOY",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Ansible playbooks should live in a separate deploy repo"
},
{
"code": "DRO-004",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Valkey/Redis must require authentication"
},
{
"code": "DRO-005",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Cache service must disable persistence (--save '' and no AOF)"
},
{
"code": "DRO-006",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Cache service must set maxmemory and allkeys-lru eviction"
},
{
"code": "DRO-008",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Cache image must use Valkey, not Redis"
},
{
"code": "DRO-009",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Cache image must be pinned to a stable version tag, not :latest"
},
{
"code": "DRO-010",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Dockerfile final stage must not use FROM alpine:edge"
},
{
"code": "DRO-012",
"category": "DEPLOY",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "Development-mode flags must not be committed in settings.php"
},
{
"code": "DRO-014",
"category": "DEPLOY",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Scheduler standard must be ofelia; in-image dcron disabled"
},
{
"code": "DRO-016",
"category": "DEPLOY",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "TYPO3 log output must go to stdout/stderr, not an on-disk FileWriter"
},
{
"code": "DRO-018",
"category": "DEPLOY",
"severity": "warning",
"weight": 5,
"scope": "advisory",
"requirement": "Each site must be registered in external uptime monitoring"
},
{
"code": "DEP-001",
"category": "DEP",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "PHP platform constraint must be declared in composer.json"
},
{
"code": "DEP-002",
"category": "DEP",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Dev-branch constraints must not appear in composer.json"
},
{
"code": "DEP-003",
"category": "DEP",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "minimum-stability must be declared as 'stable'"
},
{
"code": "DEP-004",
"category": "DEP",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "composer.lock must be committed for type:project"
},
{
"code": "DRO-007",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "ofelia must not mount docker.sock directly; use a socket proxy"
},
{
"code": "DRO-011",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Credentials must not be committed; .env git-ignored; .env.dist schema"
},
{
"code": "SC-004",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Container images must be signed with cosign after push"
},
{
"code": "SC-005",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "COMPOSER_AUTH must not be passed as a Docker build ARG"
},
{
"code": "SC-006",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Secrets must not be committed in build configuration files"
},
{
"code": "SEC-001",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "redis service image must specify an explicit version tag"
},
{
"code": "SEC-002",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "installToolPassword must not be hardcoded in settings.php"
},
{
"code": "SEC-003",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "encryptionKey must be env-driven in additional.php"
},
{
"code": "SEC-004",
"category": "SEC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "Production env files must not be committed to git"
},
{
"code": "SEC-005",
"category": "SEC",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "devIPmask wildcard must not be committed"
},
{
"code": "SEC-006",
"category": "SEC",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "trustedHostsPattern wildcard must not be committed"
},
{
"code": "DOC-001",
"category": "DOC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "AGENTS.md must exist at repository root"
},
{
"code": "DOC-002",
"category": "DOC",
"severity": "error",
"weight": 10,
"scope": "repo",
"requirement": "CLAUDE.md must exist as a symlink to AGENTS.md"
},
{
"code": "DOC-003",
"category": "DOC",
"severity": "warning",
"weight": 5,
"scope": "repo",
"requirement": "README.md must document setup, env vars and make targets"
}
]
}
Migrating a legacy site repo to gold conformance
Transforming a support/typo3-NN/app-style repo (the de-facto pattern) into a gold-conformant one. The runnable reference end state is typo3-14-gold (Netresearch-internal); the authoritative rules are this skill's checker/rules.json.
The seven moves
1. Flatten to root. Move the TYPO3 Composer project out of app/ so composer.json, config/, public/, vendor/ are at the repo root. Delete build/config/ — config/system/settings.php + config/system/additional.php live at the composer-project root (STRUCT-001/008). 2. De-secret the config. Strip every credential, hash, debug flag and host wildcard from settings.php. Inject them at runtime in additional.php from $_SERVER (encryptionKey, install-tool password, DB creds, cache auth, SMTP). .env is git-ignored; .env.dist is the schema (SEC-*, STRUCT-002/003). 3. Rename + pin Compose. docker-compose.yml → compose.yaml; dev overlay → compose.override.yaml (drop COMPOSE_FILE=a:b:c). Pin every image to an immutable reference — third-party images by @sha256 digest, first-party Netresearch-registry images by an explicit version tag; never :latest. Add healthcheck, deploy.resources.limits, restart to persistent services (CI-IMG-*, DRO-001/003). 4. Redis → Valkey. Swap the cache image to valkey/valkey, add --requirepass, --maxmemory + --maxmemory-policy allkeys-lru, --save "" and no AOF (DRO-004/005/006/008). 5. Sandbox the scheduler. ofelia must not mount docker.sock; route it through tecnativa/docker-socket-proxy with only CONTAINERS/EXEC/POST (DRO-007). 6. Harden the pipeline. Add, in order: composer audit → build → Trivy --exit-code 1 gate → CycloneDX SBOM → push → cosign sign. Move dependency updates to a merge request. Add a weekly restore-verification job. Verify the fly download checksum in GitLab CI (SC-*, DRO-013). 7. Build hardening. Multi-stage Dockerfile; COMPOSER_AUTH as a BuildKit secret (never an ARG); non-root final stage; OCI labels; SGID (g+s) not SUID on writable dirs (CI-IMG-013/015, SC-005).
Verify
Run the bundled checker against the migrated repo:
python3 <skill>/checker/check.py . # the checker ships with this skillTarget: 100 % of repo-scope rules. The four advisory rules (DRO-019 three-repo split, DEPLOY-001 ci-colocation, DRO-017 php-fpm status, DRO-018 uptime monitoring) are estate/runtime concerns — report, do not gate.