Security posture manifest v2: provenance envelope + CSRF/headers dimensions (Part of #1627)#1879
Conversation
…lope (Part of #1627) Extend the merged #1850 route-audit manifest into a provenance-tagged, multi-dimension security posture manifest (first #1627 slice). Schema v2: - Each dimension is wrapped in a `{ provenance, source, entries }` envelope, and a top-level `excluded` list names dimensions deferred to later slices with the provenance class they will eventually carry. - `routes` (provable) keeps its v1 entry shape, only wrapped in the envelope. - New `csrf` dimension (declared): one entry per mutating route with `csrf_enforced`/`exempt`, mirroring the runtime CsrfLayer predicate; the configured exemptions are recorded once at the dimension level. - New `security_headers` dimension (declared): one entry per header key with the effective declared value (including the resolved CSP template) and an `emitted` flag. Plumbing (macro-free): the `AUTUMN_DUMP_ROUTES` dump additionally emits the resolved CSRF + headers config on a stderr marker line (`[autumn:security-config] `), gated on `AUTUMN_DUMP_SECURITY=1` so only `autumn routes audit` sees it and the routes-only JSON parse path stays byte-compatible. Emitted lists are sorted for byte-determinism. Tests: red->green falsifiability fixtures for each dimension driving the real `build_manifest` path (disabling CSRF / adding an exempt prefix flips exactly the affected csrf entries; weakening x_frame_options / emptying the CSP changes exactly that header entry; byte-identical rebuild), plus a consolidated `manifest_posture` integration module covering the autumn-web dump contract. Docs: new guide page defining the provable / declared / runtime-only provenance classes with one example each, cross-linked from routes-cli. CHANGELOG intentionally not edited.
There was a problem hiding this comment.
Code Review
This pull request upgrades the security posture manifest to schema version 2, wrapping dimensions in a provenance envelope (provable, declared, or runtime-only), adding csrf and security_headers dimensions, and introducing a top-level excluded list. It also updates the CLI and runtime to dump and parse the resolved security configuration, supported by extensive integration tests and updated documentation. The review feedback highlights two opportunities to improve data cleanliness: first, using the sorted and deduplicated exempt_paths variable in build_csrf_dimension rather than the raw config; second, deduplicating safe_methods and exempt_paths in SecurityDump::from_config to ensure a clean serialized payload.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| .iter() | ||
| .filter(|r| !method_is_safe(&r.method, &csrf.safe_methods)) | ||
| .map(|r| { | ||
| let exempt = path_is_exempt(&r.path, &csrf.exempt_paths); |
There was a problem hiding this comment.
In build_csrf_dimension, the path_is_exempt check is performed using the original csrf.exempt_paths instead of the sorted and deduplicated exempt_paths local variable. This defeats the purpose of deduplicating the paths beforehand and can lead to redundant checks.
| let exempt = path_is_exempt(&r.path, &csrf.exempt_paths); | |
| let exempt = path_is_exempt(&r.path, &exempt_paths); |
| let mut safe_methods = csrf.safe_methods.clone(); | ||
| safe_methods.sort(); | ||
| let mut exempt_paths = csrf.exempt_paths.clone(); | ||
| exempt_paths.sort(); |
There was a problem hiding this comment.
In SecurityDump::from_config, the safe_methods and exempt_paths lists are cloned and sorted, but they are not deduplicated. Deduplicating them here ensures that the serialized SecurityDump on the wire is completely clean and free of duplicates, matching the deduplication logic in build_csrf_dimension.
let mut safe_methods = csrf.safe_methods.clone();
safe_methods.sort();
safe_methods.dedup();
let mut exempt_paths = csrf.exempt_paths.clone();
exempt_paths.sort();
exempt_paths.dedup();- routes_audit: per-route path_is_exempt now uses the sorted+deduped exempt_paths local, matching the dimension-level exempt_paths record. - route_listing: dedup safe_methods and exempt_paths after sorting so the serialized wire payload is duplicate-free.
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bbefdb0874
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let mut safe_methods = csrf.safe_methods.clone(); | ||
| safe_methods.sort(); | ||
| safe_methods.dedup(); | ||
| let mut exempt_paths = csrf.exempt_paths.clone(); |
There was a problem hiding this comment.
Include runtime webhook CSRF exemptions
When security.webhooks.endpoints is configured, runtime CSRF exempts each endpoint path via csrf_layer.with_exempt_path(&endpoint.path) in apply_csrf_middleware, but this dump only carries security.csrf.exempt_paths. For a configured POST webhook route that is not duplicated in csrf.exempt_paths, the v2 manifest reports csrf_enforced: true and omits the exemption even though runtime skips CSRF, so the declared CSRF dimension is incorrect.
Useful? React with 👍 / 👎.
|
|
||
| let mut entries: Vec<CsrfEntry> = routes | ||
| .iter() | ||
| .filter(|r| !method_is_safe(&r.method, &csrf.safe_methods)) |
There was a problem hiding this comment.
Treat WebSocket route listings as CSRF-safe
For #[ws] routes the route listing uses the synthetic method WS, but the router mounts them as GET and CsrfLayer checks the real request method against safe_methods, so the default GET exemption applies. This filter treats WS as mutating because it is not in the configured safe-method list, causing apps with CSRF enabled and WebSocket routes to get manifest entries claiming CSRF is enforced where runtime never validates a token.
Useful? React with 👍 / 👎.
| x_frame_options: headers.x_frame_options.clone(), | ||
| x_content_type_options: headers.x_content_type_options, | ||
| xss_protection: headers.xss_protection, | ||
| content_security_policy: headers.content_security_policy.clone(), |
There was a problem hiding this comment.
Report the nonce-aware CSP value
When security.headers.csp_nonce.enabled = true and the CSP is the default, runtime does not emit this config string; ComputedHeaders::from_config switches to the nonce-aware CSP template and substitutes a nonce per request. Copying the raw content_security_policy here makes the new security_headers dimension show a CSP value that responses do not actually send for nonce-enabled apps.
Useful? React with 👍 / 👎.
| fn header_entry(header: &str, value: String) -> HeaderEntry { | ||
| HeaderEntry { | ||
| header: header.to_owned(), | ||
| emitted: !value.is_empty(), |
There was a problem hiding this comment.
Validate emitted headers the same way runtime does
With a non-empty but invalid header value from config or env, such as a CSP or referrer policy containing a newline, this reports emitted: true even though the runtime security headers layer only inserts headers when HeaderValue::from_str succeeds and otherwise skips them. That makes the manifest overstate security headers exactly for a misconfiguration it should expose, so emitted should mirror the runtime parse check.
Useful? React with 👍 / 👎.
…untime Four honesty fixes so the security-posture manifest never claims more than the runtime actually enforces: - CSP nonce (A): dump the nonce-aware CSP template with the stable AUTUMN_CSP_NONCE placeholder when nonce injection is active + default CSP, mirroring ComputedHeaders::from_config. Extracted a shared resolved_content_security_policy() as the single source of truth. - Webhook CSRF exemptions (B): fold every configured webhook endpoint path into csrf.exempt_paths (sorted + deduped), matching apply_csrf_middleware's with_exempt_path calls, so webhook POST routes report exempt/unenforced. - WebSocket routes (C): treat WS-listed routes as CSRF-safe. #[ws] routes are mounted as GET, so CsrfLayer sees GET and never validates a token. - HeaderValue parse (D): emitted = non-empty AND HeaderValue::from_str ok, mirroring the runtime layer that skips invalid header values. Adds a red->green fixture per finding.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a98b4f978
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| let mut entries = vec![ | ||
| header_entry("content_security_policy", h.content_security_policy.clone()), | ||
| header_entry("csp_nonce", bool_header(h.csp_nonce, "enabled")), |
There was a problem hiding this comment.
Don't report CSP nonce as an emitted header
When security.headers.csp_nonce.enabled is true, this adds a security_headers entry with header: "csp_nonce" and emitted: true, but runtime only stores CspNonce in request extensions and optionally substitutes it into the Content-Security-Policy; it never sends a csp_nonce response header. For nonce-enabled apps the manifest therefore overstates the actual response headers, so this should either be modeled outside the header list or not marked as emitted.
Useful? React with 👍 / 👎.
csp_nonce is a config toggle that shapes the CSP value, not a response header. Runtime stores the nonce in request extensions and substitutes it into Content-Security-Policy; it never emits a csp_nonce header. Reporting it in security_headers overstated the actual response headers. The nonce toggle's effect stays visible via the nonce-aware CSP value.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eac7c10f78
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for endpoint in &config.security.webhooks.endpoints { | ||
| exempt_paths.push(endpoint.path.clone()); | ||
| } |
There was a problem hiding this comment.
Include unsubscribe CSRF exemptions in the dump
When the mail feature is enabled and the default one-click unsubscribe endpoint is mounted (mail.mount_unsubscribe_endpoint/.mount_unsubscribe_endpoint() with a base URL), runtime adds crate::mail::UNSUBSCRIBE_PATH to the CSRF layer in apply_csrf_middleware (autumn/src/router.rs:2188-2189), but this dump only folds webhook endpoints into csrf.exempt_paths. The v2 manifest therefore under-reports a runtime CSRF exemption for that app configuration, so consumers see declared CSRF posture that does not match what the server actually enforces.
Useful? React with 👍 / 👎.
Runtime's apply_csrf_middleware exempts the framework-owned RFC 8058 one-click unsubscribe endpoint from CSRF under the mail feature when should_mount_unsubscribe_endpoint() holds, but SecurityDump::from_config only folded webhook endpoints, under-reporting that exemption in the v2 manifest. Fold UNSUBSCRIBE_PATH in under the identical cfg+predicate.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94fad98121
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if method == "WS" { | ||
| return true; |
There was a problem hiding this comment.
Honor configured GET safety for WS routes
When CSRF is enabled and an app customizes security.csrf.safe_methods to remove GET, runtime no longer treats WebSocket upgrades as safe: #[ws] mounts as a real GET request and CsrfService only skips methods present in the configured safe-method list. Returning true for every synthetic WS route omits those routes from csrf.entries, so the manifest says no CSRF enforcement while runtime will validate/block the GET upgrade; this should map WS to GET and then apply the configured safe/exempt predicate.
Useful? React with 👍 / 👎.
WS routes are listed with the synthetic method WS but mount via axum::routing::get, so runtime CsrfService checks the real GET against the configured safe_methods. The audit previously treated WS as unconditionally safe, so an app that removes GET from safe_methods would have its WebSocket upgrade CSRF-validated at runtime while the manifest omitted it entirely. Normalize WS to GET before the safe-method check and drop the unconditional WS branch, so WS routes are excluded under default config (GET safe) but surface in csrf.entries with the exempt-path predicate applied when GET is not a safe method. The entry keeps its listed WS method for display, consistent with the routes dimension.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c8607e8a8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let mut entries: Vec<CsrfEntry> = routes | ||
| .iter() | ||
| .filter(|r| !method_is_safe(&r.method, &csrf.safe_methods)) |
There was a problem hiding this comment.
Include mutating framework routes in CSRF entries
When actuator.sensitive = true and CSRF is enabled, runtime mounts mutating framework routes such as PUT {prefix}/loggers/{name} and, with http-client, POST {prefix}/webhooks/replay, but the route listing only contributes GET actuator paths (and omits the PUT path). Because this new CSRF dimension is built solely by filtering the listed routes, those runtime-mutating actuator endpoints never appear in csrf.entries, so the manifest under-reports CSRF enforcement for sensitive actuator deployments.
Useful? React with 👍 / 👎.
… sees them
The route listing derived framework actuator routes from
actuator_endpoint_paths, a GET-only, paths-only list, and wrapped every
entry as a framework GET. Two consequences fed the csrf posture dimension
a wrong picture of the mutating attack surface:
- PUT {prefix}/loggers/{name} (mounted when actuator.sensitive) was
absent from the listing entirely, so csrf never saw it.
- POST {prefix}/webhooks/replay (mounted under http-client) was emitted
as a phantom GET, since the listing forced GET on every actuator path
even though runtime mounts only POST there.
Add actuator_mutating_routes(prefix, sensitive) returning (method, path)
pairs gated to match the mounts, a method-aware RouteInfo::framework_route
constructor, and push those routes after the GET loop in
append_framework_routes. Drop /webhooks/replay from the GET path list so
it is emitted once, as POST. /webhooks/dlq stays GET (mounted as GET).
Adds listing-level and csrf-dimension fixtures asserting the PUT loggers
route surfaces with csrf enforced and the phantom GET replay is gone.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7dd75f2d36
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if config.mail.should_mount_unsubscribe_endpoint() { | ||
| exempt_paths.push(crate::mail::UNSUBSCRIBE_PATH.to_owned()); |
There was a problem hiding this comment.
Add the mounted unsubscribe POST to the route listing
When the mail feature is enabled and an app opts into the default one-click unsubscribe endpoint, runtime merges unsubscribe_router() with both GET and POST at UNSUBSCRIBE_PATH and exempts that path from CSRF. This branch records the exemption, but no corresponding framework RouteInfo is appended, so build_csrf_dimension (which only iterates listed routes) still emits no entry for the runtime POST /_autumn/unsubscribe; the v2 manifest under-reports CSRF posture for those opted-in apps.
Useful? React with 👍 / 👎.
…posture sees the exempt POST
When the mail feature is enabled and the default RFC 8058 one-click
unsubscribe endpoint is mounted, runtime merges unsubscribe_router()
(GET + POST at UNSUBSCRIBE_PATH) and exempts that path from CSRF.
SecurityDump::from_config already folds UNSUBSCRIBE_PATH into
csrf.exempt_paths, but no framework RouteInfo was appended for it, so
build_csrf_dimension emitted no entry for the runtime POST and the
manifest under-reported CSRF posture.
append_framework_routes now lists GET + POST framework routes at
UNSUBSCRIBE_PATH under the same should_mount_unsubscribe_endpoint()
predicate the exempt fold uses, so the POST surfaces as
{ csrf_enforced: false, exempt: true } matching runtime.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae1e8c1a5f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Only the GET DLQ listing is enumerated here; `/webhooks/replay` | ||
| // is mounted as `POST` (see `actuator_mutating_routes`), not `GET`. | ||
| paths.push(actuator_route_path(prefix, "/webhooks/dlq")); |
There was a problem hiding this comment.
Keep replay in actuator runtime path sets
When the http-client feature is enabled with actuator.sensitive = true, this removes /webhooks/replay from actuator_endpoint_paths, but runtime code still uses that helper as the actuator allow-list: StartupBarrierState::from_config seeds actuator_paths from it and only adds /loggers as a separate subtree. During startup, POST {prefix}/webhooks/replay is still mounted by actuator_router_with_prefix but no longer bypasses the startup barrier, unlike the other actuator endpoints and unlike before this change; split the route-listing GET-only view from the runtime path set or add the mutating actuator paths to the barrier allow-list.
Useful? React with 👍 / 👎.
…tus route; disclose serve-path routers
Change 1 (regression fix): a prior commit dropped `/webhooks/replay` from
`actuator_endpoint_paths` to kill a phantom GET in the route listing, but that
helper is dual-use — `StartupBarrierState::from_config` seeds the startup
barrier's actuator allow-list from it. Removing the path meant the mounted
`POST {prefix}/webhooks/replay` no longer bypassed the startup barrier. Restore
the path in `actuator_endpoint_paths` (runtime allow-list complete again) and
instead exclude any path also produced by `actuator_mutating_routes` from the
GET-wrap in `append_framework_routes`, so the listing carries only the real
POST and no phantom GET.
Change 2: enumerate the tracked-job status route `GET /_autumn/jobs/{token}`
in `append_framework_routes`, gated on `jobs.tracking.route_enabled` (default
true), matching the router mount.
Change 3: add one honest `excluded` manifest entry (`serve_path_routers`)
disclosing that opt-in serve-path HTTP surfaces (MCP, inbound-mail, storage,
SEO) are injected after the route-dump early-exit and so are not enumerated at
build time, with their CSRF-exempt/self-protected mutating endpoints noted;
enumeration deferred to a dump-plumbing follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjQWjvP71LMRPsQexcYPvi
Part of #1627. Extends the merged #1850 route-audit manifest into a provenance-tagged, multi-dimension security posture manifest (first slice).
Before / After — what the manifest proves
Before (schema v1):
autumn routes auditemitted a singleroutesdimension — a flat list of routes taggedprovenance: "provable". That was the only security fact the manifest carried, and there was no vocabulary for facts that come from configuration rather than from code, nor any record of what was not yet covered.After (schema v2): every dimension is wrapped in a provenance envelope (
{ provenance, source, entries }), two newdeclareddimensions describe CSRF and security-header posture read from config, and a top-levelexcludedlist names every dimension deferred to a later slice with the provenance class it will eventually carry — so a reader can tell "proven absent" from "not yet measured".The
routesentries are byte-for-byte identical to v1 — only their container changed.How
Provenanceis a small closed enum (provable/declared/runtime-only).Dimensionshas a fixed field order (routes,csrf,security_headers); every list is deterministically sorted.excludedis a fixed, stable-ordered list.AUTUMN_DUMP_ROUTESdump now also emits the resolvedCsrfConfig+HeadersConfigas one compact JSON object on a stderr marker line ([autumn:security-config], a newSecurityDumptype inautumn_web::route_listing), gated on a newAUTUMN_DUMP_SECURITY=1env var that onlyautumn routes auditsets.autumn routesis unaffected and the routes-only stdout JSON parse path stays byte-compatible. Emitted lists are sorted for byte-determinism; the audit CLI hides the marker line from forwarded stderr.csrfentry per mutating route (method not in the configuredsafe_methods).csrf_enforced = enabled && !is_safe(method) && !is_exempt(path), withis_exemptmirroring the runtimeCsrfLayerprefix predicate (autumn/src/security/csrf.rs) exactly. Configured exemptions are recorded once at the dimension level.security_headersentry per header key, value = the effective declared string (the CSP is the resolveddefault_content_security_policy()template the runtime uses),emitted = !value.is_empty(), in sorted key order.Falsifiability tests (AC-6)
Genuine red→green fixtures driving the real
build_manifestpath (inline inroutes_audit.rs, sinceautumn-cliis binary-only):manifest_v2_envelope_shape_and_provenance_labels— schema v2, three dimensions with correct provenance labels, fullexcludedlist.csrf_dimension_only_covers_mutating_routes— safe (GET) routes never appear incsrf.entries.disabling_csrf_flips_only_csrf_entries— togglingenabledflips exactly the csrf entries;routes/security_headersbyte-identical.adding_exempt_path_flips_only_affected_csrf_entries— an/api/prefix flips only the routes under it; other dimensions byte-identical.weakening_a_header_changes_only_that_entry— DENY→SAMEORIGIN and emptying the CSP each change exactly that header entry;routes/csrfbyte-identical.manifest_rebuild_is_byte_identical— two builds of identical source+config are byte-for-byte equal.parse_security_config_reads_the_marker— the stderr marker round-trips.Plus a consolidated
autumn-cli/tests/integration/manifest_posture.rsmodule covering theautumn-webdump contract (SecurityDump::from_configsorting/determinism and CSRF/header falsifiability at the wire boundary).Gates run green:
cargo fmt --all -- --check,cargo clippy -p autumn-web -p autumn-cli --all-targets -- -D warnings,cargo test -p autumn-cli routes_audit(22 passed),cargo test -p autumn-cli --test cli_tests manifest_posture(4 passed),cargo test -p autumn-web --lib route_listing(44 passed).Docs: new
docs/guide/security-posture-manifest.mddefining the three provenance classes with one example each (provable: a#[secured]route; declared: the resolved CSP string; runtime-only: policy registration at boot inexcluded), cross-linked fromroutes-cli.md.Deferred to later slices
Listed in the manifest's
excludedblock, not implemented here:authorization_policies— full#[authorize]action→policy-type binding (needs new macro metadata; boolean policy presence already ships inroutes.entries[].policy)sessionssecretsencryption(#[encrypted]fields provable; key config declared)rate_limitsubmit_tokenoutbound_httppolicy_registration(runtime-only boot fact)Notes
autumn-macros— this slice is entirely macro-free.🤖 Generated with Claude Code
https://claude.ai/code/session_01SjQWjvP71LMRPsQexcYPvi
Generated by Claude Code