# Security

> **Conformance:** L1 / L2 / L3 (see `README.md`)
> **Audience:** Human engineers AND AI coding agents.
> **Propagation (DOC-18):** changing a rule here ripples — walk DOCUMENTATION.md §10.4 and update its dependents in the same commit (README *Index of MUSTs*, the `mgf-fed standards` / `agents` CLI, and any parallel copies / pins the matrix names).

> **Scope.** This document is the cross-project **engineering security
> standard**. It is the bar that every MGF project — including
> `mgf-common` itself — commits to. It is NOT the same as the
> repository-root `SECURITY.md` file (which is the
> vulnerability-reporting policy a security researcher reads to know
> where to send a CVE). Both documents MUST exist; this one tells you
> how to write secure code, that one tells you how to receive a
> vulnerability report. See **SC-12** for the root file template.

---

## Rules box (read this first)

| ID | Rule |
|---|---|
| <a id="SC-01"></a>**SC-01** | **Secrets MUST live in a Vault**, never in `config.yaml`, env files committed to git, source code, log records, crash reports, or error messages. The vault boundary is defined in [`CONFIGURATION.md`](CONFIGURATION.md) §5. |
| <a id="SC-02"></a>**SC-02** | **Subprocess invocation MUST use `argv` lists with `shell=False`.** `shell=True` is forbidden in core code. User-supplied templates MUST go through `string.Template.safe_substitute` + `shlex.split`, not f-strings. |
| <a id="SC-03"></a>**SC-03** | **YAML/JSON loading from any source outside the application's own writes MUST use `yaml.safe_load` / `json.loads`.** `yaml.load` (without loader) is forbidden. `pickle` MUST NOT be used for IPC, persistence of untrusted data, or any cross-version state. |
| <a id="SC-04"></a>**SC-04** | **Files that may contain private data MUST be written with mode `0o600` and atomically** (temp file + rename). This includes `config.yaml`, the vault file, crash reports, scheduler/alert state, and any log file under `<state>/logs/` (rotating handler honours the parent dir mode). See **CF-04**. |
| <a id="SC-05"></a>**SC-05** | **Cryptographic primitives MUST come from `cryptography` (or stdlib `hashlib`/`hmac`/`secrets`).** Hand-rolled crypto is forbidden. Symmetric encryption uses an authenticated construction from `cryptography` — Fernet (AES-128-CBC + HMAC-SHA256) is the canonical mechanism; an equivalent authenticated cipher (e.g. AES-GCM via `cryptography`) is conformant. Password-derived keys use PBKDF2-HMAC-SHA256 with ≥600,000 iterations or Argon2id. `random.random` MUST NOT be used for any token, nonce, or key — use `secrets.token_*` / `os.urandom`. |
| <a id="SC-06"></a>**SC-06** | **TLS verification MUST be enabled on every outbound connection.** `verify=False`, `ssl.CERT_NONE`, `paramiko.AutoAddPolicy()` against unknown hosts, and equivalent disablements are forbidden in production code. Test fixtures MAY relax verification only when the test code path is unreachable in production (gated by an explicit `is_test` parameter, not by env-var sniffing). |
| <a id="SC-07"></a>**SC-07** | **Every external request MUST carry a finite timeout.** HTTP (`httpx.Client(timeout=…)`), SSH (`paramiko.connect(timeout=…)`), DB (driver-specific), subprocess (`subprocess.run(timeout=…)`). A missing `timeout=` argument is a defect — there MUST be no "blocks forever" path through any boundary. |
| <a id="SC-08"></a>**SC-08** | **Untrusted input MUST be validated at the boundary** before it reaches use-case code: typed (pydantic / dataclass), range-checked, length-bounded, allow-listed where the value space is small. The boundary is the parser (Click option, HTTP handler, IPC reader) — NOT the use case. |
| <a id="SC-09"></a>**SC-09** | **Sensitive fields MUST be redacted by the formatter before any log sink sees them**, including stderr, file, journald, syslog, and OTLP. The redactor's default key set covers the well-known names; per-project additions go through `Redactor(extra_keys=…)`, never by editing the default. See **LG-04**. |
| <a id="SC-10"></a>**SC-10** | **Dependencies MUST be pinned via a lockfile** committed to git (`uv.lock`, `poetry.lock`, `pip-tools` `requirements.txt`). CI MUST run a vulnerability scanner (`pip-audit`, `osv-scanner`, or `safety`) on every PR. A finding MUST be triaged before merge: fix, justify (link to upstream issue), or document an embargoed acceptance with an expiry date. |
| <a id="SC-11"></a>**SC-11** | **Tracebacks, exception messages, and crash reports MUST NOT leak secret material.** When an exception's `args`, `__cause__`, or `__context__` chain may carry a secret, the translation seam (rule **EH-02**) MUST scrub it before re-raising. Crash reporter output passes through the redactor before write. |
| <a id="SC-12"></a>**SC-12** | **A `SECURITY.md` MUST exist at the repository root** describing supported versions, the security contact (email or Codeberg/GitHub private-vulnerability channel), embargo expectations, and the disclosure timeline. Template in §11.1 below. |
| <a id="SC-13"></a>**SC-13** | **Cryptographic keys at rest MUST be derived from a passphrase via a KDF** (PBKDF2/Argon2), or stored in the system keyring. Keys MUST NOT be embedded in source, build artefacts, container images, or environment defaults. |
| <a id="SC-14"></a>**SC-14** | **Authentication failures MUST NOT enumerate.** Error messages MUST be uniform between "user does not exist" and "wrong password" / "wrong key". Repeated failures from the same source MUST be rate-limited — the per-source response delay MUST increase with the failure count (e.g. exponential backoff), OR the source MUST be placed in a temporary lockout that auto-expires after a bounded window. A permanent hard lock-out is forbidden: it is itself a denial-of-service and an enumeration oracle (a locked vs unlocked account leaks whether the account exists). |
| <a id="SC-15"></a>**SC-15** | **Filesystem operations MUST validate against directory traversal.** Any path constructed from external input MUST be normalised (`pathlib.Path.resolve()`) and verified to live inside the expected parent (`candidate.is_relative_to(parent.resolve())`). Use `resolve(strict=True)` only when the target is required to already exist (a read path); a not-yet-existing write target MUST use bare `resolve()`, since `strict=True` raises on it. |
| <a id="SC-16"></a>**SC-16** | **Privileged operations MUST be confined to a narrow, audited surface.** `sudo`, `setuid`, `chmod 4755`, container-`--privileged`, and Linux capability grants MUST be documented per call site, justified by a comment, and excluded from layers that don't need them (UI, parsers, formatters). |
| <a id="SC-17"></a>**SC-17** | **The application MUST emit a structured audit-log record at INFO level for every security-relevant action**: vault read/write, subprocess invocation against user-controlled argv, SSH connection open/close, config file write, key rotation, auth attempt. Records carry `audit=true` so SIEMs can filter cleanly. |
| <a id="SC-18"></a>**SC-18** | **Releases MUST be reproducible from source.** The published artefact (wheel, sdist, container) MUST be buildable from a tagged commit using only declared dependencies. Build metadata (Python version, hatchling version, OS) MUST be recorded in the release notes for any binary-wheel project. |

