# Security Policy

blamejs is a security-first framework. The defaults are post-quantum, sealed-by-default, audit-chained, and tamper-evident from line zero. This document describes how we handle vulnerability reports, what we commit to, what's in scope vs. out of scope, and the operator-side responsibilities that turn the framework's defaults into a defensible deployment.

---

## Reporting a vulnerability

**Do not file a public issue for a security report.**

Email: `security@blamejs.com`

Please include:

- Affected version (`v0.X.Y` tag, or main `<sha>`)
- A description of the issue and the impact you observed
- A reproducer — minimal code, request, or config that triggers the behavior
- Whether you've discussed this with anyone else, including coordinated-disclosure timelines

Encrypt the report with the maintainer PGP key if the report itself is sensitive (key fingerprint published on the project's [Security tab on GitHub](https://github.com/blamejs/blamejs/security)).

### Verifying release authenticity

From **v0.9.7 onward**, every release tag is an annotated, SSH-signed tag. The `release-tags` ruleset on the repository refuses any unsigned or lightweight tag push, so the signed-tag invariant is server-side enforced.

Verify before deploying:

```sh
git fetch --tags
git tag -v vX.Y.Z          # must print: Good "git" signature for RobertLeeLW@gmail.com
```

Earlier releases (`v0.9.6` and earlier) were tagged as lightweight commits before the signing pipeline landed; `git tag -v` will report *"cannot verify a non-tag object of type commit"* on those. They remain verifiable via the **two other trust roots** that have been attached since v0.4.x and v0.6.x respectively:

- **npm tarball** — SLSA L3 provenance via OIDC. `npm view @blamejs/core@vX.Y.Z --json | jq .dist` returns the integrity hash; `gh attestation verify` walks the provenance chain back to the workflow run.
- **SBOM** (`sbom.cdx.json` + `sbom.vendored.cdx.json`) — Sigstore-keyless signed by the publish workflow's OIDC token. The verifier identity pins both the workflow PATH (not any workflow under the repo) and the Rekor transparency log URL so a DNS / HTTPS MITM cannot redirect the Rekor lookup to a forged log:

  ```sh
  cosign verify-blob --bundle sbom.cdx.json.sigstore           \
    --certificate-identity-regexp '^https://github.com/blamejs/blamejs/\.github/workflows/npm-publish\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
    --certificate-oidc-issuer     'https://token.actions.githubusercontent.com' \
    --rekor-url                   'https://rekor.sigstore.dev'                  \
    sbom.cdx.json
  ```

  For offline / air-gapped verification, set `SIGSTORE_NO_CACHE=1` + `TUF_ROOT=/path/to/local/tuf-repo` (a `tuf repo init` clone of `https://sigstore-tuf-root.storage.googleapis.com/` snapshotted at install time) so cosign reads the Sigstore TUF root from disk rather than over the network. Capture the root metadata once from a trusted channel and verify its signature against the published Sigstore root operators (see `https://docs.sigstore.dev/about/tuf/` for the rotation cadence).

Maintainer SSH signing key (Ed25519, applies v0.9.7+):

| Field | Value |
|---|---|
| Email | `RobertLeeLW@gmail.com` |
| Fingerprint (SHA-256) | `SHA256:5oF/XWhFpMde9TRfEX2GAHiApAq/MXOS4vti5zQbD7g` |
| Public key file | `https://github.com/dotCooCoo.keys` (filter for `ssh-ed25519`) |
| Registered as | GitHub SSH signing key (`desktop2-signing`) |

To verify locally without trusting GitHub's UI, fetch the public key, write your own `allowed_signers` file, and run `git tag -v`:

```sh
curl -sf https://github.com/dotCooCoo.keys                                                       \
  | grep "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPiE/PETpyiVPd8aMygJ+S9CsSVolp4HQZaAuiYVwbBa"      \
  | awk '{print "RobertLeeLW@gmail.com namespaces=\"git\" "$1" "$2}'                             \
  > /tmp/blamejs-allowed-signers
git -c gpg.ssh.allowedSignersFile=/tmp/blamejs-allowed-signers tag -v vX.Y.Z
```

PQC release-signing key (ML-DSA-65, applies v0.11.18+):

| Field | Value |
|---|---|
| Algorithm | `ML-DSA-65` (FIPS 204) |
| Public key file | `keys/release-pqc-pub.json` (committed in-tree at the release SHA) |
| Fingerprint (SHA3-512) | `ad6bee961782cd01a0751286c23ddc04a6a0ce5d2672cfb6f4ade0cc7cdc62c351c857599e9d22996e91ee56462ddf3939951808286132335d56d3bfe99d2ede` |

Every tarball ships with a `<tarball>.mldsa.sig` sidecar — an ML-DSA-65 signature over the tarball bytes computed with the framework's vendored `noble-post-quantum` primitive. The matching public key is committed in-tree at `keys/release-pqc-pub.json` and is covered by the SSH-signed tag (the first trust root) — operators fetch it from `raw.githubusercontent.com` at the tag, NOT from the GH Release page (release assets carry the tarball + digests + sigs only).

```sh
TAG=vX.Y.Z

# Tarball + PQC sidecar from the GH Release.
gh release download "$TAG" --repo blamejs/blamejs   \
  --pattern '@blamejs-core-*.tgz'                   \
  --pattern '@blamejs-core-*.tgz.mldsa.sig'

# Public key from the git source at the same tag (SSH-tag-signed,
# verifiable via `git tag -v` above).
curl -fsSL -o release-pqc-pub.json   \
  "https://raw.githubusercontent.com/blamejs/blamejs/${TAG}/keys/release-pqc-pub.json"

# Verify the sig with the framework's own ML-DSA-65 primitive —
# no Sigstore dependency, no external verifier binary.
node -e '
  var b   = require("@blamejs/core");
  var fs  = require("node:fs");
  var pub = JSON.parse(fs.readFileSync("release-pqc-pub.json", "utf8"));
  var sig = fs.readFileSync(process.argv[1]);
  var msg = fs.readFileSync(process.argv[2]);
  var ok  = b.pqcSoftware.ml_dsa_65.verify(
    sig, msg, Buffer.from(pub.publicKey, "base64url")
  );
  if (!ok) { process.stderr.write("MLDSA verify FAILED\n"); process.exit(1); }
  process.stderr.write("MLDSA verify OK (fingerprint " + pub.fingerprint_sha3_512.slice(0, 16) + "…)\n");
' @blamejs-core-vX.Y.Z.tgz.mldsa.sig @blamejs-core-vX.Y.Z.tgz
```

Compare the printed fingerprint against the SHA3-512 above — they must match. The in-tree `keys/release-pqc-pub.json` is committed alongside every release commit; an attacker swapping the public key would have to also forge the SSH-signed tag covering that commit (the first trust root). The four trust roots chain: a verified tag signs the public key file, the public key verifies the sig sidecar, the sig sidecar covers the tarball bytes.

Re-running `scripts/generate-release-signing-key.js` rotates the key. Rotation updates `keys/release-pqc-pub.json`, requires an operator-side `gh secret set RELEASE_PQC_SIGNING_KEY --env npm-publish` to match, and ships the new fingerprint in this SECURITY.md table in the same commit. The previous fingerprint stays archivable via `git log -- keys/release-pqc-pub.json`.

The four trust roots — SLSA L3 npm provenance, Sigstore-keyless SBOM signing, SSH-signed tags (v0.9.7+), ML-DSA-65 release-signing sidecar (v0.11.18+) — are independently verifiable. Tampering with any single root is detected by the others.

### Verifying SLSA L3 provenance with `slsa-verifier`

`gh attestation verify` walks the provenance chain via the GitHub API. For an offline / API-independent verification path, pin `slsa-verifier` v2.7.1 ([slsa-framework/slsa-verifier releases](https://github.com/slsa-framework/slsa-verifier/releases/tag/v2.7.1)):

```sh
TAG=vX.Y.Z
VERSION="${TAG#v}"

# Download the npm tarball + SLSA L3 attestation.
gh release download "$TAG" --repo blamejs/blamejs   \
  --pattern '@blamejs-core-*.tgz'                   \
  --pattern "blamejs-${VERSION}.intoto.jsonl"

slsa-verifier verify-artifact "@blamejs-core-${VERSION}.tgz"  \
  --provenance-path "blamejs-${VERSION}.intoto.jsonl"          \
  --source-uri      github.com/blamejs/blamejs                 \
  --source-tag      "$TAG"
```

For air-gapped / offline use, point `slsa-verifier` at a locally-snapshotted Sigstore TUF root (`--trusted-root /path/to/trusted_root.json`, captured once from a trusted channel + verified against the published Sigstore root operators).

**What this proves vs. what it doesn't.** SLSA L3 provenance binds the tarball bytes to *this exact workflow run on this exact commit on this exact tag*. It does NOT prove the source itself is clean — the [TanStack 2025-05-11 incident](https://blog.tanstack.com/the-tanstack-may-2025-supply-chain-attack/) shipped 84 malicious `@tanstack/*` versions with valid SLSA L3 provenance because the source side was compromised. Operators verifying release integrity should pair `slsa-verifier` with the [sha-to-tag verification](#verifying-release-commit-integrity) recipe below.

### Verifying release-commit integrity

Given a published tag, this confirms the tag's commit SHA is on `main`'s first-parent history and is the result of a merged PR (not a force-push, not a tag-mutation, not a sneaky direct push):

```sh
TAG=vX.Y.Z
git fetch --tags origin

# 1. Verify the tag signature (also captures the SSH-signing chain).
git tag -v "$TAG"

# 2. Resolve the commit the tag points at.
SHA=$(git rev-list -n 1 "$TAG")

# 3. Confirm the SHA is on main's first-parent history (refuses if the
#    tag points at a sidetracked commit).
git merge-base --is-ancestor "$SHA" origin/main && echo "on-main: OK"

# 4. Confirm the commit was merged via a PR (squash-merge SHA appears
#    once on first-parent; refuses on direct push to main).
PR=$(gh api "repos/blamejs/blamejs/commits/${SHA}/pulls" --jq '.[0].number')
test -n "$PR" && echo "merged via PR #${PR}: OK"
```

The same chain is enforced server-side on every tag push by the
`sha-to-tag-verify` workflow — the publish workflow refuses to proceed if any link fails. The recipe above lets operators re-run the check independently.

This is the defense against the tag-mutation class (CVE-2025-30066: `tj-actions/changed-files` retroactive tag rewrite, affected 23,000+ repos in March 2025) and the source-side-malicious-publish class (TanStack 2025-05-11). Paired with `slsa-verifier` above, they cover the source side AND the build side.

### Response time

| Severity | First response | Triage / acknowledgment | Fix released |
|---|---|---|---|
| Critical (RCE, auth bypass, vault compromise, audit-chain tampering) | within 24 h | within 72 h | within 7 d |
| High (CSRF / origin / session bypass, sealed-data leak path) | within 72 h | within 7 d | next patch (≤ 14 d) |
| Medium (info-disclosure without auth bypass, DoS) | within 7 d | within 14 d | next patch (≤ 30 d) |
| Low (defense-in-depth gaps, log-redaction misses) | within 14 d | within 30 d | next minor |

We coordinate with the reporter on disclosure — typical embargo is 14 days post-fix-released to give operators time to upgrade. Reporter credit is included in the `SECURITY` section of the release notes unless they request anonymity.

---

## Supported versions

Pre-1.0, the supported version is the most-recent published patch on the most-recent minor. Older minors do not receive security backports unless the issue is critical AND the operator base on the older minor is non-trivial.

Once 1.0 ships, the LTS calendar takes effect: each major gets 24 months of security-only patches after the next major's release.

| Version range | Security patches |
|---|---|
| Latest `v0.x` minor — current patch line | yes |
| Older `v0.x` patch lines | no |

---

## Threat model

What blamejs defends against, by design:

- **Disk theft of an offline data dir** — `vault.key.sealed` (wrapped mode) + sealed columns + audit chain mean the data dir alone is opaque without the vault passphrase. Plaintext mode is dev-only and prints a `WARNING:` on every boot.
- **Future quantum decrypt of currently-stored ciphertext** — every encrypted-at-rest blob uses ML-KEM-1024 + P-384 hybrid KEM and XChaCha20-Poly1305. There's no classical-only fallback to harvest now and decrypt later.
- **Audit-chain tampering** — every audit row carries `prevHash` + `rowHash` + `nonce` + `fencingToken`; the chain is verified at boot via `auditChain.verifyChain` and any mismatch refuses subsequent appends. Checkpoints are signed with SLH-DSA-SHAKE-256f. An attacker rewriting history needs to rewrite every subsequent hash AND forge the signing key.
- **Cross-site request forgery on state-changing routes** — `csrfProtect` cookie-mode (double-submit pattern) + `SameSite=Lax` cookie + `Origin` / `Sec-Fetch-Site` checks in CORS.
- **Drive-by scrapers / low-effort bots** — `botGuard` middleware fingerprints `User-Agent` + `Sec-Fetch-*` + `Accept-Language`.
- **Online brute-force against credentials** — `b.auth.lockout` tracks failed attempts per account (or any operator-chosen key) and engages an exponential-backoff lockout (1m → 5m → 15m → 1h → 6h+, clamped). State lives in `b.cache` so it shares across cluster nodes when the cluster backend is wired. Operator-driven `unlock(key, { req, reason })` audits with the admin's 5 W's. Backend errors fail open (the framework's job is to slow attackers, not to lock operators out of their own admin accounts when Redis dies).
- **Inline-script injection** — strict CSP default (`script-src 'self' 'nonce-...'`) blocks anything an XSS payload could ship.
- **Algorithm-substitution attacks** — every encrypted blob carries a 4-byte algorithm-ID header; `b.crypto.decrypt` dispatches on the header bytes, not on a guess at the active default. An attacker swapping a weaker algorithm into the envelope fails the AEAD tag check.
- **Supply-chain compromise via npm transitive deps** — zero npm runtime dependencies. Every external library is vendored under `lib/vendor/` with a manifest pinning version + license + provenance. Build reproducibility is verified via the GHCR image's SLSA provenance attestation (see DEPLOY.md → "Release verification").
- **Replay of API requests** — `apiEncrypt` middleware nonce-stores + replay-windows the `_ek` field; old session keys can't be reused.
- **Server-Side Request Forgery on outbound calls** — `b.ssrfGuard` resolves the hostname of every `b.httpClient.request({ url })` and refuses any IP in private / loopback / link-local / cloud-metadata / reserved ranges (incl. AWS / GCP / Azure metadata at 169.254.169.254). Wired default-on; operators on internal-mesh deployments override the loopback / private / link-local / reserved classes per call via `allowInternal: true | CIDR[]`. Cloud-metadata IPs are an unconditional hard-deny — no `allowInternal` value bypasses them, because metadata services leak instance credentials and a blanket override would let any compromised request exfiltrate them. Webhook delivery, OAuth, mail HTTP transports, object-store, and notify all inherit the gate.
- **Cross-row sealed-data smuggling** — `b.vault.aad.seal(plaintext, { table, rowId, column, schemaVersion })` binds the AEAD authentication tag to the column's identity tuple. A copy-paste of a sealed value between rows, a schema-version replay, or a table-mismatch substitution surfaces as a refused decrypt rather than silent disclosure. `reseal(value, fromAad, toAad)` re-binds during schema migrations after authenticating the source.
- **Tampered vendored library between releases** — `b.configDrift.verifyVendorIntegrity({ manifestPath })` runs at boot and compares every artifact under `lib/vendor/*` against its SHA-256 in `MANIFEST.json`. Mismatches abort start with an audit row under the `vendor` namespace (`vendor.integrity.tampered`); successful verification audits as `vendor.integrity.verified`. Operators wire this into the boot sequence ahead of opening any listener.
- **Adversarial input crashing a parser / validator** — every `lib/safe-*.js` and `lib/guard-*.js` primitive (including nested `lib/parsers/safe-*.js`) is fuzzed with coverage-guided libFuzzer harnesses via jazzer.js. ClusterFuzzLite runs every PR (300s budget per target) + a daily batch (1800s + 600s coverage); OSS-Fuzz runs the same harnesses continuously on Google's infrastructure once the upstream submission lands (project config under `oss-fuzz/projects/blamejs/`). Findings ship with minimized reproducers + persist in the regression corpus across runs. The coverage gate in `test/layer-0-primitives/codebase-patterns.test.js` refuses any future parser primitive that lands without a matching `fuzz/<name>.fuzz.js` harness (or an audited `FUZZ_NOT_REQUIRED` allowlist entry with reason).
- **Honeytoken trip detection** — `b.honeytoken` issues canary credentials (fake API key shapes, fake admin URLs, fake row IDs) registered with the framework but never handed to a real client. Any positive lookup against the registry — in a request, log search, or DB query — emits a `honeytoken.tripped` audit row regardless of where the request landed. Operators wire alerting against the audit-chain stream; the framework refuses the request silently in production and never confirms the token was a honeypot.
- **Account-takeover incident response** — `b.atoKillSwitch.trigger({ userId, reason, actor })` composes session destruction across the cluster + `b.auth.lockout` engagement + optional `b.auth.accessLock` global mode flip into a single deterministic operator-callable workflow with a single audit row capturing the actor and reason. Trigger conditions stay in operator territory (SOC alert, fraud signal, user self-report); the cleanup path is fixed.

What blamejs does **not** defend against (operator responsibility):

- **The vault passphrase being weak or reused** — `BLAMEJS_VAULT_PASSPHRASE` is the single secret that unlocks the entire data dir in wrapped mode. Argon2id makes brute-force expensive; a memorable 8-char passphrase is still memorable to an attacker.
- **A compromised admin login** — sessions inherit whatever the admin can do. Rotate session secrets after a suspected compromise (`b.session.invalidateAll()`).
- **DoS at the network layer** — `rateLimit` middleware caps per-IP / per-route, but a determined attacker with botnets needs upstream protection (Caddy + your provider's edge).
- **Physical / runtime memory access** — once an attacker has root on the host, the in-memory vault key is reachable. Hardened-host configs (LSM, secure-boot, FDE) are out of scope; we recommend them.
- **Information disclosure through legitimate logging** — `b.redact` ships a default redaction set, but operator-defined log fields can leak PII. Audit your custom log statements.
- **Compromised CI secrets** — the GitHub Actions release pipeline signs images via OIDC (no long-lived key), but if the workflow file itself is modified by an attacker with `contents: write` on the repo, they can publish a malicious image under the same signature. Branch protection + required reviewers (DEPLOY.md → "Branch protection") closes this.
- **Node.js Permission Model symlink-resolution bypass (CVE-2026-21715 / CVE-2026-21716)** — Node's experimental `--permission` model treats `fs.realpath` and `FileHandle` as opaque handles; an operator relying on the Permission Model for symlink-resolution security must NOT assume the model resolves symlinks. The framework's own symlink defenses live at the application layer (`b.vault` PEM-file read-side + `b.staticServe` realpath gate); operators using Node's experimental Permission Model layer it on top of the framework's gates, never instead of them.
- **Reverse-proxy CVE class** — operators terminating TLS or HTTP/2 in front of blamejs are responsible for the proxy's own CVE posture. Apache HTTP/2 deployments require Apache 2.4.67+ to close CVE-2026-23918 (HTTP/2 double-free). HAProxy deployments require 3.3.6 / 3.2.15 / 2.6.25+ to close CVE-2026-33555 (HTTP/3→1 desync). The framework's own HTTP/2 hardening + transport-layer smuggling defenses are necessary but not sufficient — the proxy's own vulnerable surface is operator-territory.
- **SAML processing** — the framework does not ship a SAML primitive today. Operators integrating SAML SSO route through a separately-firewalled SAML library and MUST enforce the `signed-assertion === parsed-assertion` invariant (compare the verified-signed bytes against the bytes the application logic reads — never trust any "extracted assertion" derived from a parser walk). The XML signature wrapping class (CVE-2026-25922 / CVE-2026-23687 / CVE-2026-34840) is universal across every SAML stack that re-parses after signature verification. The framework's `codebase-patterns` test refuses any direct `require("xml-crypto" | "samlify" | "xml2js")` import in `lib/`; SAML support, when added, will land as a documented opt-in primitive that bakes the signed-bytes-equality contract into the gate.

---

## Cryptographic stack

| Layer | Algorithm | Standard |
|---|---|---|
| KEM | ML-KEM-1024 + P-384 ECDH hybrid | FIPS 203 + NIST P-384 |
| Symmetric | XChaCha20-Poly1305 | RFC 8439 extended |
| KDF | SHAKE256 | FIPS 202 (XOF) |
| Hash | SHA3-512 | FIPS 202 |
| Password | Argon2id | RFC 9106 |
| Signatures (default) | SLH-DSA-SHAKE-256f | FIPS 205 |
| Signatures (legacy verify) | ML-DSA-87 | FIPS 204 |

Algorithm agility is the framework's posture, not just a feature: every encrypted blob carries an envelope header identifying the KEM / cipher / KDF used. New algorithms (HQC when standardized, FrodoKEM, etc.) land as new ID values without breaking existing data — `b.crypto.decrypt` continues to read old blobs while new writes use the new algorithm. See the wiki's [Crypto & Vault](https://blamejs.com/crypto-vault) page for the per-algorithm IDs and the migration path.

### FIPS 140-3 cryptographic boundary

The framework is **not** itself FIPS 140-3 validated; the cryptographic primitives are sourced from two boundaries operators select between:

- **Node.js OpenSSL boundary** — when the framework runs on a Node build linked against an OpenSSL FIPS-validated provider, the framework's hashing, HMAC, ECDH, RSA / EdDSA / ECDSA signing, and AES paths route through that provider. Operators on FedRAMP / DoD deployments configure Node to load the FIPS provider (`openssl fipsinstall` + `OPENSSL_CONF` pointing at the FIPS config) and run with `--force-fips`. The framework does not silently bypass when FIPS is active — every `b.crypto` call reaches the same Node primitives that the FIPS provider gates.
- **Vendored boundary (default)** — `lib/vendor/noble-ciphers.cjs` (XChaCha20-Poly1305, ChaCha20-Poly1305) and `lib/vendor/noble-post-quantum.cjs` (ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f) are pure-JS implementations bundled from `@noble/ciphers` + `@noble/post-quantum`. These are **not** FIPS-validated implementations; they implement the FIPS-published algorithms (ML-KEM = FIPS 203, ML-DSA = FIPS 204, SLH-DSA = FIPS 205) but the *implementations themselves* have not undergone the CMVP validation process. Operators in FIPS-mandated environments either:
  1. Wait for an OpenSSL FIPS provider to ship the post-quantum algorithms (in progress upstream — track [openssl/openssl#19838](https://github.com/openssl/openssl/issues)) and let the Node OpenSSL boundary take over those code paths
  2. Replace the vendored modules with operator-supplied bindings to a FIPS-validated PQC library and rebuild the framework's vendor manifest (`scripts/vendor-update.sh`)

Argon2id (`lib/vendor/argon2/`) is similarly a vendored pure-JS / WASM implementation — not FIPS-validated. Operators on FIPS-restricted password-hashing requirements pin to PBKDF2-SHA-512 via the Node OpenSSL provider until Argon2 lands in the FIPS provider catalog.

The framework's **classical** hashing (SHA-3 family, HMAC-SHA3-512), **classical** asymmetric (P-384 ECDH for the hybrid KEM), and **TLS** (Node's built-in TLS stack) all route through the Node-linked OpenSSL boundary by default — operators with `--force-fips` get FIPS coverage on every classical primitive without further configuration.

---

## Supply-chain transparency posture

Every tagged release writes a public-transparency-log entry through [Sigstore Rekor](https://docs.sigstore.dev/logging/overview/) as part of two independent trust chains:

1. **SLSA L3 provenance** (`<tarball>.intoto.jsonl`) — the SLSA reusable workflow signs the attestation with an OIDC-bound short-lived cert from Fulcio and records the issuance in Rekor. Downstream verifiers use `slsa-verifier` to confirm the tarball binds to the GitHub workflow + commit + tag that produced it.
2. **Sigstore-keyless SBOM signatures** (`sbom.cdx.json.sigstore`, `sbom.vendored.cdx.json.sigstore`) — cosign sign-blob via the same OIDC trust root. Verifiers use `cosign verify-blob ... --bundle <.sigstore>`.

Both flows publish the repository's identity (`blamejs/blamejs`), the workflow path, and the release commit SHA into the public Rekor instance. `github.com/blamejs/blamejs` is intentionally public — see the [repository's settings](https://github.com/blamejs/blamejs/settings) — so the transparency-log entry doesn't disclose anything an attacker couldn't already observe from the public repo, and the auditability gain is the operator-desired property.

The release workflow passes `private-repository: true` to the SLSA reusable workflow as an explicit acknowledgement that the transparency-log write is intentional. Without the override, SLSA's internal privacy detection (which inspects workflow permissions rather than repository visibility and so reports false positives for permissive-permission workflows even on public repos) would halt the attest step to avoid leaking the repository name.

**Downstream forks operating from a private mirror should flip the input.** If you fork blamejs into a private GitHub organization or a non-public namespace and want releases NOT to write Rekor entries, set `private-repository: false` in `.github/workflows/npm-publish.yml`'s `provenance` job. The SLSA workflow will then halt the attest step and the release pipeline will fail; you'll either accept the loss of SLSA provenance or migrate to a private-Sigstore deployment (the SLSA framework supports this via `sigstore-go` configuration outside the scope of this document).

The framework collects no telemetry from operators. Every primitive that touches an external service (DNS lookups via DoH, ACME enrollment, OCSP checks, NTP queries, OSV-Scanner SBOM scans during release, etc.) is documented at its call site and runs through an operator-supplied endpoint — there is no framework-owned ingest channel.

---

## Operator security checklist

This is the minimum-viable security posture for a production deployment. The framework's defaults handle most of it; this checklist is what the operator MUST do that the framework cannot.

**Vault**
- [ ] Set `BLAMEJS_VAULT_PASSPHRASE` to a strong, unique passphrase (≥ 32 chars, generated by a CSPRNG, not memorized)
- [ ] Seal the vault before first production boot: `blamejs vault seal --data-dir ./data`
- [ ] Confirm `vault: { mode: "wrapped" }` in the app's config (not `"plaintext"`)
- [ ] Store the passphrase in a secret manager (1Password / Vault / AWS Secrets Manager / sops) — never in git, never in shell history
- [ ] Rotate the vault passphrase quarterly: `blamejs vault rotate`
- [ ] In FIPS / regulated deployments, run `b.crypto.selfTest()` at start-up as a power-on integrity gate — it KATs SHA3/SHAKE against NIST FIPS 202 vectors and pairwise-tests ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f, throwing `crypto/self-test-failed` (fail closed) if the crypto stack is broken

**Audit chain**
- [ ] Run `blamejs audit verify-chain --db <path>` weekly via cron — walks the live audit chain end-to-end and reports tampering with `breakAt` / `breakRowId` / expected-vs-actual prevHash
- [ ] Rotate the audit signing key annually (or per compliance schedule)
- [ ] Archive old audit rows monthly: `blamejs audit archive --before <date> --out ./audit-archives/`
- [ ] Back up the audit-archive bundles to a separate location with a different passphrase

**Backups**
- [ ] Schedule nightly backups via the framework's `b.backup` primitive (encrypted with `BLAMEJS_BACKUP_PASSPHRASE`, separate from vault passphrase)
- [ ] Test restore quarterly: `blamejs backup verify --bundle <latest>` then a full `blamejs restore apply` round-trip into a staging environment (with `blamejs restore rollback` as the documented escape hatch). Automate periodic restore-and-verify drills via `b.backup.scheduleTest({ cron, restoreTo, verify, posture })` — required by HIPAA §164.308(a)(7)(ii)(D), PCI DSS 4.0.1 12.10.2, and DORA Article 24
- [ ] Verify every restored backup with `b.backupBundle.verifyManifestSignature(bundleDir, { expectedFingerprint })` — the manifest is signed with the audit-sign keypair (ML-DSA-87 / SLH-DSA-SHAKE-256f); a tampered `files[]` entry, vaultKeyEnc, or metadata fails verification
- [ ] Off-site at least one bundle (different region / cloud / physical location)
- [ ] Retain bundles per compliance window; the prev-hash chain across bundles makes silent deletion detectable
- [ ] Under `hipaa` / `pci-dss` postures, `b.backup.create` refuses `encrypt: false` at boot — the framework enforces backup encryption on these regulatory regimes
- [ ] Rotate the backup envelope key periodically (PCI DSS 3.6.4 / SOC 2 CC6.1) via `storage.keyRotation({ newRecipient | newPassphrase })` — it rotates every bundle's envelope AND re-reads each under the new key, so a rotation that corrupts a bundle reports `verifyFailed > 0` immediately rather than failing at restore time; the emitted `backup/key-rotated` audit event is the rotation record auditors expect
- [ ] Generate a posture-appropriate disaster-recovery runbook with `b.drRunbook.emit({ posture, services, rtoMs, rpoMs })` and commit it under `docs/dr/` — ships RTO/RPO + role-recovery + breach-disclosure deadlines + restore procedure

**Multi-tenant deployments**
- [ ] Wire `b.tenantQuota.create({ db, tenantField, defaultBytesCap, perTenantBytesCap })` and call `assert(tenantId)` before INSERT/UPDATE — refuses writes when a tenant exceeds their byte cap (SOC 2 CC6.1 + ISO 27001 A.8.1.5)
- [ ] Wire `b.tenantQuota.budget({ tenantField, perTenantQpsCap, perTenantTotalRowsRead })` for tenant-scoped query rate-limiting — replaces global `maxRowsPerQuery` for multi-tenant scenarios
- [ ] Wrap query results with `b.tenantQuota.instrumentQuery({ rows, tenantField, tenantId })` — emits `db.tenant.crossover` when a row's tenant disagrees with the operator-claimed tenant (RLS-bypass detection)

**mTLS** (only if using `b.mtlsCa` for service-to-service auth)
- [ ] Boot the CA with `--sealed-mode required` so the CA private key is vault-sealed before hitting disk
- [ ] Inspect CA state: `blamejs mtls status --data-dir ./data` — confirms the generation matches the operator's expected version (no silent drift on shared deploys)
- [ ] Rotate leaf certificates per their issued lifetime (typically annual); keep the CA generation field bumped on full-CA rotation events
- [ ] Distribute the CA cert to clients via `blamejs mtls show-cert --data-dir ./data` rather than copying files around — reduces "wrong-cert-trusted" mistakes

**Pipeline**
- [ ] Configure a GitHub repository ruleset on `main` (Settings → Rules → Rulesets → New) that blocks deletion + force-push (`non_fast_forward`) + non-linear merges (`required_linear_history`); these are the supply-chain-baseline guardrails that prevent silent history rewrites under a compromised maintainer token
- [ ] Configure a separate ruleset on `refs/tags/v*` that blocks tag deletion + tag updates, so a published release tag can never be silently re-pointed at a different SHA after `npm-publish.yml` consumed it for SLSA provenance
- [ ] **Required as of v0.8.72:** the `main` ruleset enforces PR-based deployment with required status checks for the CI workflow's `Framework smoke (*)`, `Wiki e2e (*)`, `ClusterFuzzLite *` (v0.8.73+ replaces the prior `Fuzz *` gate with coverage-guided fuzzing via jazzer.js / libFuzzer), `API surface snapshot gate`, `Vendor currency …`, `Secret scan (gitleaks)`, `Wiki primitive-section convention`, and `Lint summary` jobs (the matrix `*` expands to `ubuntu-latest` / `windows-latest` / `macos-latest`; require all three so a Windows-only or macOS-only regression can't sneak through) — closes the "compromised contributor key pushes directly to main under valid OIDC" path. Solo maintainer self-merges PRs; once a second maintainer joins, extend with required pull-request review (≥ 1 approver, dismiss-stale-on-push, require-code-owner-review via the CODEOWNERS file at `.github/CODEOWNERS`, last-push-approval, thread-resolution).
- [ ] Use ruleset `enforcement: "evaluate"` to dry-run a stricter posture (required signed commits, required reviews) against your existing solo workflow before flipping to `enforcement: "active"`; the audit-log surface in Insights → Rules → Recent rule runs shows what would have been blocked
- [ ] Enable repo-level secret scanning + push protection + Dependabot security updates (Settings → Code security) — free for public repos, blocks credentials at push time
- [ ] Set `BLAMEJS_DEPRECATIONS=throw` in CI so deprecated framework calls fail before reaching production
- [ ] Pin the GHCR image to a specific tag in `docker-compose.prod.yml` (never `:latest`)
- [ ] Verify cosign signatures before pulling on production hosts (DEPLOY.md → "Release verification")

**Application**
- [ ] Use `b.permissions` for every state-changing route (don't gate on `req.user` truthiness alone)
- [ ] For high-privilege scopes, set `requireMfa: true` (per-role on the role spec OR per-route via `perms.require(scope, { requireMfa: true, mfaWindowMs: C.TIME.minutes(15) })`) and stamp `req.user.mfaAuthenticated = true` + `req.user.mfaAt = Date.now()` after a successful TOTP / passkey step-up
- [ ] For destructive operations (data purge, key rotation, financial close), wire `b.dualControl.create({ minApprovers: 2, consumeLockMs: C.TIME.minutes(2), approverRoles: ["security-officer"], minReasonLength: 20 })` and gate the consumer on `consume(grantId).ready`
- [ ] For Postgres backends serving narrowed views or row-level-security policies, mount `b.middleware.dbRoleFor` so the request-time DB role is bound from the actor's permissions role; pair `b.db.declareRowPolicy` migrations with `b.externalDb.transaction({ sessionGucs })` for per-tenant binding
- [ ] For password-using auth: configure `b.auth.password.policy({ profile: "pci-4.0" })` (or `nist-aal2` / `hipaa-aal2`) and call `policy.check()` on every signup AND password change; pass `policy.shouldRotate(passwordSetAt)` through the login response so the UI can prompt rotation; pass the user's last-N stored hashes to `policy.reuseProhibited()` on change flows
- [ ] For session security: pass `{ req }` to `b.session.create()` and `b.session.verify()` so the IP / UA fingerprint is captured and checked; for high-value sessions (admin, finance) set `requireFingerprintMatch: true` OR `maxAnomalyScore: 0.7` with an operator-supplied `scorer(input)` function (impossible-travel detection, geo-distance, etc.)
- [ ] For inbound admin paths reachable on the public network: mount `b.middleware.networkAllowlist({ paths: ["/admin"], allowedCidrs: [...] })` as the in-process CIDR fence above the application-layer auth gate
- [ ] For outbound integrations: pin destination hosts via `b.httpClient.request({ allowedHosts: ["api.partner.com", ".internal.example.com"] })` so a compromised process can't reach arbitrary upstreams
- [ ] For test suites that mount a mock server on `127.0.0.1` to exercise `b.httpClient` / `b.wsClient` against deterministic fixtures: keep the SSRF gate ON in production code, then in tests inject an explicit `allowInternal: true` (per-call) alongside the mock-server URL. The opt-in is loud and audited, the production posture is unchanged, and operators reviewing the test grep see exactly which call sites talk to internal addresses. Cloud-metadata IPs (`169.254.169.254` etc.) stay hard-deny under any `allowInternal` value
- [ ] For file-upload routes: gate on magic bytes via `b.fileType.assertOneOf(buffer, ["image", "application/pdf"])` — never trust the client-supplied `Content-Type` alone
- [ ] For routes that emit or accept CSV (operator exports, user-supplied uploads, mail attachments, object-store deliverables): wire `b.guardCsv.gate({ profile: "strict" })` into `b.staticServe.create({ contentSafety: { ".csv": gate } })` and `b.fileUpload.create({ contentSafety: gate })` — strict profile applies the OWASP-recommended `prefix-tab` formula-injection mitigation, the dangerous-function denylist (HYPERLINK / WEBSERVICE / IMAGE / IMPORT* / RTD / DDE / CALL), bidi / homoglyph / control / null-byte / BOM detection, dialect-ambiguity refusal, and CSV-bomb size caps; pick `compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2"` instead of (or layered over) the profile when the workload is regulated
- [ ] For routes that accept YAML (config uploads, CI/CD pipelines, infra-as-code, document-import flows — ANY operator-supplied YAML the server parses): `b.guardYaml.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.12. For inbound YAML bodies that don't go through those primitives, wire `b.guardYaml.parse(body, { profile: "strict" })` before passing the parsed structure to operator handlers — strict refuses deserialization-tag RCE (defends CVE-2026-24009 Docling/PyYAML, CVE-2022-1471 SnakeYAML, CVE-2017-18342 PyYAML class), billion-laughs alias recursion (CVE-2026-27807 MarkUs class), Norway-problem implicit booleans, multi-document streams, leading-zero octals, duplicate keys, merge-key anchor-chains, bidi/null/control chars. Unlike JSON, YAML's threat surface includes language-specific deserialization triggers — `!!python/object/new:...` / `!!java.util.HashMap` / `!!ruby/object` etc. — which the source-level scan catches before any downstream parser (PyYAML / SnakeYAML / js-yaml) sees them
- [ ] For routes that accept JSON bodies (REST APIs / webhook receivers / config uploads — ANY operator-supplied JSON the server parses): `b.guardJson.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.12. For inbound JSON request bodies that don't go through those primitives, wire `b.guardJson.parse(body, { profile: "strict" })` before passing the parsed structure to operator handlers — strict refuses prototype pollution at source level (catches `__proto__` / `constructor` / `prototype` keys before any parser sees the input — defends CVE-2025-55182 React Server Functions RCE class), duplicate keys (RFC 8259 SHOULD-unique smuggling), NaN/Infinity, comments, JSON5 syntax, BOM, bidi/null/control chars, numeric precision-loss, depth + breadth + array-length + string-length caps. Pair with `topLevelKeyAllowlist: [...]` for routes with a known shape so unauthorized keys refuse before validation
- [ ] For routes that accept email (inbound webhooks from mail providers, .eml uploads, mailbox imports, message-archival flows, customer-support-ticket-by-email — ANY operator-supplied RFC 822/5322 message the server processes): `b.guardEmail.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.17. For inbound message bytes that don't go through those primitives, wire `b.guardEmail.validateMessage(bytes, { profile: "strict" })` BEFORE the parser sees the message — strict refuses SMTP smuggling (bare CR / bare LF outside CRLF pairs combined with embedded SMTP verbs `MAIL FROM`/`RCPT TO`/`DATA`/`EHLO`/`HELO`/`RSET`/`QUIT` — defends CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET System.Net.Mail class), CRLF header injection in single-line headers (defends From/Bcc/body smuggling), IDN homograph mixed-script domains in address-bearing headers (Cyrillic / Greek / Armenian / Cherokee codepoints overlapping Latin — operator opts in to legitimate non-Latin via `allowedScripts: ["latin", "cyrillic"]`), Punycode `xn--` labels, display-name spoofing (`"support@apple.com" <attacker@evil>` — display contains @-address that doesn't match envelope domain), IP-literal addresses (`user@[1.2.3.4]` — bypasses DNS/DMARC alignment), RFC 5322 comment syntax in addresses, multiple @ characters, RFC 5321 length caps (local-part 64 / domain 255 / address 320), RFC 5322 line cap (998), BOM injection, bidi/null/control chars in addresses + headers. For per-address validation outside a full message context (form-submitted email, signup, MX-host validation), wire `b.guardEmail.validateAddress(addr, { profile: "strict" })`. Pair with operator's DMARC / SPF / DKIM verifier for envelope-alignment checks — guardEmail is the source-level gate, not the authentication-result interpreter
- [ ] For an inbound `b.mail.server.mx` listener, wire the connection-level gate cascade so the anti-abuse controls actually run: `helo: b.mail.helo` (FCrDNS / HELO-shape / self-name spoofing → 550), `rbl: b.mail.rbl.create({ providers: [...] })` (connecting-IP DNS blocklist → 554, evaluated once per connection), and `greylist: b.mail.greylist.create({ store })` (first-seen (ip, sender, recipient) → 450 tempfail). A gate you don't wire is skipped, not synthesized — so an unwired RBL is no protection, not a default-allow surprise. SPF/DKIM/DMARC alignment is performed on the delivered message via the agent handoff until the inbound-authentication pipeline lands
- [ ] For routes that accept markdown (rich-text editors, comment systems, README rendering, documentation submission, GitHub-style wikis, mail-rendered markdown, document-import flows — ANY operator-supplied markdown the server renders): `b.guardMarkdown.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.16. For inbound markdown bodies that don't go through those primitives, wire `b.guardMarkdown.validate(body, { profile: "strict" })` BEFORE passing the source to any markdown renderer (marked / markdown-it / commonmark / remark / parsedown — all of them) — strict refuses dangerous URL schemes in inline links + images + autolinks + reference-link definitions (defends CVE-2025-9540 Markup Markdown class, CVE-2025-24981 MDC class, NuGetGallery GHSA-gwjh-c548-f787, Joplin GHSA-hff8-hjwv-j9q7), whitespace-tolerant dangerous-tag matching (`<script\n>` / `<script\t>` — defends CVE-2026-30838 CommonMark DisallowedRawHtml bypass class), HTML-entity scheme bypass (`&#x6A;avascript:` / `&#106;avascript:` decoded BEFORE scheme matching), reference-link smuggling (`[label]: javascript:...`), front-matter YAML/TOML blocks, HTML comments, code-fence language injection (language tag containing `<>"' `` blocks attribute breakout), catastrophic emphasis runs (CVE-2025-6493 CodeMirror Markdown class, CVE-2025-7969 markdown-it class), inline DOCTYPE, bidi/null/control chars, total-bytes + line + link + image + autolink + ref-def + list-depth + blockquote-depth caps. **Layer with `b.guardHtml`**: source-level guardMarkdown then render then output-level guardHtml together close the residual bypass surface that either alone misses (markdown engines surprise; sanitizers also surprise — defense in depth)
- [ ] For routes that accept XML (SOAP endpoints, sitemap submissions, RSS / Atom feeds, OAI-PMH harvesters, SAML / WS-Federation receivers, document-import flows — ANY operator-supplied XML the server parses): `b.guardXml.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.15. For inbound XML bodies that don't go through those primitives, wire `b.guardXml.validate(body, { profile: "strict" })` before passing the document to any XML parser — strict refuses DOCTYPE declarations unconditionally (XXE + billion-laughs vector — defends CVE-2026-24400 AssertJ class, CVE-2024-8176 libexpat recursive-entity stack-overflow class), `<!ENTITY>` declarations including parameter entities (out-of-band exfiltration vector), external entity references (SYSTEM / PUBLIC with file:// / http:// / ftp:// schemes — local file read + SSRF), `<xi:include>` remote inclusion (CVE-2024-25062 libxml2 use-after-free class), `xsi:schemaLocation` operator-controlled schema fetch, processing instructions (`<?xml-stylesheet ?>` CSS-injection vector), CDATA sections (often used to hide payloads from naive scanners), XML signature wrapping (xmldsig surface), bidi/null/control chars in element text + attribute values, and applies depth + element + per-attribute-value caps. DOCTYPE remains refused at every profile level (strict / balanced / permissive) because billion-laughs is universal. Operators integrating with legacy SOAP that requires DTDs must instead route through a separately-firewalled XML processor with explicit allowlist — the gate has no knob to relax DOCTYPE
- [ ] **For ZIP-shaped uploads specifically**, reach for `b.safeArchive.extract({ source, destination, guardProfile: "strict" })` — the one-liner composes `b.archive.read.zip`'s random-access reader (LFH/CD skew defense + CD-walk validation), `b.guardArchive.zipBombPolicy` defaults (per-entry + per-archive + ratio caps), `b.guardArchive.entryTypePolicy` defaults (symlink / hardlink / device / fifo / socket entries refused), and `b.guardFilename.verifyExtractionPath`'s dual-check (string-normalize + `fs.realpath`-agreement; refuses pre-resolve names exceeding PATH_MAX=4096 to defend the CVE-2025-4517 TOCTOU class; and refuses per-segment Windows write-target hazards — reserved device names like `CON`/`NUL`, NTFS alternate-data-stream `name:stream` syntax, and trailing-dot/whitespace that Windows strips into a sibling overwrite — platform-unconditionally, since the extracting host may differ from the verifying host). The fs-coupled realpath check is the depth above `b.guardArchive.checkExtractionPath`'s portable string-only gate; operators with their own extract loop call both. Default refuses ZIP encrypted entries (v0.12.10/11 add the encryption read paths). For tar / gzip / 7z / rar / zstd, the read-side primitives land in v0.12.8 / v0.12.9; until then use the legacy guard-only path below
- [ ] For routes that accept archives (zip / tar / gzip / 7z / rar / zstd / etc. — ANY upload that downstream code will extract): use the operator's archive library to enumerate entries, then validate via `b.guardArchive.validateEntries(entries, { profile: "strict" })` BEFORE extracting any file. Strict profile defends against zip-slip path traversal (CVE-2025-3445 / 32779 / 62156 / 66945 / 45582 / 11002 class), symlink + hardlink escape (CVE-2026-26960 class), per-entry + aggregate compression-ratio bombs (zip-bomb defense), total-size + entry-count caps, nested-archive recursion DoS, duplicate entry names (silent-overwrite vector), case-insensitive collisions on Windows / macOS, and per-entry filename safety (composes `b.guardFilename` for path traversal / null-byte / Windows reserved names / NTFS ADS / RTLO bidi / overlong UTF-8 / shell-exec / double-extension detection). Additionally call `b.guardArchive.checkExtractionPath(entryName, extractionRoot)` per entry at extract time AND `path.resolve(extractionRoot, entryName).startsWith(path.resolve(extractionRoot))` after path-resolve to catch any traversal that survived metadata validation
- [ ] For ANY file-upload route — wire `b.guardFilename.gate({ profile: "strict" })` to validate the filename string before it touches the filesystem. Strict profile rejects path traversal (raw + percent-encoded + UTF-8 overlong), null-byte truncation (defends extension-allowlist bypass), Windows reserved device names (CON / PRN / AUX / ... — even with extensions), NTFS alternate data streams, leading/trailing whitespace + trailing dots (Windows silently strips), Unicode bidi / RTLO file-name spoofing (CVE-2021-42574 in filename context — `Photo01By‮gpj.SCR` displays as `RCS.jpg` while OS opens `.SCR`), reserved characters, UNC paths, shell-shortcut + executable extensions (.exe / .bat / .vbs / .lnk / .scr / .dll / .so / etc.), and double-extension bypass (`invoice.pdf.exe`). Operators with non-ASCII filename requirements use `profile: "balanced"`. Operators with multi-component path-shape needs use `profile: "permissive"` and explicitly opt in to `pathSeparatorsPolicy: "allow"`
- [ ] For routes that accept SVG (avatar uploads, illustration / icon assets, mail attachments, file-upload widgets that allow image/svg+xml): wire `b.guardSvg.gate({ profile: "strict" })` — strict profile rejects every dangerous tag (script / foreignObject / animation family), denies cross-origin `<use>` references (defends server-side rasterization SSRF), refuses every DOCTYPE (defends billion-laughs entity expansion + XXE per CVE-2026-29074 class), refuses SVGZ payloads (operator must ungzip first), and enforces the SMIL animation attributeName allowlist (defends the recent CVE class where `<animate attributeName="href" to="javascript:..."/>` retroactively hijacks an element's href). For uploaded SVGs that need to be rendered, additionally serve under a strict CSP and consider rasterizing server-side to PNG before display
- [ ] For routes that emit or accept HTML (rich-text editors, comment systems, mail-rendered HTML bodies, server-rendered fragments composed from user data): wire `b.guardHtml.gate({ profile: "strict" })` into the relevant content-safety opt — strict profile applies the dangerous-tag denylist, the entire `on*` event-handler family, form-override attributes (CWE-1021), `srcdoc` + `is`, the URL-scheme allowlist (entity-decode pre-pass catches `&#x6A;avascript:` and decimal-entity bypasses), CSS injection in `style="..."`, DOM-clobbering id/name detection on form/input/button/a/img/iframe, mXSS hint flagging, IE conditional comments, and bidi / control / null / zero-width detection. For display surfaces that need to render the sanitized output, additionally serve under a strict Content-Security-Policy (`default-src 'none'` plus tight allowlist for what you actually need; or render inside a sandboxed iframe) — non-DOM HTML sanitizers cannot claim immunity from mXSS bypasses, so CSP is the second layer of defense
- [ ] **Default-on (v0.7.18+) — transport-layer smuggling defenses:** `b.middleware.bodyParser` rejects requests carrying both `Content-Length` and `Transfer-Encoding` (CL.TE / TE.CL smuggling — CVE-2022-31394 / CVE-2024-27316 class), multiple Content-Length values, Transfer-Encoding whose final coding is not `chunked`, and duplicate `chunked` tokens (TE.TE smuggling) — each rejected with HTTP 400 + `Connection: close`. `b.staticServe` resolves every requested path through `fs.realpathSync` (defeats symlink escape) AND validates the basename through `b.guardFilename` at the balanced profile (rejects path traversal / null-byte / NTFS alternate data streams / UNC / RTLO bidi / overlong UTF-8 / Windows reserved device names). `b.mail` SMTP transport runs `b.guardEmail.validateMessage` at strict profile on the produced RFC 822 wire BEFORE opening the socket — refuses outbound SMTP smuggling (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET class) even when operator-supplied subject / body / headers contain bare CR / bare LF + smuggled SMTP verbs. `b.mail.dkim.create` throws on `opts.bodyLength` (M³AAWG / Gmail / Microsoft 365 guidance — `l=` enables append-after-signature attacks). All four protections require zero operator wiring
- [ ] **Default-on (v0.13.46+) — createApp security middleware:** `b.createApp` wires the threat-aware cookie parser, CSP nonce, fetch-metadata resource-isolation guard, body parser, and CSRF protection (double-submit cookie + `Origin`/`Referer` cross-check) ON by default, ordered so CSRF validates after the body parser. State-changing requests carrying a session cookie are CSRF-validated with no operator wiring; `Authorization`-header (token-authenticated) and cookieless requests are auto-skipped as not-CSRF-able so API clients are not rejected. Configure cookie / field names through `middleware: { csrf: { cookie: { name }, fieldName } }` (operator names flow straight through — nothing is hardcoded) or opt out with `middleware: { csrf: false }` / `{ cspNonce: false }` etc.; disabling any of these security defaults emits an `app.middleware.disabled` audit row so a weakened posture is reconstructable in review
- [ ] **Default-on (v0.7.12+):** `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` automatically; `b.fileUpload` additionally wires `b.guardFilename.gate({ profile: "strict" })` as `filenameSafety`. No explicit operator action required for the baseline defense-in-depth. To opt up to a broader content vocabulary (e.g. you serve operator-built HTML with links + images), pass `contentSafety: b.guardAll.byExtension({ profile: "balanced" })` explicitly. To opt out entirely (test fixtures, raw-bytes uploads), pass `contentSafety: null` / `filenameSafety: null` with `contentSafetyDisabledReason` / `filenameSafetyDisabledReason` strings — both fire audit rows at create() time so a security review can reconstruct which deploys disabled the default-on protection. To skip a single guard while keeping the rest, pass `contentSafety: b.guardAll.byExtension({ exceptFor: { name: { reason: "..." } } })` — the reason lands in the `guardAll.gate.created` audit row. Future guards added to the family auto-extend the deploy without re-wiring
- [ ] For OAuth / OIDC RP callbacks: call `b.auth.oauth.parseCallback(query, opts?)` BEFORE consuming `code` — validates RFC 9207 AS Issuer Identifier (refuses iss-mismatch + OP `error=` redirects + state-mismatch CSRF). For FAPI 2.0 deployments add `b.fapi2.assertCallback(query)` (refuses missing iss; refuses bare-param under `fapi-2.0-message-signing` posture, requiring JARM `response`) and `b.fapi2.assertAuthzRequest(authzParams)` BEFORE issuing the redirect (refuses non-JAR authorization requests). For refresh-token flows, pass `seen({ jti, iss })` to `b.auth.oauth.refreshAccessToken` so reuse of an already-rotated token refuses BEFORE the HTTP exchange (RFC 9700 §4.13 / OAuth 2.1 §6.1)
- [ ] For OAuth authorization servers accepting RFC 9101 request objects (JAR): verify them with `b.auth.jar.parse(jar, { clientId, audience, algorithms, jwks })` BEFORE honoring the authorization request — it delegates the signature check to the alg-allowlisted `verifyExternal` (refuses `alg: "none"` / HMAC-vs-RSA confusion / JWE-on-JWS), pins `iss` + the `client_id` claim to the expected client and `aud` to your issuer, and refuses a nested `request` / `request_uri` (RFC 9101 §6.3); never accept an unsigned `request` parameter or trust query-string authorization parameters when a signed request object is available
- [ ] For Model Context Protocol servers exposing tools to LLM agents: wire `b.mcp.toolResult.sanitize(result, { posture: "refuse" })` over EVERY tool output before returning it to the model — defends OWASP LLM07 (sensitive tool output / prompt-injection echo back into the agent loop), refuses dangerous-HTML + off-allowlist URLs. Wrap each tool's input handler with `b.mcp.validateToolInput(toolName, input, schema)` (JSON Schema 2020-12 subset — `type` / `properties` / `required` / `enum` / `const` / length + range caps) so an LLM-supplied argument shape that doesn't match refuses BEFORE the tool runs. Define each tool's required scopes via `b.mcp.capability.create(scopes)` and gate execution on `cap.satisfiedBy(grantedScopes)` (LLM08 least-privilege)
- [ ] For AI inference endpoints (especially pay-per-use provider models): wire `b.ai.quota.create({ dimension, period, limit, enforcement: "hard" })` and call `quota.consume(tenant, model, amount)` BEFORE every inference — caps tokens / requests / cost-usd / compute-hours per tenant per window so one tenant cannot run up an unbounded bill (OWASP LLM10:2025 unbounded consumption / denial-of-wallet). For multi-node deployments supply an `opts.store` whose `reserve` (atomic conditional test-and-charge) + `add` are atomic on the shared backend (Redis Lua / a SQL `UPDATE ... WHERE used + :amt <= :limit`) so the ceiling is enforced on the cluster-wide aggregate, not per-process
- [ ] For endpoints returning aggregate statistics over personal data (counts / sums / means / histograms): add calibrated noise with `b.ai.dp` and gate spend with `b.ai.dp.budget({ scope, epsilon, delta })` — use `type: "laplace"` (snapping mechanism) or `type: "gaussian"` (discrete Gaussian), NEVER a hand-rolled `Math.random`-based noise generator: a naive double-precision Laplace sampler lets an attacker distinguish neighbouring datasets from a single output (Mironov 2012), silently breaking the guarantee. Track the per-scope ε/δ budget so repeated queries can't be averaged to cancel the noise
- [ ] For data with a TTL (GDPR Art. 17, PCI 3.1, retention windows): declare retention rules via `b.retention.create({ db, audit }).declare({ name, table, ageField, ttlMs, action: "erase" })` and run on a `b.scheduler` cadence; honour legal-hold via `legalHoldField`
- [ ] For write-once-read-many object archives (SEC 17a-4, FINRA, HIPAA-shaped retention): create the bucket with `b.objectStore.bucketOps.create(name, { objectLockEnabled: true })` (Object Lock can ONLY be flipped at create time), apply a default retention via `setObjectLockConfiguration(name, { mode: "COMPLIANCE", years })`, and pin individual objects with `setObjectRetention(name, key, { mode, retainUntil })` or `setObjectLegalHold(name, key, "ON")` — `COMPLIANCE` cannot be shortened or bypassed by anyone (including root); pick deliberately
- [ ] At boot, before any outbound socket opens: call `b.network.bootFromEnv({ env: process.env, audit: b.audit })` so operator-supplied NTP / DNS / proxy / DPI-trust / TCP socket settings (`BLAMEJS_NTP_*`, `BLAMEJS_DNS_*`, `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, `BLAMEJS_EXTRA_CA_CERTS`, `BLAMEJS_SOCKET_*`) apply uniformly
- [ ] If the deployment sits behind a deep-packet-inspection proxy with its own re-signing CA: install the CA via `b.network.tls.addCa("/path/to/corp-ca.pem", { label: "corp-mitm" })` and pass `allowDpiTrust: true` to `b.security.assertProduction` — every CA addition audits with subject + fingerprint so a forensic review can reconstruct the trust path
- [ ] For authenticated time (HIPAA / PCI / FIPS shops): use `b.network.ntp.nts.query({ host: ntsKeServer })` (RFC 8915) instead of plain SNTP; set `BLAMEJS_NTS_REQUIRE=1` to fail closed on negotiation failure
- [ ] When a DNS answer drives a trust decision (DANE / TLSA pinning, SSHFP, CAA enforcement, OPENPGPKEY lookup) and the upstream resolver isn't itself trusted: verify the answer's DNSSEC signature with `b.network.dns.dnssec.verifyRrset(...)` rather than trusting the resolver's AD bit — an on-path or compromised resolver can set AD on a forged answer, but cannot forge the RRSIG. Validate the whole delegation chain root→TLD→zone with `b.network.dns.dnssec.verifyChain(...)` (default-pinned to the IANA root KSKs, or `trustAnchors` for a private root) so trust is anchored, not borrowed from the resolver. `verifyChain` bounds KeyTrap (CVE-2023-50387) amplification with non-configurable caps (≤4 same-tag candidate keys per RRSIG, ≤64 DNSKEYs/zone, ≤16 DS/delegation, ≤128 chain links, and a signature-validation budget that scales with chain depth so deep delegations validate while bounded collisions stay bounded) and caps NSEC3 iterations at 150 (CVE-2023-50868) — a hostile zone is refused, not allowed to exhaust CPU. For a negative answer that drives a fail-closed decision (an allowlist lookup, a revocation check), verify the NSEC / NSEC3 proof with `b.network.dns.dnssec.verifyDenial(...)` so a forged NXDOMAIN cannot suppress a record; keep the default Opt-Out refusal unless the zone's opt-out spans are acceptable for that decision. For DANE / TLSA, once the TLSA RRset is DNSSEC-verified, pin the peer certificate with `b.network.dns.dane.matchCertificate(...)` — a DANE-EE(3) match authenticates the key with no public CA, while PKIX usages are flagged as still needing PKIX
- [ ] At boot in production: call `await b.security.assertProduction({ vault: "wrapped", dbAtRest: "encrypted", auditSigning: "wrapped", ntpStrict: true, requireEnv: ["BLAMEJS_VAULT_PASSPHRASE"], dataDir: "./data" })` to refuse to start on weak posture instead of warning
- [ ] At boot: call `await b.configDrift.create({ dataDir, audit }).checkpoint({ allowedOrigins, csp, vaultMode, ... })` so the next boot detects + audits any silent runtime config change
- [ ] At boot, before any listener opens: call `b.configDrift.verifyVendorIntegrity({ manifestPath: "./lib/vendor/MANIFEST.json", audit: b.audit })` so a tampered `lib/vendor/*.cjs` artifact aborts start instead of running with a swapped crypto bundle
- [ ] For every CSP-emitting deployment: mount `b.middleware.cspReport({ path: "/_csp" })` and route the CSP `report-uri` / `report-to` directive at it so violation reports land in the audit chain (namespace `csp.violation`) — surfaces XSS attempts and operator-misconfigured directives within minutes
- [ ] Issue at least one `b.honeytoken` canary per high-value surface (admin API key shape, unused admin URL, fake DB row ID) and wire alerting on `honeytoken.tripped` audit rows to your on-call channel — any positive lookup is by definition unauthorized
- [ ] For incident-response runbooks: pre-wire `b.atoKillSwitch.trigger({ userId, reason, actor })` into your SOC tooling so a confirmed compromise is a single call rather than a multi-step manual cleanup
- [ ] For DSR-receiving operators: stand up `b.dsr.create({ ticketStore, posture, identityResolver })` and wire `query` / `erase` callbacks for every personal-data source — the framework owns deadline computation, audit emission, and ticket state; the operator owns the storage backend and the per-source data path
- [ ] For DORA-scoped financial entities: wire `b.dora.create({ audit })` at the incident-classification step; the framework's three-stage report-shape (initial / intermediate / final) maps to Commission Delegated Regulation 2024/1772
- [ ] For SEC-registered issuers: feed material cybersecurity incidents through `b.secCyber.eightKArtifact({ ... })` to produce the Form 8-K Item 1.05 disclosure within the 4-business-day window
- [ ] For SMS-marketing operators: capture and audit consent via `b.tcpa10dlc.recordConsent({ phone, campaignId, consentText, signature })` BEFORE the first message — TCPA penalties are $500-$1,500 per violation
- [ ] For high-sensitivity columns (PHI / PCI / regulated PII): seal via `b.vault.aad.seal(plaintext, { table, rowId, column, schemaVersion })` + `unseal(value, ...)` so the AEAD tag binds to the column's identity tuple — copy-paste between rows, schema-version replay, and table-mismatch attacks all surface as a refused decrypt rather than silent disclosure. Use `b.vault.aad.reseal(value, fromAad, toAad)` to re-bind on schema migrations after authenticating the source
- [ ] For inbound mail receivers (mailbox imports, support-ticket-by-email, webhook receivers from mail providers, mailing-list ingestors): verify the message's authentication-results before acting on it via `b.mail.spf.verify` + `b.mail.dmarc.evaluate` + `b.mail.arc.verify` (chain hops up to 50, validates each AMS / AS hop signature + the chain-validation `cv=` rules) — the existing DKIM verifier covers the first-hop signature; SPF + DMARC + ARC together close the spoofed-relay surface. For relays that re-sign outbound, call `b.mail.arc.sign` with `cv="none"` on the first hop and `cv="pass"`/`cv="fail"` on subsequent hops so the chain is well-formed for the next relay
- [ ] For systems where the same message must trigger exactly one downstream action (payment-processed → email-sent + ledger-row, order-received → fulfilment-job + audit-row, retry-driven webhook receivers): wrap the receive path in `b.inbox.create({ externalDb, table, retentionDays, audit }).handle({ messageId, source }, async (xdb) => { /* business state change inside same xdb txn */ })` so the dedupe row and the state change commit atomically — duplicate delivery short-circuits via the (source, message_id) PRIMARY KEY constraint instead of double-processing. Pair with `b.outbox` on the publishing side for end-to-end exactly-once across services
- [ ] For Server-Sent Events streams: use `b.sse.create(req, res, opts)` (or `b.middleware.sse(handler)` which composes the same validator). Both refuse newline / CR / NUL in `event:` / `id:` / `Last-Event-ID` fields rather than silently stripping (CVE-2026-33128 h3, CVE-2026-29085 Hono, CVE-2026-44217 sse-channel — silent stripping was the bug). Operators emitting SSE manually should call `b.sse.serializeEvent({...})` so the same validator runs
- [ ] For Model Context Protocol endpoints (AI assistant integrations): wire `b.mcp.serverGuard({ verifyBearer, redirectUriAllowlist, toolAllowlist })` in front of the operator's MCP handler. Defaults: bearer auth required (CVE-2026-33032 nginx-ui auth-bypass class), `redirect_uri` exact-match allowlist enforced per OAuth 2.1 / RFC 9700 §4.1.1 (CVE-2025-6514 mcp-remote OAuth RCE class), dynamic client-registration refused unless `allowDynamicRegister: true` with `registerClientAllowlist` (confused-deputy class)
- [ ] For Apollo Federation subgraphs: gate `_service.sdl` / `_entities` probes via `b.graphqlFederation.guardSdl({ routerToken, nonceStore })`. Disabling introspection in production does NOT hide `_service.sdl` — schema leak is independent of the introspection toggle. Pass `nonceStore` for single-use replay defense across router fetches
- [ ] For LLM-bound user input: classify via `b.ai.input.classify(text)` before flowing into a prompt. Refuses (`refuseIfMalicious`) on instruction-override / persona-jailbreak / role-reset / OpenAI system-tag templates / tool-call injection / exfil-callback / encoded-bypass / markdown+HTML smuggling / BIDI/zero-width/control-char density patterns (OWASP LLM01:2025 + NIST COSAIS RFI). Defense in depth: pair with output filtering, system-prompt isolation, tool-output validation
- [ ] For agent-to-agent collaboration (Linux Foundation Agentic AI Foundation A2A protocol): `b.a2a.signCard(card, privateKey)` produces ML-DSA-87 signed cards; `b.a2a.verifyCard(envelope, publicKey, { expectedIssuer })` enforces signature + expiry + issuer match before accepting a peer agent's capabilities. HTTPS-only endpoints (or localhost) at validation time
- [ ] For AI-generated or AI-modified media (image, audio, video, document) the operator publishes: emit C2PA Content Credentials via `b.contentCredentials.sign({ asset, claims, signer })` and surface the AI Preferences signal to scrapers via `b.aiPref.middleware()` (publishes `/.well-known/ai.txt` + the `Content-Usage` response header). NIST AI 100-4 (the "Reducing Risks Posed by Synthetic Content" report, finalized April 2026) is the canonical US guidance on watermarking and provenance-disclosure for synthetic content — it identifies durable-watermark + content-credential pairing (C2PA / IPTC) as the recommended baseline. The framework's `b.contentCredentials` (provenance signing) and `b.aiPref` (AI-training opt-out + per-asset usage signal) primitives implement that pairing; operators on California AB-3030 / SB-942, EU AI Act Article 50 transparency, or US Executive-Order watermarking obligations route every AI-touched outbound asset through both before publication. Detection-side watermarking (operator-applied perceptual hashing for downstream verification) stays operator-territory until a vendor-neutral standard ships
- [ ] For subscription / consent / DSR cancel-flow endpoints: enforce FTC Negative Option Rule click-to-cancel parity via `b.darkPatterns.attest({ signup, cancel, posture })` (postures: `ftc-2024` / `ca-sb942` / `strict`) and gate the cancel route with `b.darkPatterns.middleware({ lookupAttestation, resourceIdFromReq })` — refuses cancel-endpoint requests with HTTP 451 if the operator hasn't recorded a parity attestation
- [ ] For Postgres external-db backends — call `await b.externalDb.assertRoleHardening({ backend, declaredRoles, mode: "throw" })` at boot so a forgotten ALTER ROLE / leftover migration role / privileged role added outside change-management refuses startup instead of silently shipping. `mode: "audit"` downgrades to an audit-only `db.role.hardening.unrecognized` row when a hard fail isn't appropriate (multi-tenant superuser pools)
- [ ] For Postgres external-db backends — `b.externalDb.init({ backends: { name: { applicationName: "<deploy>-<role>" } } })` is set on every fresh connection by default (defaults to `"blamejs"` when omitted) so `pg_stat_activity` / `log_line_prefix` / DB-side audit logs surface a stable identifier. CR / LF / NUL / oversized values (>63 bytes) are refused at config-time
- [ ] For routes that build JSONB queries from operator input — `b.db.from(table).where(field, "@>", value)` and the `?` / `?|` / `?&` JSONB key operators route the value through `b.safeJsonPath.validateContainment` / `validateKey` automatically; refuses NUL / control / bidi / zero-width characters in any string leaf or key. Operators building a literal JSONpath expression for `@?` / `@@` call `b.safeJsonPath.validateExpression(expr)` before binding — refuses filter predicates `?(...)`, recursive descent `$..`, script-shape `(@.x.y)`, JS-source hints, and bracket depth bombs
- [ ] For idempotency-key middleware on multi-process fleets — use `b.middleware.idempotencyKey.dbStore({ db: b.db })` instead of `memoryStore`. As of v0.9.15 the dbStore defaults to `hashKeys: true` (operator-supplied keys are sha3-512 namespace-hashed before insert/lookup so the DB never sees raw keys that might carry PII — order numbers / emails / vendor prefixes) and `seal: true` (cached response `headers` + `body` are sealed via `b.cryptoField.sealRow` AEAD envelope when vault is initialized so a DB dump leaks neither). Forensic columns (`status_code` / `fingerprint` / `expires_at`) stay plaintext-queryable without unsealing. Opt-out via `{ hashKeys: false, seal: false }` only with a documented justification
- [ ] For long-running daemons exposing live metrics — use `b.metrics.snapshot.startWriter({ path, intervalMs, fields })` to flush an atomic JSON snapshot to disk; let a CLI/sidecar consume it via `b.metrics.snapshot.read(path)` + `b.metrics.snapshot.render(snap, { format: "prometheus" | "text" })`. Avoids opening an HTTP port for scrape access. Snapshot read uses `b.safeJson.parse` with a 4 MiB ceiling so a hostile writer with disk-write access can't OOM the reader
- [ ] For decoding CBOR from untrusted sources (COSE / CWT / WebAuthn attestation objects, IoT payloads): use `b.cbor.decode(buffer, opts)` — bounded by default (depth + total-size caps, indefinite-length + reserved-info refusal), never a raw streaming parser. Allowlist only the tags you process via `allowedTags` (an un-vetted tag triggers semantic reprocessing — a confused-deputy vector), and on a signature-verify path pass `requireDeterministic: true` so a non-canonical re-encoding can't slip a malleable representation past the verifier
- [ ] For verifying COSE_Sign1 (RFC 9052) signed statements (SCITT receipts, CWT, C2PA manifests): use `b.cose.verify(coseSign1, { algorithms, publicKey })` — `algorithms` is a required allowlist (no defaults, the alg-confusion defense), the COSE bytes + protected header decode through the bounded `b.cbor`, and a `crit` header naming a label the verifier doesn't understand is refused (§3.1 crit-bypass). Bind request context with `externalAad`. ML-DSA-87's COSE id is a non-final draft — prefer the classical ES256 / EdDSA ids for interoperable signing today
- [ ] For COSE_Encrypt0 (RFC 9052 §5.2 single-recipient AEAD): use `b.cose.encrypt0` / `b.cose.decrypt0` — the default is ChaCha20/Poly1305 (AES-GCM is opt-in), the IV is generated fresh per call (never reuse an IV with the same key), and `decrypt0` requires an `algorithms` allowlist + reconstructs the Enc_structure as AAD so a wrong key / tampered ciphertext / AAD mismatch fails authentication. Pass `externalAad` to bind the ciphertext to its context
- [ ] For CBOR Web Tokens (RFC 8392 — constrained / IoT / FIDO auth): verify with `b.cwt.verify(cwt, { algorithms, publicKey, expectedIssuer, expectedAudience })` — it enforces the COSE alg allowlist (via `b.cose.verify`), refuses an expired `exp` or future `nbf` (with a bounded `clockSkewSec`, default 60s), and refuses an `iss` / `aud` mismatch. Always pass `expectedIssuer` + `expectedAudience` for tokens you consume so a token minted for a different service or issuer can't be replayed against yours
- [ ] For device / software attestation (RFC 9711 EAT): send the attester a fresh random nonce and verify the returned token with `b.eat.verify(eat, { algorithms, publicKey, expectedNonce, requireDebugDisabled: true })` — the `expectedNonce` binding is the freshness / anti-replay control (without it a captured attestation replays forever), and `requireDebugDisabled` refuses a device whose `dbgstat` is enabled or absent. Pin `expectedProfile` to the EAT profile you understand so a token built to a different profile's semantics isn't misinterpreted
- [ ] For install-pipeline contexts that run BEFORE the framework is installed (Dockerfile build stages, `install.sh`, `update.sh`, SEA bundle verification) — use `b.selfUpdate.standaloneVerifier` (since v0.9.13). It's a zero-dep verifier (only `node:crypto` + `node:fs`) for ECDSA P-384 / Ed25519 / ML-DSA-87 signatures. Operators physically copy the file via `cp "$(node -p "require('@blamejs/core').selfUpdate.standaloneVerifier.path")" install/standalone-verifier.js` into their install pipeline alongside an operator-owned pubkey
- [ ] For daemons that rotate TLS posture without restarting (pinset reload / certificate refresh / `C.TLS_GROUP_PREFERENCE` updates) — call `b.pqcAgent.reload()` after the posture change so the next `b.pqcAgent.agent` access rebuilds against current TLS state. Existing in-flight sockets complete naturally; idle keep-alive sockets are torn down
- [ ] For SBOM regeneration / vendor-data integrity sweeps / release-asset bundling — use `b.crypto.hashFilesParallel(filePaths, { algorithms, concurrency, onProgress })` to hash N files in parallel in a single-pass per file. Operator-tunable concurrency cap (default `min(8, paths.length)`, range 1..256) + tunable algorithms list (default `["sha256", "sha3-512"]` for PQC-first + legacy compat). Returns rows in input order
- [ ] Audit all `{{{ raw }}}` template outputs — these bypass HTML escape
- [ ] Run `blamejs api-snapshot compare --file ./api-snapshot.json` in CI to catch removed methods or changed signatures before they ship
- [ ] Subscribe to the `blamejs-security-announce` mailing list for advisories

---

## Database audit hardening

The framework's audit + consent chains are append-only at the application layer (insert-only emit + chain-integrity boot-time verification + tip-rollback detection). Operators running framework state against a cluster Postgres / SQLite (`b.cluster.externalDbBackend`) layer the following DB-side controls so a privileged compromise of the framework role can't silently rewrite the chain.

**Forensic isolation**

- [ ] `REVOKE INSERT, UPDATE, DELETE ON _blamejs_audit_log, _blamejs_consent_log, _blamejs_audit_checkpoints FROM PUBLIC` and from every role except the framework's own. Even framework roles get only INSERT — UPDATE / DELETE are permanently refused (the framework never issues either against append-only tables); the `BEFORE-DELETE` / `BEFORE-UPDATE` triggers installed by `b.frameworkSchema.ensureSchema` raise on attempt regardless, but a REVOKE fails closed before the trigger fires
- [ ] In single-node mode the same WORM invariant lives in the local SQLite via `_installAppendOnlyTriggers` — operators using `db.runSql` / `db.exec` can verify the triggers exist via `SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'no_%'`
- [ ] `REVOKE SELECT ON pg_stat_statements FROM <app_role>` so operator-issued SQL with sensitive literals (audit reasons, sealed values pre-encryption) doesn't surface in the cluster's shared statement-text cache. Pair with `pg_stat_statements.track = none` for the framework role on multi-tenant clusters
- [ ] On AWS RDS / Aurora / GCP Cloud SQL: extend `assertRoleHardening({ ignoreSystem: true })` with the cloud-specific service roles (`rdsadmin`, `cloudsqlsuperuser`, `azure_pg_admin`) — the default ignoreSystem list covers them but custom roles added by the cloud provider's vendor extensions need explicit declaration

**Residency & replication**

- [ ] WAL archive / streaming replicas MUST stay within the declared `b.db.getDataResidency().region`. Cross-region WAL ship (Aurora Global, RDS cross-region read replica, pg_basebackup over public internet) silently moves audit + consent rows out of the residency boundary even when the framework's `b.externalDb.residencyTag` enforcement is correct. Confirm replication topology + log-shipping path before flipping `personal` classification onto an externalDb backend
- [ ] For point-in-time recovery: bound the PITR window with the framework's retention floor (`b.retention.complianceFloor`) — restoring a snapshot older than the consent-erasure timestamp re-materializes deleted personal data and creates a new GDPR Art. 17 violation. Pair PITR replay with a `b.retention` re-run before re-opening the restored DB to traffic
- [ ] Backup encryption key MUST differ from the framework `BLAMEJS_VAULT_PASSPHRASE` — a single-key compromise that exfiltrates the running vault should not also unlock every backup bundle
- [ ] WAL archive bucket / replication slot credentials live in a separate secret-manager scope from the framework role's connection string; rotate independently

**Statement audit**

- [ ] Enable `log_min_duration_statement` at the cluster level (typically `1000ms`) so slow queries land in the operator-managed log stream alongside the framework's `db.query.slow` observability events. The framework emits the `1s` / `5s` / `30s` buckets; the cluster log captures the SQL text the framework intentionally redacts from audit metadata
- [ ] For roles that issue DDL (migration runner, framework boot): set `log_statement = ddl` so a forensic review can correlate the framework's `db.ddl.executed` audit row with the cluster log's verbatim DDL text — closes the trust gap between "framework says it ran X" and "cluster log shows X actually ran"

## Watch list

CVE classes the framework tracks but does not currently ship a primitive for — operator awareness items:

- **HTTP/2 WINDOW_UPDATE rate-flood variants** — CVE-2026-21714 closes the leak-after-GOAWAY shape; the broader rate-flood class (peer bursting WINDOW_UPDATE to spin nghttp2 flow-control accounting) remains an active research area. The framework's H/2 server caps `maxConcurrentStreams` / `maxSessionMemory` / `maxHeaderListPairs` plus a per-stream WINDOW_UPDATE rate cap; operators on Internet-facing deployments add upstream rate-limiting at the edge.
- **Glassworm Unicode in audit-log readers** — log-readers that render audit-row metadata as a string in a terminal can be tricked by bidi / zero-width / homoglyph characters in operator-supplied reasons. The framework's `b.redact` strips the dangerous classes from emitted audit rows; operators building custom log viewers route the rendered string through `b.guardHtml` (HTML viewer) or `b.guardCsv` (CSV exporter) before display.
- **picomatch / minimatch ReDoS class** — the framework does not vendor a glob library; the only glob-shaped match in `lib/` is the bounded subscription matcher in `b.pubsub` (`_MAX_CHANNEL_LEN` cap before regex compile). Operators vendoring a glob library separately must cap input length and apply a regex-evaluation budget (CVE-2026-26996 / CVE-2026-33671 / CVE-2026-27904 class).
- **AdonisJS multipart-filename → arbitrary-write class** — the framework's `b.fileUpload` routes every multipart filename through `b.guardFilename.gate({ profile: "strict" })` by default (path traversal / null-byte / NTFS ADS / UNC / overlong UTF-8 / Windows reserved names / RTLO bidi). Operators implementing a parallel multipart receiver outside the framework's primitive must wire the same gate.
- **fs.realpath symlink-chain Permission Model bypass class** — see "Operator territory" entry above; the framework's symlink defenses live at the application layer (`b.vault` PEM-file read-side + `b.staticServe` realpath gate); operators using Node's experimental Permission Model add it as defense-in-depth, never as the primary symlink-resolution boundary.
- **QUIC / HTTP/3 outbound (RFC 9000 / RFC 9001)** — the framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only. QUIC + HTTP/3 are deferred-with-condition: re-open when Node's `--experimental-quic` flag graduates to stable and `node:http3` ships. Until then, operators wanting outbound h3 wire their own client outside the framework (see `lib/http-client.js` header note on the future `kind: "h3"` transport shape). The framework's TLS 1.3 + h2 anti-amplification + flow-control caps remain in force on every other transport. Inbound h3 is similarly deferred; operators terminating h3 at the edge route h2 / h1 to the framework's `b.router`.

---

## Node 26 compatibility

Today's `engines.node` floor is `>=24.14.1` and the release container pins `node:24-alpine`. Node 26 satisfies the floor and the framework's test suite runs cleanly on Node 26 today. When Node 26 promotes to Active LTS (target Oct 2026), the framework will bump the floor to `>=26.x` in a dedicated slice that ships the queued refactors (Map.getOrInsertComputed sweep, Ed25519 context-parameter adoption, PKCS8 reverse-direction roundtrip test) as one PR. Operators tracking the prep work read [memory/specs/node-26-deferred-floor-bump-items.md] in the project memory.

Two Node 26 platform-level changes operators integrating with blamejs should be aware of now:

- **`localStorage` global.** Node 26 adds `localStorage` as a platform-wide global (returns `undefined` unless the process was started with `--localstorage-file`). The framework's storage backend is exposed as `b.backup.diskStorage(opts)` (renamed from `b.backup.localStorage` in v0.11.2; the legacy alias was removed in v0.11.20). The Node 26 global itself does not collide with the framework's property-access shape (`b.backup.X(...)`) — operators with user code under blamejs that uses **bare** `localStorage` (no `b.backup.` prefix) need to know that the name now resolves to a Node global rather than throwing `ReferenceError`; what was previously a typo surface is now a silent-noop surface.
- **ML-KEM / ML-DSA PKCS8 export shape.** Node 26 defaults `KeyObject.export({ format: "pkcs8" })` for ML-KEM-768/1024 and ML-DSA-44/65/87 to the seed-only PKCS8 encoding — structurally different and much shorter than the Node 24 full encoding. Sealed key material on disk written by the framework on Node 24 (vault primary key, audit-sign signing key, content-credentials signing key, AIBOM signing key, A2A card key, ACME account key) continues to re-import cleanly on Node 26 because `nodeCrypto.createPrivateKey({ format: "pkcs8" })` is documented to accept both shapes ([Node 26 release notes](https://nodejs.org/en/blog/release/v26.0.0)). New material written on Node 26 is in the seed-only shape — operators with parallel Node 24 readers of the same sealed disk must run a one-time migration when they bump the writer to Node 26 (Node 24's importer pre-dates the seed-only shape). The framework's integration suite at `test/integration/pqc-pkcs8-forward-compat.test.js` covers the current-Node export → re-import → sign-verify roundtrip and embeds a captured Node-26 seed-only fixture that re-imports every run as a regression guard. A captured Node-24 full-shape fixture is the missing piece for the cross-version assertion to be locally verifiable; it's added in the Node 26 floor-bump slice when a Node 24 build environment captures it. Until then the cross-version property rests on Node's documented importer contract.

## Reporting CVEs in vendored dependencies

The framework vendors all crypto libraries under `lib/vendor/`; the authoritative list with versions and licenses lives in [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json). Vulnerabilities found upstream that affect blamejs are tracked in the project's [Security tab](https://github.com/blamejs/blamejs/security/advisories). Operators subscribed to the repo's security advisories receive a notification on every published advisory.

We aim to ship a vendored-dep refresh release within 7 days of an upstream patch landing for any High / Critical CVE in our vendored set, faster for Critical-with-active-exploitation. The vendor-update workflow (`scripts/vendor-update.sh`) keeps the manifest, license, and provenance metadata in sync; every refresh release notes the from→to versions of every changed library.
