Skip to content
Open
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
286 changes: 286 additions & 0 deletions .github/workflows/asvs-prove-absences.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Update it whenever a crypto dependency, algorithm, or key source changes.
| Config fingerprint ([ADR 0041](adr/0041-load-path-attestation-and-change-attribution.md)) | SHA-256 content digest of a loaded config bundle — path-relative Merkle fold over every loaded file (`*.py` incl `_*.py`, `connections.toml`, `codesets/*`, `environments/*.toml`); `hashlib` in `config/fingerprint.py` | Recorded in the `config_reload` audit detail (not stored as a secret) | Recomputed per reload/startup; binds reviewed-commit → loaded-bytes (integrity/attribution, not confidentiality) |
| Engine wheel attestation ([ADR 0041](adr/0041-load-path-attestation-and-change-attribution.md) D3) | SHA-256 over each **loaded** first-party `messagefoundry` module file, compared to the installed wheel's `*.dist-info/RECORD` baseline (a base64 `sha256=` manifest already in the wheel); `hashlib` in `integrity.py` | Drift recorded in the hash-chained `startup_integrity` audit row (not a secret); RECORD baseline read from site-packages metadata | Recomputed at startup + on demand; in-place-tamper tripwire (integrity, not confidentiality). Alert-only by default; `[integrity].fail_closed_on_drift` refuses to start on drift; no-op on an editable install |
| ASVS corpus pin ([ADR 0156](adr/0156-asvs-scorecard-as-data-a-derived-count-verified-evidence-anchors-and-a-fail-closed-drift-gate.md)) | SHA-256 over the **OWASP ASVS 5.0.0 corpus file**, recorded in `[scorecard].corpus_sha256` and recomputed on every verifier run; `hashlib` in `scripts/asvs/scorecard.py`. **Integrity of a build input, not a security control** — no secret, no key, no message authentication, and nothing user- or PHI-derived is hashed. It exists because the corpus was originally fetched from `master` (the bleeding-edge branch, where a rolling "latest" release republishes identical filenames) and matched the tagged `v5.0.0_release` asset only by luck; the digest is now recorded and checked rather than assumed, because ASVS requirement ids are **not stable across versions** (bare `1.2.5` is *Architecture* in 4.0.3 and *Encoding and Sanitization* in 5.0.0), so a corpus that moves silently re-points every id in the scorecard | Not a secret: the digest is committed alongside the corpus it pins | Recomputed on every scorecard verification; a mismatch fails the gate and forces re-verification before any verdict is trusted |
| ASVS scorecard revision identifier ([ADR 0156](adr/0156-asvs-scorecard-as-data-a-derived-count-verified-evidence-anchors-and-a-fail-closed-drift-gate.md)) | SHA-256 over the **ASVS scorecard file**, printed truncated to 16 hex characters by a `--prove-absences` run; `hashlib` in `scripts/asvs/prove_report.py`. Same class as the corpus pin above and **not a security control** for the same reasons — no secret, no key, no message authentication, nothing user- or PHI-derived. It differs only in what it covers: the record itself rather than a build input, and it is never compared against a declared value. It exists so a run states *which* revision of the record it read — two runs reporting different counts are otherwise indistinguishable from one run whose input moved underneath it | Not a secret: it is an identifier in a run log, and the scorecard it covers is private for unrelated reasons | Recomputed on every run; nothing is gated on it, so a change is information for a reader rather than a failure |
| Outbound message signing (opt-in) | Detached JWS (RFC 7515) — RS256/PS256 (RSA) or ES256 (ECDSA P-256), SHA-256; `cryptography` in `transports/signing.py` (ASVS 4.1.5, [ADR 0018](adr/0018-per-message-signatures-accepted-risk.md)) | Operator-supplied PEM **private** signing key per connection (inline via `env()` or a PEM file path; encrypted-key passphrase via `env()`); the **public** key is shared with the partner out-of-band. **Usage scope:** this private key **only** signs this connection's outbound per-message JWS — a message-**authenticity/integrity** key in transit; it is never used for at-rest encryption or session/token material, and the partner holds only the matching **public** verification half | **OFF by default**; per-connection opt-in. `kid` carried in the JWS header so key rotation / a managed provider ([ADR 0019](adr/0019-pluggable-keyprovider-hsm-kms-vault.md)) slots in without a wire change |
| DIRECT S/MIME (opt-in, [ADR 0085](adr/0085-direct-hisp-smime-connector.md)) | CMS **sign-then-encrypt** in `transports/direct.py` (core `cryptography` `serialization.pkcs7`): PKCS#7 signature over the body with a **SHA-256** digest, the public-key signature algorithm (RSA / ECDSA) following the loaded signing key type (not pinned to RSA), then a PKCS#7 **envelope** to the partner's recipient cert. The envelope content-encryption cipher is the **`cryptography` pkcs7 library default** — no algorithm is pinned in code | Sender **signing cert** + PEM **private key** (optional `signing_key_password`) and the per-partner **`recipient_cert`**, all operator-supplied files; the recipient cert is trust-verified at construction against an operator `trust_anchor` (one-level direct-issuance check); key/cert mismatch refused. **Usage scope:** the sender signing key signs the CMS body and the partner's `recipient_cert` encrypts the CMS envelope — this material protects the **confidentiality + authenticity of a DIRECT message to one partner in transit**; it is not an at-rest store key and encrypts nothing in the store | **OFF by default** — only when a DIRECT Connection is configured, and its HISP relay host is gated by the **opt-in** `[egress].allowed_direct` allow-list (empty by default = unrestricted; an unlisted host is refused only once the list is populated, or outright when `[security].block_unlisted_outbound` is set). Signing key + recipient certs rotate on the schedule below |
| OIDC IdP JWKS verification keys (opt-in, [ADR 0142](adr/0142-federated-sso-oidc-authorization-code-pkce-relying-party-hybrid-ad-backed.md)) | **Public** verifying keys fetched from the IdP JWKS: **RS256/PS256** (RSA, ≥ 2048-bit floor) and **ES256/ES384** (EC P-256/P-384) — rebuilt from each JWK by `cryptography` in [`auth/oidc/jwks.py`](../messagefoundry/auth/oidc/jwks.py); the closed `SignatureAlgorithm` enum forecloses `alg:none` and RS256→HS256 confusion. Bounded, TTL-cached (`DEFAULT_JWKS_TTL_SECONDS`), a 512 KiB body cap, a global min-refetch floor (fetch-amplification bound), and a hard refusal of a duplicate `kid`; a key below the floor is skipped/refused, never merely warned. **Usage scope:** these are **public**, non-secret keys used **only** to verify the IdP's id-token signature at console login — they encrypt nothing and can protect no data; the engine holds no private half. | Fetched from the IdP JWKS URI over the CA-pinned no-redirect opener (row below); held process-local in `JwksCache`, never persisted, never logged | Refetched per TTL / on an unknown `kid` within the amplification bound; rolls when the IdP rotates its signing keys |
Expand Down
21 changes: 21 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8824,3 +8824,24 @@ larger change than this one.
**Source:** found 2026-08-09 while probing for a second instance of the `#1106` class before building a
generalised check, on the reasoning that a meta-check built from one instance is shaped like that
instance. Two domains were probed; this one leaked.