---

## 1. Mental model — defense in depth, ruthlessly pragmatic

Security is the **fourth first-class concern** of every PR alongside
correctness, reliability, and performance (rule **DP-06**). It is not
a phase, a sweep, or a checklist applied at the end. It is a property
of every change.

The model:

1. **Trust boundary maps.** Every project MUST have a one-paragraph
   description of its trust boundaries: who calls in (UI, SSH, IPC,
   network), who we call out to (libvirt, hypervisor, cloud SDK,
   remote agent), what crosses each boundary (config, command argv,
   secrets, telemetry).
2. **Minimum privilege.** Each layer requests the smallest set of
   privileges it can do its job with. The UI does not need raw
   subprocess. The parser does not need disk write. The use case
   does not need env-var read.
3. **Defense in depth.** No single mitigation is the last line —
   `argv`-only **and** input validation **and** audit logging.
   When one layer fails, the next catches.
4. **Fail closed.** When a security check is ambiguous, the
   application MUST refuse the action, log the refusal at WARNING,
   and surface an actionable message. "I'm not sure, so I'll allow
   it" is the failure mode that keeps incident-responders awake.
5. **Observable.** Every security-relevant action is logged
   (rule **SC-17**). An attacker who succeeds leaves a trail; a
   defender who responds reads it.
6. **No DIY crypto, no DIY auth.** Use vetted primitives
   (`cryptography`, system keyring, OAuth/OIDC libraries). The bar
   for "I'll write this myself" is "I have a published cryptanalysis
   credential and a peer-review path".

---

## 2. Secrets management

This section operationalises rule **CF-05** (secrets live in a vault,
not the config file). See [`CONFIGURATION.md`](CONFIGURATION.md) §5
for the full Vault interface; this section adds the security-side
rules.

### 2.1 What counts as a secret

A value is a **secret** if any of these is true:

- It authenticates a person or service (passwords, API keys, OAuth
  tokens, refresh tokens, session cookies).
- It signs or encrypts data (private keys, symmetric keys, MACs).
- Knowing it grants access to a resource (DB connection strings with
  embedded credentials, presigned URLs, vault unlock passphrases).
- Possession provides a meaningful capability (device IDs in some
  contexts, hardware fingerprints used for licence binding).

When in doubt, it is a secret.

### 2.2 Where secrets live

| Storage | What goes there | Why |
|---|---|---|
| **System keyring** (libsecret / Keychain / Credential Manager via `keyring` or `secretstorage`) | Per-user secrets | OS-managed, encrypted at rest, ACL by user |
| **Encrypted file vault** (Fernet AES-128, key derived from a user passphrase via PBKDF2) | Same, when no keyring is available | Cross-platform fallback; equivalent confidentiality |
| **Container/K8s mounted secrets file** (read by `pydantic-settings.file_secret_settings`) | Service deployments | The orchestrator is the secret source of truth |
| **Cloud KMS / HSM** | High-value keys in cloud deployments | Centralised rotation, audit trail |

### 2.3 Where secrets MUST NOT live

- `config.yaml`, `settings.toml`, or any other on-disk config.
- `.env` files committed to git. (`.env.example` showing knob names,
  not values, is fine.)
- Environment-variable defaults in `docker-compose.yml`,
  `pyproject.toml`, CI configuration files.
- Source code, including "TODO: replace before deploy" placeholders.
- Log records (the redactor catches the obvious cases — see **LG-04**).
- Crash reports, error messages, exception `args`.
- URLs (no `https://user:pass@host/`).
- Git history (use `git filter-repo` to purge if it slips).

A **pre-commit hook** SHOULD run `gitleaks` or `trufflehog` against
staged content. CI MUST run the same scan on every PR.

### 2.4 Backend selection — no silent fallback (DP-10)

`Vault.__init__` chooses a backend at construction time. The choice
MUST be:

1. **Logged at INFO** with `audit=true`:
   `LOG.info("vault backend selected", extra={"backend": "keyring", "audit": True})`.
2. **Queryable:** `vault.backend` returns `"keyring"` or `"file"`.
3. **Configurable:** the consumer can force a backend via config to
   prevent quiet downgrade in odd environments.

When the keyring backend fails at runtime (lock daemon crashed, dbus
unavailable), the operation MUST raise `VaultError` rather than
silently degrading to file. Falling back across runs is acceptable;
falling back mid-session masks compromise.

### 2.5 Credential lifecycle

Every credential type MUST have a documented lifecycle:

- **Issue.** How the secret enters the vault (user paste, OAuth
  callback, certificate enrolment, manual operator action).
- **Rotate.** How the secret is replaced. Rotation MUST be possible
  without downtime — the vault MUST support read-old-write-new with
  a grace window during which both values are accepted.
- **Revoke.** How the secret is invalidated. Revocation MUST trigger
  an audit log entry and MAY notify the owner.
- **Expire.** Time-bounded credentials (OAuth access tokens, signed
  URLs) MUST refresh automatically; the refresh failure path is a
  documented error case, not a silent stall.

---

## 3. Subprocess security (operationalises DP-06 + EH-02)

### 3.1 The argv-only rule

**Always:**

```python
# RIGHT
result = subprocess.run(
    ["qemu-img", "info", str(path)],
    timeout=10,
    capture_output=True,
    text=True,
    shell=False,
    check=False,
)
```

**Never:**

```python
# WRONG — shell injection vulnerability
subprocess.run(f"qemu-img info {path}", shell=True)
```

Even if `path` looks safe, ANY string-concatenated argv is a defect.
The shell wrapper at `<app>/common/shell.py::run_command` (template
in [`ERROR_HANDLING.md`](ERROR_HANDLING.md) §3.2) is the single
allowed call site for `subprocess.run` in core. CI MUST lint that
`subprocess` is imported in exactly one module.

### 3.2 User-supplied templates

When the user provides a template string (hooks, custom commands,
profile actions), it MUST be parsed safely:

```python
import shlex
import string

# User template: "echo Hello {name}"
template = string.Template(user_template_str.replace("{", "${").replace("}", ""))
expanded = template.safe_substitute(name=safe_name) # missing keys preserved literally
argv = shlex.split(expanded) # respects quoting; rejects partial quotes

result = run_command(argv, timeout=user_timeout_seconds)
```

