Repository navigation
fix(grafana_alloy): redact URLs in journald lines before they ship to Loki (#84) - #85
Merged
Merged
Conversation
… 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>
This was referenced Oct 5, 2026
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
JSON-escaped URLs bypass the redactor and can still expose credentials to Loki.
Review effort: Balanced
Findings: 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.
…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>
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Closes #84.
What
grafana_alloyused 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:A new
loki.process "redact_urls"stage now runs first on every unit's line:It is two
stage.replacestages, matched on://or JSON's:\/\/(any scheme, any case):<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/ycomes out ashttps://<redacted>@x/<redacted>./<redacted>, whichever separator it was.So
https://u:p@rpc.example.io/v2/KEY?x=ybecomeshttps://<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'sencoding/json, PHP'sjson_encodeand 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:
\u002fseparators, fully percent-encoded URLs, deeper JSON nesting, a URL that journald splits across records (lines over 48K), aDebug-printedurl::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 throughdecdn_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_urlandcrates/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:wss://,HTTP://with only a query);#,/or@, a fragment straight after the host, IPv6 with userinfo, userinfo with no path, and an@in the path;\/separators,\u0026in 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 itslevellabel proves the redacted JSON still parses;@ # ? :without a URL; a URL with nothing after its host; plain decdn-node JSON;SECRET-*marker may come through, in any of the four harness configurations;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:
SECRET-*;daemon_level, or the path stage removed, the tests fail.Local runs:
make lint,make lint-ansible,make lint-alloy,make test-scriptsandmolecule test -s grafana-cloudall pass.Rollout
config.alloychanges 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
otelcol.*, notloki.process. decdn-node already sanitizes at its tracing sites.strip_urls-style). The Alloy stage only catches URL-shaped text.🤖 Generated with Claude Code