Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 45 additions & 629 deletions AGENTS.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ stops being the first migration or loses its recorded id.
A dev database created **before #407 merged** predates those folded-in columns —
wipe and recreate it as above.

The full reasoning — why the freeze, what the fingerprint guard covers, and what
made the wrong versions of it pass — is in
[`docs/decisions/407-migration-freeze.md`](docs/decisions/407-migration-freeze.md).

### Backup & restore (self-hosted)

Two complementary layers (spec §17.5):
Expand Down Expand Up @@ -184,6 +188,12 @@ from the changelog; see [Writing a commit message](#writing-a-commit-message)).
Releasing has two stages: **CI publishes an image for every merge; you decide when
those become a version.**

> This section is the **how-to**. The **invariants** — what not to break, and why
> each step is shaped the way it is — live in the release section of
> [`AGENTS.md`](AGENTS.md#releases-and-image-publishing-351); the full internal
> mechanism (promotion, the release-please split, the App token, the commit-body
> parser) is in [`docs/decisions/351-releases.md`](docs/decisions/351-releases.md).

### 1. Merging a PR into `main`

CI builds the image, scans it for vulnerabilities, boots it against a throwaway
Expand Down
109 changes: 109 additions & 0 deletions docs/decisions/146-ci-security-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# CI security gates, lock-file healing, Dependabot, action pinning (#146)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).


CI fails a PR when a dependency carries a known **high+** advisory:

- **NuGet** — `dotnet list package --vulnerable` (parsed; the CLI always exits 0).
- **npm, production deps** — `npm audit --omit=dev`. Dev-only advisories (vite,
vitest, eslint…) are **advisory only** — logged, never blocking, since they
don't ship to users. Promote to blocking, or bump the dep, when one appears.
- **Dependency review** — PR-only; fails when the diff *introduces* a vulnerable
dep. Needs the repo's **Dependency graph** (Settings → Advanced Security); while
it's off the step self-skips with a loud CI warning and activates automatically
once enabled.
- **CodeQL** (`.github/workflows/codeql.yml`) — SAST, **advisory** (reports to the
Security tab; not a required check). To gate on it, enable code-scanning merge
protection in a branch ruleset ("Require code scanning results").
- **Scheduled audit** (`.github/workflows/security-audit.yml`) — the same two
audit gates on a weekly cron against `main`, plus `workflow_dispatch`. The CI
gates only fire on a PR or a push, so without this an advisory published
against a dependency nobody is touching goes unnoticed until the next PR.

**NuGet lock files.** `Directory.Build.props` sets `RestorePackagesWithLockFile`,
so every project has a committed `packages.lock.json` and CI restores with
`--locked-mode` — restores are **deterministic**, and a dependency can't float to
a different resolved version between a green local run and CI. **When you add or
bump a package, run `dotnet restore` and commit the changed lock files in the
same commit** — otherwise CI fails the restore with `NU1004`.

**How the graph learns about transitive NuGet.** Not from the lock files. GitHub
parses `.csproj`/`.vbproj`/`.nuspec`/`.fsproj`/`packages.config` for NuGet, never
`packages.lock.json`, and doesn't derive NuGet transitives statically — on
manifests alone it sees 20 direct `PackageReference`s out of ~80 resolved, leaving
dependency-review blind to a transitively-introduced vulnerable package.
`.github/workflows/dependency-submission.yml` closes that: on a push to `main`
touching the dependency set, Microsoft's component-detection reads the restore
output and submits the resolved graph via the Dependency Submission API. npm needs
none of this — the graph reads `web/package-lock.json` and already has the full tree.

### Dependabot NuGet PRs: automatic lock-file healing

Dependabot bumps a package in one project and regenerates only that project's
`packages.lock.json`; every downstream project in the reference chain then fails
CI's `--locked-mode` restore with NU1004. The `.github/workflows/dependabot-lockfix.yml`
workflow heals this automatically: after CI completes on a `dependabot/nuget/**`
PR, it re-runs `dotnet restore Cluckwork.sln --force-evaluate` (in a no-credential
job), then commits and pushes the refreshed lock files (in a separate job that
runs no project code and holds a short-lived GitHub App token). The App-token push
re-triggers CI, which then goes green. See
`docs/superpowers/specs/2026-07-25-dependabot-lockfix-design.md` for the security
model.

**One-time setup (required for the push to work):** create a GitHub App with
Repository → Contents: Read and write, install it on this repo, and add the repo
Actions secrets `LOCKFIX_APP_CLIENT_ID` and `LOCKFIX_APP_PRIVATE_KEY`. Until both exist
this workflow fails closed (no push) — **and so does the Release workflow (#351),
which shares the same App**: without them its mint step fails on every push to
`main`, so no release is cut.

The **Release** workflow needs **Pull requests: Read and write** and **Issues:
Read and write** on top. Changing an App's permissions does **not** apply to an
existing installation until the installation owner **approves** the request
(GitHub holds it pending), so adding the permissions in the App settings is only
half the job — approve it on the repo's installation too, or the mint keeps
failing with the old grant.

That widens the *installation*; each mint then downscopes with `permission-*`, and
the lockfix job pins `permission-contents: write` so the extra grants never reach
the token that pushes to a Dependabot branch. **Keep that pin when adding
consumers** — and understand its limit: `permission-*` caps the token the action
returns, not the private key, which can always mint the App's full grant. The cap
makes a wider token a deliberate act rather than the default; it is not a
boundary. The lockfix job stays genuinely narrow because it executes no
PR-controlled code, not because of the cap alone.

**Dependabot** (`.github/dependabot.yml`) covers the other half: the gates
*enforce* (a vulnerable dep fails the build), Dependabot *proposes* (it opens the
bump PR, and — with Dependabot alerts enabled in repo settings — flags a new
advisory the day it publishes). Neither replaces the other. Weekly grouped
version updates for `github-actions`, `npm` (`web/`) and `nuget`; security fixes
arrive ungrouped so they can be read and merged on their own.

Both audit gates run through `.github/scripts/vuln-gate.mjs` (self-tested with
`node --test`), which shares one **escape hatch**: `.github/security-exceptions.json`.
Add a `{ id: GHSA-…, ecosystem, reason, expires }` entry to mute one advisory
until a **required** calendar date — past it, the advisory blocks again and CI
warns the entry is stale. The gate **fails closed**: a malformed report (e.g. an
`npm audit` registry error), an unknown severity, or a malformed exception
(missing scope/reason, impossible date, non-GHSA id) all block rather than pass.
The `id` must be an exact GHSA, so an advisory GitHub only knows by CVE can't be
excepted — bump or pin the package instead. Reach for an exception only when
there's no fixed version to move to; prefer bumping or pinning a patched
transitive version (npm `overrides` / direct NuGet reference) first. The same
file feeds dependency-review's allowlist, so the gates never disagree.

### Pin third-party Actions to a commit SHA

Third-party GitHub Actions (anything **not** `actions/*` or `github/*`) are pinned
to a full commit SHA with a trailing `# vX.Y.Z` comment — **never** a mutable
version tag. A compromised action can retarget a "trusted" tag to malicious code
that exfiltrates CI secrets; both the 2026-03 `aquasecurity/trivy-action` and the
2025-03 `tj-actions/changed-files` incidents did exactly that. Dependabot's
`github-actions` ecosystem reads the trailing comment and bumps **both** the SHA
and the comment on a new release, so a SHA pin stays current. GitHub-owned
`actions/*` and `github/*` may keep major-version tags (GitHub-controlled, lower
risk). Currently SHA-pinned: `actions/create-github-app-token`,
`aquasecurity/trivy-action`, and
`advanced-security/component-detection-dependency-submission-action`.
5 changes: 5 additions & 0 deletions docs/decisions/261-postgres-tls-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Production Postgres TLS floor + libpq mapping (#261/#262)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).

**Production Postgres TLS floor (#261/#262):** `ConnectionStrings:Default` accepts libpq URI form (`postgresql://…`, translated to Npgsql key-value; a param Npgsql supports under a different spelling — `channel_binding`, `target_session_attrs`, `gssencmode` — is **mapped**, and only a genuinely unsupported one — `sslcompression` — skips-with-warning; **`ContainsKey("<libpq name>") == false` does NOT mean Npgsql lacks the setting** — it usually means the keyword is spelled differently (`keepalives` → `Tcp Keepalive`, `client_encoding` → `Client Encoding`, `gssencmode` → `GSS Encryption Mode`), so search the keyword list for the concept before concluding a param is unmappable; that exact false negative is what shipped #332, and the first fix for it re-made the same mistake with `keepalives`. Still unmapped and worth a follow-up: the `keepalives*` family and `client_encoding` — `keepalives` needs a *value* translation (`1` → a bool keyword), not just a keyword map) as well as key-value. In **Production** the effective `sslmode` is validated once at boot as a fail-closed **allow-list**: `VerifyCA`/`VerifyFull` silent, `Require` warns (prefer VerifyFull), and **everything else — `Disable`/`Allow`/`Prefer`, unset (Npgsql defaults to `Prefer`), or any undefined `SslMode` — fails the boot**. Never auto-injected. `Database:AllowInsecureConnection=true` is the explicit opt-out for the co-located plaintext compose stack (set on both `app` and `migrate`; a real deploy never sets it). The `VerifyFull` CA + per-host `sslmode` are deploy config (in the separate deployment/ops repo). Integration tests opt out of this **and** the #260 guard in `CluckworkWebApplicationFactory` (plaintext Testcontainers, no proxy) — a Production-env test that skips those opt-outs will fail the boot.
5 changes: 5 additions & 0 deletions docs/decisions/263-migrate-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Migrate command + prod migration split (#263)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).

**Migrate command + prod migration split (#263):** `dotnet Cluckwork.Api.dll migrate` is a third run-then-exit CLI verb (same shape as `seed`) that applies EF migrations then exits — the **pre-deploy-job entrypoint**. In **Production** (`appsettings.Production.json`, loaded because the container leaves `ASPNETCORE_ENVIRONMENT` unset → Production; setting it to Development silently re-enables boot-migration) **`Database:MigrateOnStartup=false`**, so the request-serving process never runs schema DDL. The **actual guarantee is ordering** — migrate runs *before* serve: `deploy/docker-compose.yml` runs a one-shot `migrate` service first and `app` waits on `service_completed_successfully` (any other orchestrator runs the `migrate` job as a pre-deploy step). `DatabaseReadyHealthCheck` is the **backstop**: `/health/ready` returns **503 while any migration is pending**, so an orchestrator's readiness gate won't route traffic to a stale schema. This is the app-side of #263's privilege separation. The **remaining host-specific deploy step**: create a least-privilege runtime role (`GRANT USAGE ON SCHEMA public` + DML/sequence privileges, **no DDL**; plus **`ALTER DEFAULT PRIVILEGES`** for the migrator role so objects future migrations create are auto-granted to the runtime role) and point the migrate job at a separate owner/migrator credential. This PR **does not** close #263 on its own — it references it; the role split closes it.
5 changes: 5 additions & 0 deletions docs/decisions/266-container-health-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Container health probe: the `healthcheck` verb (#266)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).

**Container health probe (#266):** the runtime image **and** the compose `app` service carry a `HEALTHCHECK` that runs `dotnet Cluckwork.Api.dll healthcheck` — a **fourth run-then-exit CLI verb**, but **dispatched *before* host build** (`Program.cs`, `args is [HealthCheckCliCommand.Verb, ..]`), not via `CliDispatcher`: unlike `migrate`/`seed`/`recover-admin` — which operate on the built host — this one needs no host/DI/DB/config, so a 30s probe must not re-run the whole app startup or re-log boot warnings on every tick. The hardened image ships **no curl/wget** (#267), so the probe is **in-process**: it GETs `/health/ready` over loopback (port derived from `ASPNETCORE_URLS`, default `8080`) and exits `0` on a 2xx, `1` on any other status **or** an unreachable server (refused/timeout) — never a false green. So an instance whose `/health/ready` is 503 (DB down, migrations pending per #263) reports **unhealthy** and compose/an orchestrator stops routing to it. Its `ProbeAsync`/`DefaultReadyUrl` are unit-tested directly (no Docker). The CI `image` job (#267) now also **boots** the built image against a throwaway Postgres and asserts `/health/ready` goes green + the verb exits `0`, so an unbootable image fails CI instead of at deploy time — this is the **app-side of #266**. Deploy-side (the host's readiness path, wait-for-CI gate, post-deploy smoke) lives in the separate deployment/ops repo.
Loading
Loading