`format()` and f-strings on user templates are forbidden — they
expand `__class__` and arbitrary attribute access on the formatted
object, which is the Jinja-style "format string injection" CWE-134
class of bug.

### 3.3 Process privilege separation

Long-running daemons MUST drop privileges as early as possible:

- Configure the systemd unit with `User=`/`Group=`, not root.
- `CapabilityBoundingSet=` to the minimum needed (libvirt
  socket access, network bind, etc.).
- `NoNewPrivileges=yes`, `ProtectSystem=strict`,
  `ProtectHome=tmpfs`, `PrivateTmp=yes` — see the unit template
  in [`COMMON_COMPONENTS.md`](COMMON_COMPONENTS.md) §"What you do
  NOT lift" (deployment templates).

Per-operation privilege escalation (sudo for one libvirt call) MUST
be replaced by a long-running unprivileged daemon talking to a
privileged socket — the libvirt-with-policy-kit shape.

---

## 4. Deserialization safety

### 4.1 YAML

**Always `yaml.safe_load`.** The default `yaml.load` constructs
arbitrary Python objects via `!!python/object` tags — this is
remote code execution if the YAML comes from any untrusted source.
The pin test:

```python
# tests/unit/security/test_yaml_safe.py
import yaml
import pytest

def test_yaml_load_is_forbidden(monkeypatch):
    """Sanity check: nothing in core uses yaml.load (only safe_load)."""
    import subprocess
    out = subprocess.run(
        ["grep", "-rn", "yaml\\.load(", "src/"],
        capture_output=True, text=True,
    )
    assert out.stdout == "", f"yaml.load found in core: {out.stdout}"
```

### 4.2 JSON

`json.loads` is safe by default — no executable content. But:

- **Validate the shape.** A typed pydantic model is the parser. Bare
  `dict[str, Any]` access in core code is a CF-01 violation.
- **Reject oversized payloads.** Set a byte limit before
  deserializing (`response.content[:1_000_000]` then `json.loads`).
  Without it, a hostile responder can OOM the process.

### 4.3 Pickle

**Forbidden** for:

- IPC (use msgpack or JSON with a typed schema).
- Persistence of state read by a different process or a different
  version (use the typed config + atomic-write pattern).
- Any input that crosses a trust boundary (network, file from a
  different user, downloadable artefact).

Pickle is acceptable inside a single process for ephemeral
multiprocessing handoff (`multiprocessing.Pool`) where the trust
boundary is the OS process boundary — but JSON / msgpack are
preferable because they survive a refactor that splits the worker
out of the main process.

### 4.4 Other formats

- **TOML:** stdlib `tomllib` (read-only, safe).
- **XML:** `defusedxml`, never the stdlib `xml.etree` directly
  (entity-expansion attacks).
- **CSV:** stdlib `csv` is fine; remember to disable cell-formula
  injection by quoting cells that start with `=`, `+`, `-`, `@`
  before writing.

---

## 5. File system security

### 5.1 File modes

| What | Mode | Why |
|---|---|---|
| `config.yaml` | 0o600 | May contain non-secret-but-private settings (paths, internal hostnames) |
| Vault file | 0o600 | Encrypted secrets at rest |
| Crash report | 0o600 | May contain redacted-but-still-sensitive context |
| Log files | 0o640 (or 0o600 if no log group) | Operator-readable; not world-readable |
| Owned state files (DB, cursor, cache) | 0o600 | Avoid leakage if home dir permissions are loose |
| Public artefacts (wheels, downloads) | 0o644 | Read-only to others |

The atomic-write pattern handles mode setting:

```python
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(content, encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
```

### 5.2 Atomic writes (rule CF-04)

See [`CONFIGURATION.md`](CONFIGURATION.md) §9. The temp + rename
pattern is the canonical shape for any state file the application
owns. A crash mid-write MUST NOT corrupt state.

### 5.3 Path traversal protection (rule SC-15)

When a path is constructed from external input (HTTP request, IPC
message, user-supplied template, file inside an archive), validate
that it stays within the expected root:

```python
def safe_join(root: Path, name: str) -> Path:
    """Resolve `name` under `root`, refusing any path that escapes."""
    candidate = (root / name).resolve()
    if not candidate.is_relative_to(root.resolve()):
        raise ValueError(f"path {name!r} escapes root {root!r}")
    return candidate
```

The check MUST happen on the resolved (`.resolve()`)
path — string-level checks like `if ".." in name` miss
encoded paths (`%2e%2e`), Unicode lookalikes, and Windows
`..\..\..\windows\system32`.

### 5.4 Symlink and hardlink races (TOCTOU)

When operating on a file the user controls (e.g., a config file
under `~/.config/<app>/`), open with `os.O_NOFOLLOW` to refuse
symlinks, and use file descriptors rather than re-resolving the
path between operations:

```python
import os

fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
try:
    with os.fdopen(fd, "rb") as fh:
        data = fh.read()
finally:
    pass  # fdopen owns the fd
```

For most application code this is over-engineering — the OS-level
file-mode protection on `~/.config/<app>/` is sufficient. Apply the
extra hardening when the file is in a directory with broader
write access (e.g., `/tmp/<app>/`).

---

## 6. Cryptography

### 6.1 What we use

| Use case | Library / primitive | Notes |
|---|---|---|
| Symmetric encryption at rest | `cryptography.fernet.Fernet` | AES-128-CBC + HMAC-SHA256, authenticated, versioned, URL-safe |
| Password-derived keys | `cryptography.hazmat.primitives.kdf.pbkdf2.PBKDF2HMAC` | SHA-256, ≥600,000 iterations (OWASP 2023 baseline), 16-byte salt from `secrets.token_bytes` |
| Strong password-derived keys | `argon2-cffi.PasswordHasher` | Argon2id, t=2 m=64MiB p=1 (OWASP baseline) |
| Hashing for integrity | `hashlib.sha256` / `hashlib.blake2b` | Never SHA-1 or MD5 |
| MAC | `hmac.new(key, msg, hashlib.sha256)` | `hmac.compare_digest` for comparison (constant-time) |
| Random tokens | `secrets.token_urlsafe(32)` | 256 bits of entropy by default |
| Random bytes | `secrets.token_bytes(n)` / `os.urandom(n)` | Never `random.randbytes` (PRNG, not CSPRNG) |
| TLS | stdlib `ssl` with default context | `ssl.create_default_context()` is hardened by default; never override `check_hostname=False` |
| Certificate pinning | `httpx` event hook + `cryptography.x509` | Use only when the upstream is a known fixed identity |
| SSH keys | `paramiko` with `RSAKey.from_private_key_file` | 4096-bit RSA or Ed25519; reject DSA |