## 1203. Decide how the public engine repo obtains the private ASVS scorecard for --prove-absences

> 🔢 **Filed 2026-08-09. DECIDED the same day -- option 2: the scheduled pass runs in the vault. What remains is the vault-side workflow, sequenced separately and deliberately NOT built from the engine branch.** Value **7/10** · Difficulty **2/10**. `--prove-absences` is wired; its input -- `docs/security/asvs-scorecard.toml` -- lives in the vault, so the scheduled pass belongs there rather than in the public engine repo. Engine side is now `workflow_dispatch` only and carries no cron; `scripts/asvs/prove_report.py` ships in the engine and is mirrored into the vault, on the same footing as `scorecard.py` (ADR 0156 §7).

**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** decide -- decision made, build outstanding.
**Severity:** none to the engine. Until the vault workflow exists the 276 absence claims stay unproven by execution -- the state BACKLOG #1006 shipped the capability to end. The engine job no longer advertises that gap as a daily red: a job that fails closed on no input must not be *scheduled* to obtain no input, because a gate whose first act is to fail is one somebody switches off, and a disabled workflow reads the same as a passing one at a glance. The failing-closed behaviour itself is unchanged and must stay -- advisory applies to findings, never to the instrument.

**Why there is a decision at all.** The vault reads the engine for free: `asvs-scorecard.yml` checks out `MEFORORG/MessageFoundry` with the comment *"public: no token needed"*. The reverse direction has no free version, because the scorecard is in a private repo and the engine is public.

**The three options, with what each actually costs.**

