github robintra/perf-sentinel v0.5.23

latest releases: chart-v0.2.62, chart-v0.2.61, v0.8.13...
one month ago

What's new in v0.5.23

v0.5.23 ships the second of three UX surfaces above the daemon ack API introduced in v0.5.20. After the CLI helper landed in v0.5.22, the HTML report can now operate in a live mode that connects to a running daemon for interactive ack/revoke workflows directly from the browser. The static report shipped without --daemon-url keeps its pre-0.5.23 behavior: same panels, same strict default-src 'none' CSP, no network egress. The HTML output for --input X --output Y (no daemon flag) is fully equivalent to the v0.5.22 artifact for the same input.

The new flag is perf-sentinel report --daemon-url <URL>. When set, the generated HTML connects to the daemon on every operator interaction: per-finding Ack and Revoke buttons appear in the Findings table, a connection-status badge sits in the top bar, a new Acks tab lists daemon-side acknowledgments (paginated at the daemon's MAX_ACKS_RESPONSE = 1000 cap), a Show acknowledged filter toggles whether already-acked findings stay in the list, a manual refresh button re-fetches /api/status, /api/acks, and a Forget key button purges the in-memory X-API-Key when the operator wants to drop the session. The browser-side JavaScript stays textContent-only with document.createElement and addEventListener, no innerHTML, no setAttribute('on*', ...), no eval. The no_forbidden_apis_in_template test continues to enforce the rule on every build.

Authentication uses the same X-API-Key header the v0.5.20 daemon API expects on POST and DELETE /api/findings/{sig}/ack. The HTML never embeds the key (a CI artifact must not leak credentials). Instead, on the first 401 from a write call, an auth modal opens with a <input type="password">. The submitted value lands in sessionStorage under perf-sentinel.daemon.api-key, scoped to the tab and cleared at tab close. localStorage is not used (purges only at browser shutdown, not tab close, the wrong threat-model fit). When the daemon rotates its key, the next /api/status call returns 401, the cached value is dropped via sessionStorage.removeItem, and the operator is re-prompted on the next write. Every browser fetch is wrapped in an AbortController with a 10-second timeout so a hung daemon does not leave requests pending until the browser's default network timeout (multi-minute on Chrome).

The daemon side of the picture is the new [daemon.cors] config section. Default allowed_origins = [] means the layer is not wired and the daemon emits no Access-Control-Allow-Origin header, byte-for-byte preserving the pre-0.5.23 loopback-only posture. Wildcard mode ["*"] is intended for development. A non-wildcard list whitelists exact origins for production. Mixing wildcard with explicit origins (["*", "https://reports.example.com"]) is rejected at config load with a clear error: silently degrading to wildcard would let an operator believe the whitelist was tighter than it actually was. The CORS layer is scoped to the /api/* sub-router only, never to the OTLP /v1/traces ingest, the Prometheus /metrics endpoint, or the /health probe. A wildcard CORS configuration cannot let a browser POST traces or scrape metrics, only call the operator-facing query API. The cors_layer_does_not_leak_to_otlp_or_metrics_or_health_routes test mirrors the real router topology and asserts the absence of CORS headers on those three paths under wildcard mode, so a future axum upgrade or accidental refactor that flipped the merge order would break the build instead of regressing security.

The CORS allow-list is intentionally narrow: methods GET, POST, DELETE, OPTIONS, headers Content-Type and X-API-Key only. X-User-Id was dropped from the allow-list because the daemon does not enforce it server-side, the by field on a POST /api/findings/{sig}/ack body is operator-attested only. Access-Control-Max-Age is 120 seconds (2 minutes), short enough for a tightened whitelist to take effect on the next browser preflight without a forced refresh, long enough to amortize the OPTIONS roundtrip across a typical interaction (open report, click ack, click revoke). Access-Control-Allow-Credentials is not set: incompatible with AllowOrigin::any(), and unnecessary because the daemon auths via the X-API-Key header rather than cookies. Operators wanting CORS protection on the API surface must keep daemon_api_enabled = true. Setting cors_allowed_origins while disabling the API is rejected at config load to avoid the silent post-deploy "why isn't ack working" trap.

The Content-Security-Policy meta tag is now built per render. Static mode keeps the 0.5.22 directive verbatim (default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'). Live mode appends connect-src 'self' <daemon_url>, so the in-page JavaScript can reach the validated daemon URL plus any same-origin asset (a future template change adding a same-origin fetch will not silently break under the strict CSP). The daemon URL is run through validate_url upstream (the same validator the perf-sentinel ack --daemon flag uses since 0.5.22), so no CSP-breaking byte (single quote, semicolon, whitespace, curly brace) can land in the directive value. A debug_assert!(!csp.contains("{{")) in inject plus a const _: () = { ... } block enforcing the same invariant on the static CSP prefix at compile time guard the substitution pipeline against a future relaxation of the validator.

The validator itself is now pub(crate) and reused by three CLI subcommands: perf-sentinel ack --daemon (since 0.5.22), perf-sentinel report --daemon-url (new in 0.5.23), and perf-sentinel query --daemon. The latter previously did its own ad-hoc Uri::parse that did not reject userinfo, paths, query strings the way validate_url does, so a perf-sentinel query --daemon http://alice@host/v1/?debug=1 would silently misroute. The drift is fixed and locked in by the same per-class rejection tests the ack subcommand already covers.

Helm chart 0.2.26 ships in lockstep, bumping appVersion to 0.5.23 and the default daemon image tag to ghcr.io/robintra/perf-sentinel:0.5.23. No chart-level config change beyond the image tag, the new [daemon.cors] and --daemon-url flag are pure runtime knobs that do not touch the chart templates.

Added

  • HTML report live mode: new --daemon-url <URL> flag on perf-sentinel report enables the live JavaScript boot path. Without the flag, the report stays static (current behavior preserved byte-for-byte, modulo a new placeholder substitution that produces the same final string).
  • Per-finding Ack and Revoke buttons in the Findings table, hidden in static mode via CSS (body.ps-live reveals them). Clicking Ack opens a modal with reason (required), expires (Never / 24h / 7d / 30d), and an optional by input; submit posts to /api/findings/{sig}/ack. Clicking Revoke confirms (text-only globalThis.confirm) then deletes the same endpoint.
  • Show acknowledged toggle in the Findings panel toolbar, also hidden in static mode. Filters the static finding list against the live /api/acks set.
  • Acknowledgments panel as a new Acks tab listing the daemon-side acks. Footer notes the MAX_ACKS_RESPONSE = 1000 cap consistent with the CLI ack list output.
  • Connection status badge in the top bar with three states: Connected (green), Authentication required (orange), Disconnected (red). Driven by a GET /api/status ping at boot and on every refresh.
  • Manual refresh button that re-fetches /api/status, /api/acks simultaneously via Promise.all and re-renders.
  • Forget key button revealed when an X-API-Key sits in the live state. Clicking purges sessionStorage and re-pings the daemon. Hidden when no key is cached so the static mode stays clean.
  • Auth modal with a no-echo password input, opened on the first 401 from a write call. The key is held in sessionStorage only, never localStorage, never the DOM, never console.log. Cancelling the modal sets the badge to Authentication required and disables further write attempts.
  • AbortController fetch timeout of 10 seconds on every live-mode browser fetch. A hung daemon does not leave the request pending until the browser's default network timeout.
  • [daemon.cors] config section with allowed_origins: array<string> (default []). Wildcard mode ["*"] for dev, exact-list mode for prod. Each non-wildcard entry must have a scheme, must not end with a trailing slash, and must not contain ASCII control characters. Mixed wildcard with explicit list is rejected at config load.
  • CORS layer scoped to /api/*: built into the query API sub-router before merging into the outer router, so OTLP /v1/traces, /metrics and /health are never reachable cross-origin even under wildcard mode. Allowed methods: GET, POST, DELETE, OPTIONS. Allowed headers: Content-Type, X-API-Key (X-User-Id explicitly excluded, the daemon does not enforce it). Access-Control-Max-Age: 120 seconds. No Access-Control-Allow-Credentials.
  • Cross-section config validation: daemon_api_enabled = false + cors_allowed_origins != [] is rejected at config load with an actionable error pointing at both knobs. Catches the silent "why isn't ack working post-deploy" trap.
  • Dynamic Content-Security-Policy built per render. Static mode keeps the 0.5.22 directive verbatim. Live mode appends connect-src 'self' <daemon_url>. The URL passes validate_url upstream so CSP-breaking bytes cannot reach the meta tag.
  • Compile-time invariant on STATIC_CSP via const _: () = { ... while ... assert!(...) } block, enforcing that the static prefix never contains a {{ byte sequence that could shadow placeholder substitution. The runtime debug_assert! in inject covers the daemon-URL half.
  • Substitution-order safety in inject (3-step replacen: JSON first, then CSP, then title). User-supplied data cannot shadow static placeholders, locked in by hostile_input_label_with_json_placeholder_does_not_double_substitute and friends.
  • validate_url lifted to pub(crate) in crates/sentinel-cli/src/ack.rs and reused by perf-sentinel report --daemon-url and perf-sentinel query --daemon. The latter previously had ad-hoc validation that accepted userinfo, paths and query strings; the drift is fixed.
  • docs/HTML-REPORT.md (English) and docs/FR/HTML-REPORT-FR.md (French): new reference page covering live mode setup, daemon URL validation, auth flow, security caveats (X-API-Key in sessionStorage, script-src 'unsafe-inline', preflight DoS surface), and an 8-step manual smoke test.
  • [daemon.cors] section in docs/CONFIGURATION.md + FR mirror, documenting the scope-to-/api/* posture, the read-endpoint exposure note, the methods/headers/max-age decisions.
  • Click Ack from HTML report row in the docs/ACK-WORKFLOW.md decision table + FR mirror, completing the three-axis UX overview (TOML / daemon CLI / daemon HTML).
  • 44 new tests across the workspace: 11 CORS layer tests in daemon/listeners.rs::cors_tests, an end-to-end CORS scoping integration test that mirrors the real router topology, 8 CSP / payload tests in report/html.rs, an IPv6 literal test, 8 config validation tests for the new [daemon.cors] section, plus the cross-section consistency check.

Changed

  • Substitution order in inject: JSON is now substituted first (no user-controlled data has been laid down yet, so the only {{REPORT_JSON}} occurrence is the static template marker), then CSP, then title. The previous order would have let a hostile input_label containing {{REPORT_JSON}} shadow the static placeholder once replacen saw the user data laid into the title.
  • tower-http features extended with "cors" (was ["decompression-gzip", "limit"]). No new top-level dependency, just an additional feature on a crate already in the daemon dep tree.
  • Helm chart 0.2.25 to 0.2.26, appVersion 0.5.22 to 0.5.23, default daemon image tag points at ghcr.io/robintra/perf-sentinel:0.5.23. The artifacthub.io/images annotation is updated in lockstep.

Behavior

  • Static HTML mode preserved byte-for-byte. A perf-sentinel report --input X --output Y call without --daemon-url produces the same artifact as 0.5.22 (modulo the now-templated CSP value, which renders to the identical string in static mode).
  • No HTTP-shape change on the daemon API. The three ack endpoints, the /api/findings, /api/acks, /api/status, /api/correlations, /api/explain/*, /api/export/report routes all keep their 0.5.21/0.5.22 status codes and JSON shapes. The CORS layer is purely additive.
  • CORS layer absent by default. Empty cors_allowed_origins means no Access-Control-Allow-Origin header is emitted on responses. The loopback-only posture pre-0.5.23 is preserved for operators who do not opt in.
  • CORS layer scoped to /api/*. Even under wildcard mode, OTLP /v1/traces, Prometheus /metrics and the /health probe never echo CORS headers. A browser cannot post traces or scrape metrics regardless of allowed_origins.
  • Read-endpoint exposure documented. Whitelisting an origin grants browser-side read access to every finding, ack, and trace export the daemon retains. The new [daemon.cors] doc section calls this out explicitly so operators do not whitelist origins they would not let curl localhost:4318/api/findings.
  • CSP connect-src 'self' <url> in live mode. Same-origin assets allowed (file:// gives 'self' = null, https origin gives the document host), plus the validated daemon URL.
  • X-API-Key in sessionStorage only. Cleared at tab close. Never localStorage. Never the DOM. Never logged. The template_does_not_leak_session_storage_to_local_storage test enforces the rule.
  • Stale-key recovery on rotation. A 401 from /api/status purges the cached key and sets the badge to Authentication required. The next write call opens the auth modal with a clean slate.
  • CORS preflight DoS surface documented. OPTIONS preflight short-circuits before the X-API-Key check, so a rogue origin in the whitelist (or any origin under wildcard mode) can spam preflights past the auth boundary. The max_age = 120s cap reduces volume from legitimate browsers but does not help against a malicious script. Mitigation posture for 0.5.23 is to deploy the daemon behind a reverse proxy with per-IP rate limiting (nginx limit_req, Caddy rate_limit, Cloudflare WAF) when exposing it cross-origin. A native tower-governor integration is tracked for a future release.

Documentation

  • New docs/HTML-REPORT.md covering live mode setup, the CORS prerequisite, the auth flow, the security caveats, and an 8-step manual smoke test (start daemon with CORS, generate live HTML, click Ack, click Revoke, restart daemon with [daemon.ack] api_key, exercise the auth modal, reload tab, verify session persistence).
  • docs/CONFIGURATION.md gains a [daemon.cors] allowed_origins section with the scope-to-/api/* documentation, the per-method allow-list, and the explicit caveat that read endpoints stay unauthenticated under CORS.
  • docs/ACK-WORKFLOW.md decision table extended with a third option ("Click Ack from HTML report"), placing it alongside the TOML and CLI options.
  • French mirrors at docs/FR/HTML-REPORT-FR.md, docs/FR/CONFIGURATION-FR.md (extended), docs/FR/ACK-WORKFLOW-FR.md (extended). Same content with French accents and prose conventions.

Install

Prebuilt binaries (Linux amd64 / arm64, macOS arm64, Windows amd64):

curl -LO https://github.com/robintra/perf-sentinel/releases/download/v0.5.23/perf-sentinel-linux-amd64
chmod +x perf-sentinel-linux-amd64
sudo mv perf-sentinel-linux-amd64 /usr/local/bin/perf-sentinel

Linux binaries are statically linked against musl and run on any distribution (Alpine, Debian, RHEL, Ubuntu any version) regardless of glibc version, and inside FROM scratch images.

From crates.io:

cargo install perf-sentinel --version 0.5.23

Docker:

docker run --rm -p 4317:4317 -p 4318:4318 \
  ghcr.io/robintra/perf-sentinel:0.5.23 watch --listen-address 0.0.0.0

Helm chart 0.2.26 ships alongside, see the matching chart-v0.2.26 release for the chart-side details.

Full Changelog: v0.5.22...v0.5.23

Don't miss a new perf-sentinel release

NewReleases is sending notifications on new releases.