### 6.2 Key rotation

Encrypted artefacts MUST carry a key version so re-encryption is
possible without breaking older artefacts in flight. The Fernet
"multi-fernet" shape:

```python
from cryptography.fernet import Fernet, MultiFernet

# v2 (current) and v1 (still readable for migration)
fernet = MultiFernet([Fernet(key_v2), Fernet(key_v1)])

decrypted = fernet.decrypt(blob) # tries v2, falls back to v1
re_encrypted = fernet.encrypt(decrypted) # always emits v2
```

The migration path: read with multi-fernet, write with single. After
all artefacts are re-encrypted (a sweep job, idempotent), drop v1
from the multi-fernet construction.

### 6.3 What we don't use

- **DIY crypto.** No hand-rolled AES, no "I XORed it with the
  password", no homemade signature schemes.
- **Deprecated primitives.** No MD5, SHA-1, DES, 3DES, RC4. Reject
  them in CI via a regex sweep:
  `git grep -E '(md5|sha1|MD5|SHA1)' src/` MUST return only
  documented integrity-not-cryptographic uses.
- **`random` module for security.** `random.SystemRandom` is OK
  (it wraps `os.urandom`), but the bare `random.X` calls use a
  Mersenne Twister PRNG that is not cryptographically secure. The
  pin test is a grep that allows `secrets.X` and
  `random.SystemRandom().X` but flags bare `random.X` outside
  test code.

### 6.4 At-rest key storage

Cryptographic keys MUST be derived from a passphrase or stored in
the system keyring. Embedded keys in source, build artefacts,
container images, or CI environments are forbidden.

Acceptable shapes:

- User passphrase → PBKDF2 → key (no key on disk).
- Random key → encrypted with user passphrase → on disk
  (`<state>/vault/key.enc`, mode 0o600).
- Random key → stored in system keyring under a known service name.
- Cloud KMS / HSM holds the key; application requests
  encrypt/decrypt operations.

The fallback chain is configurable via `Settings.vault.backend`;
the active choice is logged at startup (rule **SC-17**).

---

## 7. Network security

### 7.1 TLS

`httpx`, `requests`, `aiohttp`, stdlib `ssl` — every client MUST
verify by default. Production code MUST NOT set `verify=False`.

Tests MAY relax verification when the test fixture is reachable only
from test code. The relaxation MUST be parameter-explicit:

```python
# RIGHT
def fetch(url: str, *, verify: bool = True) -> Response:
    return httpx.get(url, verify=verify, timeout=10)

# In a test against a self-signed local server:
fetch("https://localhost:8443/", verify=False)
```

A pin test asserts no production code path passes `verify=False`:

```python
# tests/unit/security/test_tls_verify.py
import subprocess

def test_no_verify_false_in_src():
    out = subprocess.run(
        ["grep", "-rn", "verify=False", "src/"],
        capture_output=True, text=True,
    )
    assert out.stdout == "", f"verify=False found in src/: {out.stdout}"
```

### 7.2 Certificate pinning

For high-value endpoints (a known telemetry collector, a fixed-host
update server), pin the upstream certificate's fingerprint. The
`httpx` recipe:

```python
def make_pinned_client(url: str, expected_sha256: str) -> httpx.Client:
    def verify_pin(response: httpx.Response) -> None:
        peer_cert = response.extensions["network_stream"].get_extra_info("ssl_object").getpeercert(binary_form=True)
        digest = hashlib.sha256(peer_cert).hexdigest()
        if digest != expected_sha256:
            raise SecurityError(f"certificate pin mismatch: {digest}")
    return httpx.Client(event_hooks={"response": [verify_pin]})
```

Pinning is a maintenance burden — when the upstream rotates its
cert, the application breaks until the pin is updated. Apply only
where the cost of MITM is high enough to justify the operational
cost.

### 7.3 Timeouts everywhere (rule SC-07)

Every external call has a finite timeout. Recommended defaults:

| Surface | Default timeout |
|---|---|
| HTTP single request | 10 s |
| HTTP idempotent retry total | 30 s |
| SSH connect | 10 s |
| SSH command (interactive) | 60 s |
| SSH command (batch / long-running) | configurable, default 300 s |
| DB query (interactive) | 5 s |
| DB query (batch) | configurable |
| Subprocess (default) | 30 s |

A timeout MUST raise a typed exception (`GuestUnreachableError`,
`CommandError`), not a bare `httpx.TimeoutException`. The
translation seam (rule **EH-02**) does the conversion.

### 7.4 Outbound traffic policy

Production deployments SHOULD restrict outbound traffic at the
network layer (firewall, container egress policy) so the
application can only reach endpoints it explicitly needs. This is
defense-in-depth against credential exfiltration if the application
is compromised. Document the egress allowlist alongside the
deployment topology.

---

## 8. Input validation (rule SC-08)

### 8.1 The boundary

Validation happens at the **boundary** — the parser that ingests the
input. Use cases assume their input is valid.

| Boundary | Validator |
|---|---|
| CLI flag | `click.Choice`, `click.IntRange`, custom `click.ParamType` |
| HTTP request | `pydantic.BaseModel` |
| YAML/JSON file | `pydantic.BaseModel` (rule **CF-01**, **CF-07**) |
| Environment variable | `pydantic_settings.BaseSettings` field validator |
| IPC message | `pydantic.BaseModel` (msgpack/JSON deserialise → validate) |
| Subprocess output to be re-fed | Parse with a typed reader; reject anything that doesn't fit |

### 8.2 What to validate

- **Type.** A field declared `int` MUST reject `"123"` unless an
  explicit coercer is in scope.
- **Range.** Numeric fields use `Field(ge=…, le=…)`. Durations use
  positive lower bounds (`ge=1`).
- **Length.** String and collection fields use
  `Field(max_length=…)` to bound memory.
- **Allow-list.** Categorical fields (`Literal["a", "b", "c"]`) reject
  any other value.
- **Format.** Emails (`pydantic.EmailStr`), URLs (`pydantic.HttpUrl`),
  IP addresses (`ipaddress.ip_address`), UUIDs (`uuid.UUID`).
- **Semantic.** A "time window" struct must satisfy `start <= end`.
  Cross-field constraints go in a `@model_validator(mode="after")`.

### 8.3 What NOT to do

- Validate on the way OUT of the application (too late; the bad
  value already lived in memory).
- Validate by regex when a typed parser exists (regex-only
  validation drifts).
- Validate "in spirit" — `if x.startswith("/")` is not validation,
  it's a vibe.

### 8.4 Resource exhaustion

