Skip to content

fix(grafana_alloy): redact URLs in journald lines before they ship to Loki (#84) - #85

Merged
thiras merged 3 commits into
mainfrom
fix/alloy-redact-journal-urls
Oct 5, 2026
Merged

thiras merged 3 commits into
mainfrom
fix/alloy-redact-journal-urls

Conversation

@thiras

@thiras thiras commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #84.

What

grafana_alloy used to send every journald line to Grafana Cloud Loki unchanged. When a daemon error quoted the RPC URL, the provider API key ended up in Loki. sponsord does this today:

Error: read PaymentPool.usdc(): error sending request for url (https://<provider>/v2/<api-key>)

A new loki.process "redact_urls" stage now runs first on every unit's line:

loki.source.journal → redact_urls → daemon_level → journal_identity → loki.write

It is two stage.replace stages, matched on :// or JSON's :\/\/ (any scheme, any case):

  • userinfo becomes <redacted>. The match runs to the last @ of the whitespace-free token, so a password with unencoded / ? # @ is covered too. The cost is that a URL with an @ in its path loses its host: https://h/users/@x/y comes out as https://<redacted>@x/<redacted>.
  • path, query and fragment become /<redacted>, whichever separator it was.

So https://u:p@rpc.example.io/v2/KEY?x=y becomes https://<redacted>@rpc.example.io/<redacted>. The scheme and host stay, so you can still tell which provider it was. A URL with no userinfo and nothing after its host comes out unchanged, and so does a line with no URL.

The match ends at whitespace, " or \". It may swallow punctuation such as reqwest's closing ), which is better than leaking a key. JSON escapes are read as whole pairs (\u0026, \/, \\), so Go's encoding/json, PHP's json_encode and one level of JSON nested in a JSON string are covered, and the line stays valid JSON for the level stage. The stage has no knob.

Not covered: \u002f separators, fully percent-encoded URLs, deeper JSON nesting, a URL that journald splits across records (lines over 48K), a Debug-printed url::Url, and OTLP (#86).

decdn-node: does it leak too? (question from the issue)

No, by design. Upstream decdn/decdn #954/#958 (2026-06-19, included in the pinned 3ebf5f17) sends chain-RPC errors through decdn_common::redact::{sanitize_err_chain, sanitize_rpc_display}, which remove the whole URL. Tests cover it: crates/node/src/buyer_channel.rs::a_chain_read_warning_does_not_log_the_rpc_url and crates/cli/tests/setup_redacts_rpc_error.rs. sponsord has no equivalent, so it is the confirmed leak. This stage is defense in depth for both daemons and every other unit.

Tests

validate.sh (make lint-alloy) runs the rendered stage in the real pinned Alloy 1.19.2 against sample lines, and asserts the exact output for each:

  • sponsord's reqwest error, decdn-node JSON with userinfo, a port, a path and a fragment, and a line with two URLs (wss://, HTTP:// with only a query);
  • passwords with unencoded #, / or @, a fragment straight after the host, IPv6 with userinfo, userinfo with no path, and an @ in the path;
  • JSON escaping: \/ separators, \u0026 in the query and in the userinfo, a URL quoted inside a JSON string, and one level of nested JSON. The escaped and nested samples sit in decdn-node's stream, so its level label proves the redacted JSON still parses;
  • lines that must not change: no URL; @ # ? : without a URL; a URL with nothing after its host; plain decdn-node JSON;
  • no SECRET-* marker may come through, in any of the four harness configurations;
  • in every rendered config that ships the journal, every loki.source.* must feed the redactor, and each later hop (daemon_level, journal_identity, loki.write) must have exactly one feeder, so nothing can bypass it.

Each check was shown to fail when it should:

  • the earlier expressions leak SECRET-*;
  • a second log source wired past the redactor fails the wiring check;
  • with the source pointed back at daemon_level, or the path stage removed, the tests fail.

Local runs: make lint, make lint-ansible, make lint-alloy, make test-scripts and molecule test -s grafana-cloud all pass.

Rollout

config.alloy changes on every host with Grafana Cloud logs on, so the next deploy restarts Alloy once. This is noted in the CHANGELOG.

Out of scope / follow-ups

🤖 Generated with Claude Code

… Loki (#84)

The journald pipeline sent every line to Grafana Cloud Loki unchanged.
When a daemon error quoted the RPC URL, the provider API key went with
it. sponsord does this today: "error sending request for url (...)".

A new loki.process "redact_urls" stage now runs first on every unit's
line, ahead of the level stages. Two stage.replace stages, matched on
"://", replace userinfo, path, query and fragment with <redacted> and
keep the scheme and host. The redacted part ends at whitespace, '"' or
'\', so decdn-node's JSON lines stay valid. The stage has no knob.

validate.sh now runs the stage in the real pinned Alloy with lines that
carry URLs. It checks the exact redacted text, that no SECRET-* marker
gets through, that both daemons' levels still parse, and that lines
with no URL come out unchanged. It also fails any rendered config whose
journal source skips the redactor.

Closes #84

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

JSON-escaped URLs bypass the redactor and can still expose credentials to Loki.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Adds journald URL-credential redaction to the Grafana Alloy logging pipeline.

Changes:

  • Redacts URL userinfo, paths, queries, and fragments before Loki forwarding.
  • Adds real-Alloy behavioral tests and Molecule wiring assertions.
  • Documents behavior and rollout impact.
File Description
AGENTS.md Records the redaction invariant.
ansible/​galaxy/​CHANGELOG.md Documents the security fix and restart.
ansible/​molecule/​grafana-cloud/​verify.yml Verifies redactor wiring.
ansible/​roles/​grafana_alloy/​README.md Documents redaction behavior.
ansible/​roles/​grafana_alloy/​templates/​config.alloy.j2 Adds the redaction stage.
ansible/​tests/​alloy-config/​validate.sh Tests redaction with real Alloy.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ansible/roles/grafana_alloy/templates/config.alloy.j2 Outdated
thiras and others added 2 commits October 5, 2026 14:10
…actor

Both stages looked for a literal "://", and the redacted part stopped at
any backslash. So JSON written by other units could still ship
credentials. PHP's json_encode writes https:\/\/host\/..., which matched
neither stage, so the whole URL went through. Go's encoding/json
escapes '&' as &, which stopped the redacted part mid-query and
let the rest of the query through.

The separators may now be '\/', and the redacted part runs across
every JSON escape except \" and \\. Those still end it, so the line
stays valid JSON.

validate.sh adds a sample for each case (escaped slashes, &, and a
URL quoted inside a JSON string). Each sample fails on its own when its
half of the fix is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR review found three shapes that still shipped credentials to Loki.
All three were reproduced in the real Alloy 1.19.2:
- A password with an unencoded / ? or # stopped the userinfo stage,
  so the username and the start of the password shipped:
  postgres://u:pa#ss@db/app came out as postgres://u:pa/<redacted>.
- A JSON escape in the userinfo (Go's &) stopped both stages,
  and the whole URL shipped, path key included.
- JSON nested in a JSON string (\\u0026) cut the query short, so the
  rest of it shipped.

The userinfo stage now runs to the last @ of the token, and every part
of the match reads JSON escapes as whole pairs; only \" ends the match.
The trade-off: a URL with an @ in its path loses its host
(https://<redacted>@x/<redacted>). Losing information beats leaking a
key.

validate.sh:
- adds samples for each leak, plus the existing shapes nothing tested
  yet (@ in a password, a fragment straight after the host, IPv6 with
  userinfo, userinfo with no path, a line with no URL but @ # ? :);
- puts the escaped and nested samples in decdn-node's stream, so its
  level label proves the redacted JSON still parses;
- now fails any config where a loki.source.* bypasses the redactor or
  a later hop has a second feeder;
- renames the unchanged helper to entry_is, since it also checks
  redacted output.

Docs:
- Restore the header comment to its main text, so config.alloy changes
  only on hosts with logs on, as the CHANGELOG says.
- Move the README bullet into its own section, out of the cost
  guardrails.
- State the limits instead of overclaiming: sponsord does not redact
  at the source; / escapes, percent-encoded URLs, deeper nesting,
  journald line splits and OTLP (#86) are not covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thiras
thiras merged commit 4499e5c into main Oct 5, 2026
16 checks passed
@thiras
thiras deleted the fix/alloy-redact-journal-urls branch October 5, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

grafana_alloy: journald lines that quote the RPC URL (and its API key) ship to Grafana Cloud Loki unredacted

2 participants