1. **The engine holds a vault-read credential.** Implemented and off: `vars.ASVS_VAULT_REPO` and `secrets.ASVS_VAULT_READ_TOKEN` do not exist, and the workflow does not create them. Setting both switches it on with no code change. The cost is a security-boundary cost, not a maintenance one: the vault exists so that a compromise of the public repo does not yield the security corpus, and a vault-read token in the public repo's secret store collapses that boundary to a single credential. The checkout is already sparse to the one scorecard file, cone mode off, so the *materialised* blast radius is minimised -- that mitigates the checkout, not the token.
2. **The prover runs in the vault instead** -- a NEW workflow beside `asvs-scorecard.yml`, not inside it, so that job's deliberate stdlib-only, five-minute constraint is untouched. The vault already checks the engine out with no token, so this needs **no new credential in either direction**. Its cost is that the vault pays an install against a lockfile it does not own. `scripts/asvs/prove_report.py` and `scorecard.py` run there unchanged, pointed at a local scorecard and an `engine/` checkout. **This is the recommendation.**
3. **A self-hosted runner** that already holds both checkouts. Cheapest operationally; a self-hosted runner attached to a public repo is its own well-known hazard.

**The output points the same way as the input, and this is the half that is easy to miss.** The prover's problem lines name the cell and the control that would not prove, which is a ranked list of the weakest controls on the record -- the same disclosure that got the *"verdict-attributed anchor manifest"* rejected in the 2026-08-08 tracking-rework diagnosis. `prove_report.py` therefore suppresses them by default and prints only counts, with `--detail` reserved for a private log. So the public repo is the wrong holder of the input **and** the wrong host for the output, and neither is fixed by moving the environment -- whereas option 2's only cost *is* the environment.

**What would NOT be an honest resolution.** Making the no-input path green. Advisory applies to findings, never to the instrument: a run that obtained no scorecard scanned zero claims and is not evidence about any of them, which is why it exits 2. A green run that measured nothing is precisely the class [ADR 0158](adr/0158-silent-controls-green-signals-that-mean-nothing-and-shape-over-detection.md) exists to name, and it is the class this whole wiring was built to end. If the daily red is unwanted before a decision is made, **disable the workflow** -- that is honest, and it is visible in the workflow list.

**Source:** filed 2026-08-09 while wiring `--prove-absences` into CI, the follow-up named in #1006's own closing banner. Measured against vault `origin/main` at `1a59e4a195b12bfb54af96397fa4c4e076cc213d`: 276 absence claims, 0 carrying `observable`, 0 carrying `mutation_path`, and 0 references to `--prove-absences` under `.github/` in either repo before this wiring landed. Note that #1006's banner narrates *"~81 existing absence claims"*; the record carries 276, so that figure is stale and should not be quoted onward.
1 change: 1 addition & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ claims move with it.
| `zizmor.yml` | Lints the workflow files themselves for insecure patterns (template injection, over-broad tokens), and runs `actionlint` on the workflow syntax. Hard-fails, but **not a required check** — it is paths-filtered, so it does not report on a PR that touches no workflow, and requiring it would wedge every such PR. The `actionlint` pre-commit hook is the local half. |
| `dast.yml` | Authenticated authorization sweep against a live loopback listener in front of a real engine. **Not a required check** — nightly / release-tag / manual dispatch only, with no `pull_request` trigger, so it never reports on a PR and cannot wedge one. It is NOT `continue-on-error`: it goes red on a finding. See [ADR 0155](adr/0155-dast-dynamic-security-testing-of-the-running-engine.md). |
| `quality-advisory.yml` | Advisory quality measurement — complexity (ruff `C901`), duplication (`jscpd`), diff-coverage (`diff-cover`) and mutation testing (`mutmut`). **Every job is advisory and none is in branch protection.** See below for how each signal reaches a reviewer. |
| `asvs-prove-absences.yml` | Runs `scripts/asvs/scorecard.py --prove-absences`: applies each absence claim's stated reintroduction to a scratch tree and requires its named observable to go red. **Advisory and not in branch protection.** Two jobs. `selftest` runs on any PR touching the wiring, needs no credential, and is what stops the tool rotting in the repo that develops it. `prove` is **`workflow_dispatch` only** — the scheduled pass runs in the vault, the only repo holding the scorecard, per the 2026-08-09 location decision (BACKLOG #1203). A dispatch here still fails closed with exit 2 when no input is configured, because a run that scanned nothing must not report success; it is simply not *scheduled* to obtain nothing. `scripts/asvs/prove_report.py` ships here and is mirrored into the vault, on the same footing as `scorecard.py`. |

Several heavier legs (server-DB store tests, load/throughput, service-smoke, DICOM/FHIR breadth) run
**nightly on a schedule** and/or only when a PR touches their paths, so an ordinary PR does not pay for
Expand Down
Loading
Loading