For inputs whose size is attacker-controlled, set a maximum size
**before** parsing:

```python
MAX_PAYLOAD = 1_000_000 # 1 MiB

raw = response.content
if len(raw) > MAX_PAYLOAD:
    raise ValidationError("payload too large")
data = MyModel.model_validate_json(raw)
```

For YAML, parse incrementally is hard — set the byte limit on the
input file before reading. For ZIP/TAR archives, refuse extraction
when the uncompressed-vs-compressed ratio exceeds a threshold (zip
bomb defense).

### 8.5 Environment variables are a trusted input

Environment variables MUST be treated as **trusted input from the
process owner** — the same trust level as a config file the
operator writes. They are NOT untrusted in the way an HTTP body or
a CLI flag taken from a downstream user is.

Concretely:

- Env-var values are **shape-validated** at the boundary
  (`pydantic_settings.BaseSettings` field validator — typed,
  range-checked, length-bounded) so a malformed value fails fast
  rather than corrupting downstream state. The validation is
  defensive, not adversarial: it catches the operator's typo, not
  a hostile attacker.
- Env vars MUST NOT carry secret values that the application
  treats as "trusted just because they came from the environment"
  — secret material goes through the vault (§2). A token in
  `$<APP>_API_KEY` is acceptable when the deployment topology
  treats the environment as a secret carrier (containers,
  systemd `LoadCredentialEncrypted=`); it is NOT acceptable as
  a default in source.
- **Side-effect URLs taken from env vars** (crash sinks, log
  sinks, webhook targets) inherit the env-var trust level:
  the application will POST data to whatever host the env-var
  names. This is correct behaviour — the operator who set the
  variable is the trust anchor — but every such surface MUST be
  **documented**: which env var names it, what data is sent,
  and the explicit warning that an attacker who can write the
  process environment can redirect that data.

The escape hatch when a process cannot trust its environment:
configure the side-effect URL via the typed `MgfSettings` object
(e.g., `MgfSettings(crash=CrashSettings(sinks=[...]))`), passed
in by code that came from the application's own trusted source.
The env-var path is a v0.1 convenience layer; settings-driven
configuration is the production-hardening shape.

Pin: a documented env-var sink that is NOT also documented
in `SECURITY_STANDARD.md` §16 or its consumer-side equivalent
is a defect, not a feature.

---

## 9. Logging hygiene (operationalises LG-04)

### 9.1 The redactor as the last line of defense

Every formatter holds a `Redactor` instance and runs it before
serialising the record (see [`LOGGING.md`](LOGGING.md) §6). The
default redactor catches:

- Keys named `password`, `token`, `secret`, `api_key`,
  `authorization`, etc. (case-insensitive, exhaustive list in the
  redactor module).
- Values matching `Bearer …`, `Basic …`, JWT-shaped strings, PEM
  blocks.

The redactor is **defense in depth** — code MUST NOT rely on it as
the primary defense. The primary defense is "don't put secrets in
log payloads in the first place":

```python
# WRONG — relying on the redactor
LOG.info("connecting", extra={"password": creds.secret})

# RIGHT — log a non-secret representation
LOG.info("connecting", extra={"username": creds.username})
```

Per-project additions go through the constructor:

```python
redactor = Redactor(extra_keys=frozenset({"x-internal-token", "session_uuid"}))
setup_logging(redactor=redactor)
```

**Worst-case bounds.** The redactor walks structured payloads in
place. Two robustness properties are explicitly held in the
`mgf-common` reference implementation:

- **Depth-capped recursion** — `_MAX_REDACT_DEPTH = 64`. A
  deeply-nested or pathologically-shaped payload cannot raise
  `RecursionError` *inside* the redactor itself (mid-scrub —
  which would propagate to the caller of `LOG.info(...)`).
  Beyond the cap, the redactor inserts a sentinel and returns.
- **Cycle guard** — `id()` set with `try / finally` pop. A
  self-referential payload (a `dict` that contains itself, or
  legitimately shared substructure) does not infinite-loop the
  redactor. SIBLING (legitimately shared) paths are walked
  both times; only true cycles are caught.

**Scope limitation.** The redactor scrubs STRUCTURED payload
keys (dict / list / tuple traversal) and KNOWN value patterns
(Bearer / JWT / PEM / OpenAI / GitHub / AWS / Slack / Google).
It does NOT pattern-match arbitrary `key=value` substrings
inside free-form strings — a string like
`"Connection(user='alice', password='hunter2')"` will NOT have
`hunter2` scrubbed unless `password` appears as a structured
key. Use typed log fields (`extra={"password": ...}`), not
formatted free-text, so the redactor can find what it must
scrub.

### 9.2 Tracebacks (rule SC-11)

A traceback MAY carry secrets in:

- `args` of an exception (`raise ValueError(f"bad password: {pw}")`).
- The exception's `__cause__` chain.
- Local variables in stack frames — Python doesn't include them by
  default, but `traceback.format_exception` with `chain=True` does
  include the cause messages.

The translation seam (rule **EH-02**) MUST scrub before re-raising:

```python
# In <app>/common/ssh.py::SSHClient.connect
try:
    self._client.connect(username=username, password=password, ...)
except paramiko.AuthenticationException as e:
    # NOTE: paramiko sometimes embeds the auth method in str(e). Even
    # though it doesn't include the password, scrub anyway in case
    # of upstream change.
    raise GuestUnreachableError(f"SSH auth failed for {username}@{host}") from None
    # `from None` suppresses __cause__ chain; the original exc is in
    # `__context__` which the JSON formatter does not log by default.
```

The crash reporter (`mgf.common.observability.report_now`) MUST run
the redactor over the report payload before write, in addition to
the per-record formatter redaction. This catches secrets that
slipped past the formatter into the structured `args` field of the
exception object itself.

**Defense-in-depth chain for crash reports** (three layers in
the `mgf-common` reference implementation):

1. **Bounded `repr()` on exception args.** The crash
   reporter formats each `exc.args[i]` through a `_safe_repr`
   allowlist — primitives (`str`, `int`, `float`, `bool`,
   `bytes`, `None`) and a small set of stdlib types
   (`Path` / `UUID` / `datetime` / `Decimal`) get the full
   `repr()`; anything else (custom classes, stdlib containers
   whose reprs delegate to elements) becomes `<TypeName>`.
   This bounds the leak surface for a custom class whose
   `__repr__` would otherwise surface secret state.
2. **Redactor pass before egress.** Every crash report
   passes through the active `Redactor` BEFORE the local
   atomic-write AND BEFORE any remote sink fires (Sentry /
   OTLP / HTTP webhook).
3. **Fail-safe minimal payload.** If the redactor
   itself raises mid-scrub (a custom regex with catastrophic
   backtracking, a malformed payload, etc.), the report is
   REPLACED with a *minimal* dict — timestamp + report id +
   app + version + source + exception type + platform +
   `redaction_status="failed"` — so the operator still sees
   the crash happened, but every potentially-secret-bearing
   field is dropped. Fail-safe, not fail-open.

Limit acknowledged: Python's `traceback.format_exception` bakes
`repr(exc)` (= `repr(exc.args[0])` for single-arg exceptions)
into the traceback line upstream of the crash reporter. The
bounded-`repr()` allowlist covers the structured `args` field
but does NOT rewrite the traceback string the stdlib formatter
built. Translation seams (rule **EH-02**) MUST scrub at the
seam, not rely on the report-time redactor catching
repr-embedded state.

### 9.3 Audit log records (rule SC-17)

Security-relevant actions emit a structured INFO record with
`audit=true`. The set:

| Action | Log fields |
|---|---|
| Vault read | `audit=true, event="vault.read", key=<key>, backend=<name>` |
| Vault write | `audit=true, event="vault.write", key=<key>, backend=<name>` |
| Vault delete | `audit=true, event="vault.delete", key=<key>` |
| SSH connect | `audit=true, event="ssh.connect", host=<host>, username=<u>` |
| SSH disconnect | `audit=true, event="ssh.disconnect", host=<host>` |
| Subprocess against user-controlled argv | `audit=true, event="exec.user", argv0=<first arg>, source=<hooks/profile/cli>` |
| Config write | `audit=true, event="config.write", path=<path>` |
| Auth attempt success | `audit=true, event="auth.ok", subject=<user>, source=<host>` |
| Auth attempt failure | `audit=true, event="auth.fail", subject=<user>, source=<host>` |
| Privilege escalation | `audit=true, event="priv.escalate", capability=<cap>` |

Audit records SHOULD NOT carry the secret material itself — only
the metadata of the action. SIEM consumers filter `audit=true` and
forward to a long-retention sink.

---

## 10. Dependency security (rule SC-10)

### 10.1 Pinned lockfile

Every project ships a `uv.lock` (or `poetry.lock`, or
`requirements.txt` generated by `pip-compile`) committed to git.
This pins every transitive dependency to a specific version + hash.

**Why hash, not just version:** registries can be mutated. Public
PyPI (where third-party deps live) is mutable in narrow ways (yanked
releases, but not deletions); the self-hosted devpi index that
serves `mgf-*` packages holds the `magogi/prod` stage **non-volatile**,
so a `name+version` is immutable *by the index* — but an admin with
devpi-host access can still toggle it volatile and `devpi remove` +
re-upload (see [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §8,
rule **PKG-07**). A hash pin defends against the scenario where an attacker
takes over the publish credential or the devpi host and re-publishes a known
version with malware — the hash mismatch fails the install regardless
of which index served it.

```bash
# Lock from pyproject.toml:
uv lock
git add uv.lock && git commit -m "deps: refresh lockfile"
```

### 10.2 Vulnerability scanning

CI MUST run a vulnerability scanner on every PR and main branch:

```yaml
# .woodpecker.yml step
- name: vuln-scan
  image: python:3.12-slim
  commands:
    - pip install pip-audit
    - pip-audit --strict --requirement uv.lock --desc
```

Or `osv-scanner`:

```bash
go install github.com/google/osv-scanner/cmd/osv-scanner@latest
osv-scanner --lockfile=uv.lock
```

A finding MUST be triaged before merge:

1. **Fix.** Bump the dependency to a patched version. Re-run the
   lockfile generator.
2. **Justify.** If the vulnerable code path is unreachable from this
   project's usage (e.g., a CVE in a TLS server when the project is
   only an HTTP client), document with a link to upstream issue and
   a one-paragraph justification, then suppress with the scanner's
   ignore mechanism (an `osv-scanner.toml` ignore stanza, etc.).
3. **Accept with embargo.** If neither fix nor justification is
   available, document the acceptance with an expiry date (max 30
   days). When the date arrives, CI MUST surface the unresolved
   item.

### 10.3 Supply chain

- Reproducible builds (rule **SC-18**).
- Sign release artefacts where the ecosystem supports it (Sigstore
  for PyPI; signed git tags via `git tag -s`).
- Pin GitHub/Codeberg actions / Woodpecker plugins to commit SHA,
  not floating tag.
- Mirror critical dependencies (private PyPI, Artifactory) for
  high-availability deployments.

---

## 11. Vulnerability response

### 11.1 The repository-root SECURITY.md (rule SC-12)

Every public repository MUST ship a `SECURITY.md` at the root. The
template:

```markdown
# Security Policy

## Supported versions

| Version | Supported |
|---------|--------------------|
| 1.x | :white_check_mark: |
| 0.x | :x: (pre-1.0; please upgrade) |

## Reporting a vulnerability

We take security seriously. If you discover a vulnerability,
please report it privately:

- **Email:** security@<domain> (PGP key: <fingerprint or link>)
- **Or:** open a private security advisory on Codeberg
  (`Settings → Security → Report a vulnerability`)

Please **do not** open a public issue or pull request until we have
had a chance to triage and patch.

### What to include

- A description of the vulnerability and its impact.
- Steps to reproduce (a minimal test case is ideal).
- The version(s) affected.
- Any suggested mitigations.

### What to expect

| Stage | Timeline |
|---|---|
| Acknowledgement | within 3 business days |
| Initial assessment | within 7 business days |
| Patch + release | within 30 days for high-severity, 90 days for medium |
| Credit + public disclosure | coordinated with the reporter |

We follow a **90-day default embargo**, extendable by mutual
agreement. After embargo, the vulnerability is disclosed with a
CVE (if applicable), credit to the reporter (unless declined), and
release notes.

### Out of scope

- Vulnerabilities in dependencies — please report upstream.
- Self-inflicted DoS (running as root with `--no-verify` flags
  configured by the operator).
- Issues requiring physical access to a user's machine.
```

### 11.2 Internal handling

When a vulnerability is reported:

1. **Acknowledge** to the reporter within the policy window.
2. **Reproduce** in a private branch. Fix in the same branch.
3. **Backport** the fix to every supported release line.
4. **Coordinate** the release. Ship the fixed version, then the
   advisory + CVE.
5. **Post-mortem** for any vulnerability with severity ≥ medium —
   write up in an internal doc covering: what happened, when it
   was introduced, what defense-in-depth layer caught (or failed to
   catch) it, what changes prevent the class.

### 11.3 CVE / advisory

For any project, file a security advisory via the Codeberg / GitHub
UI and (for severity ≥ medium) request a CVE. Keep the advisory's
metadata accurate: affected version range, fixed version, CWE, and
references.

**Registry mechanics — there is no auto-yank.** `mgf-*` packages
are distributed from the self-hosted devpi index (`magogi/prod`), not
pypi.org, and devpi has no PyPI-style "yank" that an advisory triggers
automatically. Removing an affected version is a **manual host-admin
action**: since the stage is non-volatile, an admin toggles it volatile
and runs `devpi remove <name>==<version>` on the devpi host. Always **ship the fixed version first**, then delete
the affected one, so no consumer is left unable to resolve a
replacement. The removed version's content is deleted (devpi's volatile
toggle allows removal), unlike pypi.org's yank-but-keep model — but a
published version is immutable by policy and deletion here is reserved
for vulnerability removal / botched-upload recovery, recorded in the
CHANGELOG / release log. See
[`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §8 (rule
**PKG-07**) for the immutability + delete/re-upload contract.

---

## 12. Threat model template

Every project MUST publish a one-paragraph threat model in its
`docs/ARCHITECTURE.md` (or equivalent). The shape:

```markdown
## Threat model

**Trust boundaries.**
- Inbound: CLI invocations from the local user; HTTP from the same-host
  GUI; SSH from a remote operator (where applicable).
- Outbound: libvirt over the local UNIX socket; HTTP to the
  configured OTel collector; SSH to operator-controlled guests.

**Attacker capabilities we defend against.**
- Local unprivileged user attempting to read another user's vault.
- Network attacker attempting MITM on the OTel exporter or HTTP
  webhook.
- Hostile guest VM attempting to exfiltrate via the SSH connection
  the operator opens.
- Supply chain compromise of a transitive dependency.

**Capabilities explicitly out of scope.**
- Local root attacker (nothing on the box is ours to defend at that
  point).
- Physical access to the host.
- Side-channel attacks (timing, cache, etc.) — defer to upstream
  cryptography library.

**Mitigations in this project.**
- Vault uses keyring/Fernet (SC-01..05).
- All outbound TLS verified, OTel collector cert pinned (SC-06,
  optional pinning per §7.2).
- SSH validates host key against known_hosts; never AutoAddPolicy
  in production (SC-06).
- Lockfile committed, pip-audit on CI (SC-10).

**Known residual risks.**
- A user with shell access on the local machine can read this
  user's keyring. Defense: per-user accounts; do not run as a
  shared user.
- An attacker who compromises the systemd unit running the
  scheduler can run hooks as the unit's user. Defense: minimum
  capabilities (SC-16); audit log review.
```

This is **not boilerplate** — every project has a unique trust
boundary map. Copy the structure, customise the content. A two-page
threat model that is correct beats a ten-page one that paraphrases
this template.

---

## 13. Anti-patterns (forbidden)

| Anti-pattern | What's wrong | Right shape |
|---|---|---|
| `subprocess.run(cmd_str, shell=True)` | Shell injection (CWE-78) | argv list + `shell=False` + validated input |
| `yaml.load(stream)` (no Loader) | Arbitrary code execution (CWE-502) | `yaml.safe_load` |
| `pickle.load(open("untrusted.pkl"))` | RCE if untrusted | JSON or msgpack with typed schema |
| `httpx.get(url, verify=False)` in production | MITM (CWE-295) | Default `verify=True`; pin if warranted |
| `requests.get(url)` without `timeout=` | DoS via slow loris | Always set `timeout=…` |
| Storing a token in `config.yaml` | Plaintext secret in git / logs | Vault (SC-01) |
| `random.choice(charset)` for a session ID | Predictable PRNG (CWE-330) | `secrets.token_urlsafe(32)` |
| `hashlib.md5(password)` for any auth use | Broken hash (CWE-327) | Argon2id or PBKDF2 with high iteration count |
| `if user_input in os.listdir(folder)` validation | TOCTOU + traversal (CWE-22) | `safe_join(root, name)` with `is_relative_to` check |
| `LOG.info(f"sending {request_body}")` | Secret leak through logs | Log non-secret fields; rely on redactor as backup |
| `paramiko.AutoAddPolicy()` against unknown hosts in production | MITM via host-key spoofing | `RejectPolicy` + explicit known_hosts file |
| `except Exception: log_and_continue()` around vault calls | Auth failure masked | Re-raise typed `VaultError`; let the caller handle |
| `os.system("rm -rf …")` | shell injection + no error handling | argv list via `run_command` |
| Embedding a private key in source | Supply-chain leak | KDF + encrypted file or system keyring |
| `assert is_authorised(user)` | Compiled out by `-O` | `if not is_authorised(user): raise PermissionError(...)` |

---

## 14. Self-assessment checklist (every PR)

Before requesting review, the author MUST be able to answer YES:

1. Are all new external boundaries (HTTP, SSH, subprocess, file
   read of user-controlled paths) covered by validation, timeout,
   and typed exception translation?
2. Are all secrets sourced from the vault (or container secret
   mount), never from `config.yaml`, env defaults, or source?
3. Are all new log records that touch user-controlled data either
   filtered through the redactor or explicitly listing non-secret
   fields?
4. If touching crypto: is it `cryptography` / stdlib `hashlib` /
   `secrets`, never DIY?
5. If adding a subprocess invocation: is it through `run_command`,
   with `argv` list and `shell=False`?
6. If touching deserialization: is it `safe_load` / `loads` with a
   typed parser at the boundary?
7. If touching file write: atomic + mode 0o600 via the canonical
   pattern?
8. If adding a security-relevant action: does it emit an
   `audit=true` log record?
9. Does the change require new dependencies? If yes, are they pinned
   in the lockfile and clean under the vuln scanner?
10. If escalating privileges (sudo, capability, container privileged):
    is the surface as small as possible and documented per call site?

---

## 15. Mechanical enforcement matrix

A MUST that no tool can check for relies entirely on code review and
will rot. The standards aim to make every MUST checkable. Today's
state:

| Rule | Mechanical check | Notes |
|---|---|---|
| SC-01 secrets-in-vault | `gitleaks` / `trufflehog` pre-commit + CI | False positives need allow-list curation |
| SC-02 argv-only | `grep -rn "shell=True"` in CI | + `import-linter` "subprocess imported in only one module" |
| SC-03 safe_load | `grep -rn "yaml\\.load("` in CI | Pickle: import-linter contract excluding pickle from core |
| SC-04 0o600 atomic | code review + canonical helper | Helper signature enforces both |
| SC-05 vetted crypto | `grep -rn "hashlib\\.md5\|hashlib\\.sha1\|random\\."` | + dependency policy disallowing `pycrypto` (vulnerable) |
| SC-06 TLS verify | `grep -rn "verify=False\|CERT_NONE\|AutoAddPolicy"` in CI | Test exceptions need `# noqa: SC006-test` annotation |
| SC-07 timeouts | `ruff` rule + custom AST checker | A `requests.get(...)` without `timeout=` fails CI |
| SC-08 input validation | code review + pydantic at boundaries | `pydantic.BaseModel.model_validate` is the canonical entry |
| SC-09 redaction | unit test asserting `password=x` → `[REDACTED]` | LG-04 already pins this |
| SC-10 lockfile + scan | CI step (pip-audit / osv-scanner) | Required PR gate |
| SC-11 traceback scrubbing | code review at translation seams | Hard to check mechanically; covered by §9.2 conventions |
| SC-12 root SECURITY.md | repo lint: file existence + grep for "Reporting" header | One-time per repo |
| SC-13 KDF for keys | code review | + `grep -rn "KEY = b'"` in CI |
| SC-14 no auth enumeration | code review at the auth boundary | + integration test asserting equal response time within 50ms |
| SC-15 path traversal | helper function + grep for raw `Path(root)/user_input` | Helper enforces `is_relative_to` |
| SC-16 minimum privilege | systemd unit review + `git grep "sudo\|setuid"` | + capability declarations |
| SC-17 audit log | unit test per audit event | Test reads structured log capture, asserts `audit=True` field |
| SC-18 reproducible build | CI builds + checksums match published | `twine check dist/*` + manual verify post-publish |

This matrix is the **promise** of the standard: every line above is
something a CI job, a lint rule, a unit test, or a one-shot repo
audit can verify. A new MUST proposed for inclusion in a future
version of this doc MUST come with a mechanical check or a written
justification for why it's review-only.

---

## 16. AS-IS in `mgf-common`

- **Vault** — shipped in `mgf.common.vault` (file + env +
  keyring backends with a registry that picks per-deployment).
  Interface documented in [`CONFIGURATION.md`](CONFIGURATION.md) §5.
- **Subprocess wrapper** — not in `mgf.common`; this is a
  per-project template (each consumer owns its subprocess
  seam). See [`COMMON_COMPONENTS.md`](COMMON_COMPONENTS.md) §5
  for the shape.
- **Atomic writes** — `_write_yaml_atomic` in
  `src/mgf/common/config/_loader.py`; `_write_local` in
  `src/mgf/common/observability/crash_reporter.py`. Both honour
  mode 0o600.
- **Redactor** — `src/mgf/common/observability/redaction.py`. Default
  key set covers the well-known names; `Bearer`, JWT, PEM patterns;
  `extra_keys` for per-project additions.
- **Crash reporter defense-in-depth** — (a) `_safe_repr` allowlist
  bounds exception-arg `repr()` to primitives + stdlib types
  whose `__repr__` is structurally fixed; (b) the active
  `Redactor` runs over the report payload BEFORE the local
  atomic-write AND BEFORE any remote sink fires (per **SC-11**);
  (c) on redactor failure, a fail-safe **minimal payload**
  ships — timestamp + report id + app + version + source +
  exception type + platform + `redaction_status="failed"` —
  so the operator still sees the crash happened, but every
  potentially-secret-bearing field is dropped. The
  `Redactor` itself is depth-capped (`_MAX_REDACT_DEPTH = 64`)
  and cycle-guarded; a pathological payload can't blow the
  redactor's stack mid-scrub.
- **Runtime snapshot hardening** (per **SC-04**) — `MGF_DOCTOR_RUNTIME_PATH`
  snapshots are written via `atomic_write` at mode `0o600`,
  and the snapshot's settings dict has secret fields
  (`crash.sentry_dsn`, `crash.http_url`, `crash.otlp_endpoint`)
  replaced with `<set>` / `<unset>` presence indicators rather
  than the raw values. Operators see "is this configured?",
  not the URL itself. Per **SC-04**.
- **`<APP>_CRASH_SINK` env-var trust** — the v0.1 fallback path
  accepts an arbitrary `http://` / `https://` URL via the
  `<APP>_CRASH_SINK` env var; the crash report is POSTed there
  unmodified (after redaction). This is the env-var-trust shape
  documented in §8.5: the operator who sets the variable is the
  trust anchor. A process whose environment cannot be trusted
  MUST configure crash sinks via `MgfSettings.crash.sinks`
  instead and ignore the env-var path entirely. See
  [`docs/reference/crash_reporting.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/reference/crash_reporting.md)
  §"Trust assumption" for the consumer-facing note.
- **No DIY crypto** — `mgf.common` uses no cryptographic primitives
  itself; the optional `[observability]` extra pulls in `sentry-sdk`
  which uses TLS by default.
- **Dependency policy** — pinned via `uv.lock` (committed). Vuln
  scan: `pip-audit --strict` runs in `.woodpecker.yml` (rule
  **SC-10**, ✅).
- **Secret scan** — `gitleaks` runs in `.woodpecker.yml` (rule
  **SC-01**, ✅). Config in `.gitleaks.toml` allowlists
  the redactor's own test fixtures and pattern literals.
  Pre-commit hook is operator-configurable (`pre-commit install`
  + the gitleaks pre-commit recipe); CI is the gate.
- **SBOM** — CycloneDX 1.x manifest generated by `cyclonedx-py`
  on every CI run, shipped as `dist/mgf-common-<version>.cdx.json`
  (rule **SC-18**, ✅). Source-of-truth recipe in
  `.woodpecker.yml::sbom`; consumer-facing doc in
  [`RELEASING.md` §2.3](RELEASING.md#23-sbom-emission-rule-sc-18).
  PyPI does not currently host CycloneDX SBOMs — for now the
  artifact is published via the Woodpecker run UI + release
  notes; PyPI-native hosting is a follow-up.
- **Repo-root `SECURITY.md`** — **not present yet** — **gap** (rule
  **SC-12**), template in §11.1.

---

## 17. Cross-references

- Vault interface and config-file boundary: [`CONFIGURATION.md`](CONFIGURATION.md) §5.
- Translation seams (rule EH-02) where traceback scrubbing happens: [`ERROR_HANDLING.md`](ERROR_HANDLING.md) §3.
- Redactor implementation + log-record pipeline: [`LOGGING.md`](LOGGING.md) §6.
- Subprocess wrapper template: [`ERROR_HANDLING.md`](ERROR_HANDLING.md) §3.2.
- Test patterns for security paths (negative cases): [`TESTING.md`](TESTING.md) §10.
- The first-class-concerns rule (DP-06) that elevates security to PR-level scrutiny: [`DESIGN_PRINCIPLES.md`](DESIGN_PRINCIPLES.md) §1.5.

---

*End of `SECURITY_STANDARD.md`.*
