Repository navigation
fix(brokerurl): mask user, password, query values and decoded forms without residue (#159) - #213
Conversation
…hort values (#159) Table tests for the examples in #159: a user name or password quoted on its own, a decoded password (p%40ss -> p@ss), a query value on its own, overlapping secrets (abcd + cdef on abcdef), and 1-2 character values that must leave unrelated text alone. Plus the watchdog force-reconnect log line from the issue comment, with user:pass@ and ?token= in the error. Red on master (MaskSecrets and the secrets argument of buildForceReconnectFn do not exist yet). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ithout residue (#159) Secrets now also returns the user name and password, each query and fragment value, each raw and %-decoded (path and query forms), without duplicates or empty values. The new MaskSecrets merges the match intervals of all secrets (overlapping matches of one secret included) and replaces each merged interval once, so overlapping secrets leave no residue, and skips secrets shorter than MinSecretLen (3 runes) so a 1-2 character user name or password no longer masks unrelated text. errForLog is now MaskText(MaskSecrets(...)). The watchdog's forced reconnect logs Connect()'s error through errForLog with the source's secrets, like every other connect path (issue comment). Mask and MaskText are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#213 #159 — head be93b89Status: All acceptance criteria met. Local Go suites, test-all.sh and CI are green, and all 10 mutants were killed. The PR is still a draft and has not been merged or marked ready. Evidence tags: [T] test or CI, [A] analysis, [K] known, not re-run. Commits
Acceptance criteria
Design
Tests
Mutants (all red with the mutant, green on the code) [T]
CI per job (run 37191084773, head be93b89) [T]
Guards [A]
Open items
|
Review — CS-Macmini PR#213 brokerurl-masking — head be93b89Dom: APPROVE med nits. F1 is small and worth fixing before the PR goes ready. Evidence tags: [T] test I ran, [A] analysis or code reading, [K] known from the author's report or CI and not re-run by me. Reviewed head Findings
1. Security and correctness of the masking
Attack strings (
|
| Case | Broker | Result |
|---|---|---|
Two-layer % |
tcp://dev-user:p%2540ss99@h |
p%40ss99 and p%2540ss99 are masked. A doubly decoded p@ss99 leaves ss99 (F2). |
+ vs %20 |
?token=ab+cd%20ef |
ab cd ef, ab+cd ef and ab+cd%20ef are masked. A mixed form, ab cd%20ef, is not, and I consider it unrealistic. |
| Unicode | tcp://brügér:pæsswørd@h |
Raw values are masked. The standalone re-encoded form is not (F2). |
| Encoded unicode in the URL | tcp://u1x:p%C3%A6ssw%C3%B8rd@h |
Both the raw and the decoded form are masked. |
| 12 000-character secret | tcp://u1x:<Zq9×4000>@h |
x **** y |
| Secrets that are substrings of each other | user abcdef, password bcd, ?k=cdefgh |
abcdefgh bcd cdefgh xbcdx → **** **** **** x****x |
| Self-overlap | password abab |
abababab → ****, ababa → ****a (the a is not part of a secret) |
Password with : |
dev-user:pa:ss:wd |
Both parts are masked. |
Password with @ |
dev-user:p@ssw0rd |
Masked. |
Password with # |
dev-user:hun#ter2 |
Masked. |
%q-quoted URL containing " |
dev-user:hun"ter2 |
parse "tcp://****@h": invalid |
| Empty user | tcp://:hunter2@h |
Masked. |
| Empty password | tcp://dev-user:@h |
Masked. |
| Both empty | tcp://:@h |
Masked. |
| IPv6 host | tcp://dev-user:hunter2@[fe80::1%25en0]:1883 |
URL, user and password are masked, and the host is kept. |
| Fragment value | tcp://h#k=fragsecret |
Masked. |
@ in query, path or fragment |
see F1 | The password and query token leak. |
| Short values | tcp://u:pw@h on uptime 1h, pw ok, u2 |
Unchanged. |
2. No over-masking
- Values shorter than 3 runes leave unrelated text alone, and the rune count is used rather than the byte count. [T]
- Common words of 3 or more runes that appear in the query or the user name get masked (F3). This is cosmetic, and I judge a minimum length of 3 the right trade-off. [T][A]
MaskandMaskTextare untouched in the diff, andTestMask,TestMaskTextandTestMaskIsIdempotentpass. [A][T]
3. Call sites (grep on the merged tree) [A]
| Path | Through errForLog with secrets |
Test or mutant |
|---|---|---|
Initial connect failure, main.go:192 |
Yes | No unit test (it is in main()) |
Watchdog force-reconnect, main.go:589 |
Yes | M4 killed |
Wiring in main(), main.go:173 |
Yes | M5 survives (F7) |
Connection lost, logged, mqtt_source.go:59 |
Yes | M9 killed |
Connection lost, stored lastError, mqtt_source.go:60 → source_status.go:83 |
Yes | M6 killed |
Subscribe error, mqtt_source.go:51 |
No, raw token.Error() |
Not in scope. In paho v1.5.0 these errors are constant strings that never quote the URL. |
Connection-attempt log, main.go:523 |
brokerForLog (Mask) |
Older than this PR |
- On the server side,
/api/mqtt/statusreadslastErrorfrom the ingestor stats file and also appliesMaskText(cmd/server/mqtt_status.go:185). The value the server reads was already masked byerrForLogin the ingestor (MarkDisconnect).MarkConnectclears it.MarkDisconnectis the only place that writeslastError, so the server never needsSecrets. [A] - A stats file written by an older ingestor would carry only the older masking. That is out of scope. [A]
4. Performance
Secretsruns once per source at startup (twice, see F6).MaskSecretsruns only on the error paths listed above. Typical cost is O(k·n). Measurements are under F5. There is no hot path and no allocation storm. [T][A]
5. Tests and mutants
internal/brokerurl:go test -race -count=1 ./...passed on the merged tree. [T]cmd/ingestor:go test -race -count=1 ./...on the merged tree, run once (go1.27.0 darwin/arm64):ok github.com/corescope/ingestor 407.805s(6m50s wall, 0DATA RACE). [T]- CI run 37191084773 is green. [K]
- Mutants, applied one at a time to a copy of the merged tree, with
internal/brokerurland the ingestor tests matchingErrForLog|_159|_118|Disconnect|ForceReconnect, without-race. [T]
| Mutant | Result |
|---|---|
M1: decoded returns the raw form only |
Killed (TestSecrets, TestMaskSecrets_159, …NoResidue_159, TestErrForLogMasksSecretParts_159) |
M2: interval merge without max (end not extended) |
Killed (TestMaskSecrets_159, TestErrForLogMasksSecretParts_159, TestErrForLogMasksKnownSecrets_118) |
M3: MinSecretLen 3 → 1 |
Killed (TestMaskSecrets_159, TestErrForLogMasksSecretParts_159) |
M4: force-reconnect errForLog(token.Error()) without secrets |
Killed (TestBuildForceReconnectFnMasksConnectError_159) |
M5: main() passes no secrets to buildForceReconnectFn |
Survives (F7) |
M6: MarkDisconnect without secrets |
Killed (TestDisconnectErrorMasksKnownSecrets_118) |
M7: QueryUnescape form dropped |
Killed (TestSecrets, TestMaskSecrets_159) |
| M8: fragment parts dropped | Killed (TestSecrets, TestMaskSecrets_159) |
| M9: connection-lost log without secrets | Killed (TestDisconnectErrorMasksKnownSecrets_118) |
My first mutant pass also failed TestConfigExampleHasNoLiteralClientID_118 in every run. That was an artifact of my partial copy, which lacked ../../config.example.json. With the file in place the baseline is green, and the results above are from the corrected setup. [T]
6. Rules [A]
- Changed files:
cmd/ingestor/{main.go, mqtt_client_id.go, mqtt_credentials_159_test.go}andinternal/brokerurl/{brokerurl.go, brokerurl_test.go}. Only the expected packages are touched, andcmd/serverand.github/are unchanged. - The diff adds no
map[string]interface{}and nointerface{}at all. - Fork guard
github.repository == 'Kpa-clawbot/CoreScope'appears 9 times indeploy.ymland once inrelease-fast-path.yml, at head and on the merged tree. - The title, body and both commit messages have no closing keywords (
Relates to #159). - Both commits (
4732887b,be93b891) have author and committerdborup <kontakt@meshview.dk>.
Known conflict with #210 (cmd/ingestor/main.go, buildForceReconnectFn)
git merge-tree of #210 head c9f9f693 with this head reports CONFLICT (content) in cmd/ingestor/main.go. #210 rewrites the function body as err := client.Connect().Error() with a switch. The resolution must keep:
- the
secrets ...stringparameter ofbuildForceReconnectFn, andbuildForceReconnectFn(client, tag, secrets...)inmain(), using the hoistedsecrets := mqttSourceSecrets(source); - the classification
connectRetryInProgress(client, err)on the rawerr, since masking could change the text it matches; - the
default:log line as… Connect() failed: %s", tag, errForLog(err, secrets...), not%vof the rawerr; - the initial connect line as
errForLog(token.Error(), secrets...).
After the resolution, TestBuildForceReconnectFnMasksConnectError_159 and #210's own force-reconnect tests must both pass. The variadic parameter keeps #210's two-argument calls compiling.
Not verified
- The
main()wiring at runtime: I ran no live broker and no live ingestor. The wiring is checked by reading the code only (F7). - Which error strings paho and Go's
net,tlsandwebsocketlayers actually produce, beyond the paho subscribe errors I checked. Whether any of them double-decodes or re-encodes credentials (F2) is analysis only. cmd/servertests,test-all.shand E2E: I did not re-run them becausecmd/serveris untouched. [K] CI green.- Browser: there is no UI change.
- The resolution of the conflict with fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210, which will be checked separately.
… unwired secrets (#159) Review round 1 of #213: - F1: an '@' in the query hides the password and the query values from Secrets (tcp://dev-user:hunter2@broker.example:1883?mail=a@b with "bad password hunter2", tcp://h?token=abc123&mail=a@b with "token abc123 expired"). - F4: a decoded form that is not valid UTF-8 (%A6abc) matches inside a rune and leaves a broken byte. - F6/F7: the per-source setup main() uses returns the secrets once and wires the watchdog's forced reconnect with them (attachClient). Red at be93b89: assertions fail in internal/brokerurl, and cmd/ingestor does not build (prepareMQTTSource returns no setup yet). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main() is not unit-testable, so a source-text guard (as for the route mask backfill) pins its share of the wiring: prepareMQTTSource, then setup.attachClient(client) before the first Connect(), and the initial connect error logged with setup.secrets. Red at fe40aae. Kills the review's mutant M5 and its variants, and guards the coming merge with #210 in the same loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… UTF-8 decodes (#159) Review round 1 of #213: - F1: Secrets reads the URL twice, with the user-info ending at the last '@' (as Mask does) and at the last '@' before the first '/', '?' or '#' (RFC 3986), so an '@' in the path, query or fragment no longer hides the password or the query values. Mask is unchanged. - F4: a %-decoded form that is not valid UTF-8 is dropped. - F2: Secrets documents that only one layer is decoded and nothing is re-encoded. - F6/F7: prepareMQTTSource returns an mqttSourceSetup carrying the secrets, computed once; attachClient wires the watchdog's connected check and forced reconnect with them, and main() logs the initial connect error with setup.secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#213 runde 2 — head d9b8ebaStatus: Review feedback round 1 addressed. F1, F4, F6 and F7 are fixed with tests, F2 is documented, and F3 and F5 are accepted without change. Every new mutant is killed, all local suites are green and CI is green. The PR is still a draft. Review feedback addressed (commit Evidence tags: [T] test or CI, [A] analysis, [K] known, not re-run. Commits in this round:
Tests (local, head d9b8eba) [T]
Rules [A]
CI per job (run 37196096684, head d9b8eba) [T]
Open items
|
Conflict in cmd/ingestor/main.go buildForceReconnectFn, resolved by keeping both sides: - from #210: the doc comment on paho's Connect() statuses, the switch on err with connectRetryInProgress, and the retry-pending info line; - from #213: the secrets ...string parameter, and errForLog(err, secrets...) in both the retry-pending and the Connect() failed line. connectRetryInProgress still reads the raw error, whose text it compares with paho's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#159) #210 added a retry-pending info line next to the Connect() failed line. The test drives it with paho's status error and IsConnected()=true, and a configured password that occurs in that text, and asserts the line is masked and still classified as retry-pending (classification reads the raw error). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#213 master-merge — head 120c1e3Status: Merged master (commit Evidence tags: [T] test or CI, [A] analysis, [K] known, not re-run. Commits
Conflict resolution (
|
Review — CS-MacBook PR#213 runde 2 + master-merge — head 120c1e3Dom: APPROVE Evidence tags: [T] test or probe I ran, [A] analysis or code reading, [K] known from the author's report or CI and not re-run by me. Reviewed head Findings
No finding blocks merge. Nothing from round 1 is reopened. 1. Conflict resolution in
|
| Element | Source | Preserved at head |
|---|---|---|
Full doc comment on paho's Connect() statuses and on IsConnected() |
#210 | Yes, byte-identical, plus one added paragraph saying that both lines are masked and that the classification reads the raw error |
err := client.Connect().Error() and switch { case err == nil … } |
#210 | Yes |
case connectRetryInProgress(client, err) on the raw error |
#210 | Yes. The raw err is passed. Mutant R2 (classification on the masked error) is killed |
Retry-pending info line text (… Connect() returned %s; paho reports a retry pending (IsConnected=true), not starting a new attempt) |
#210 | Yes. Only %v/err became %s/errForLog(err, secrets...) |
Connect() failed line |
#210 | Yes, masked |
secrets ...string parameter |
#213 | Yes |
errForLog(err, secrets...) in both log lines |
#213 | Yes. Mutants R1 and R3 are killed |
- fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210's two-argument calls
buildForceReconnectFn(client, tag)still compile, because the parameter is variadic. fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210's own tests are unchanged in the diff against master. [A] - Other places where fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210 and fix(brokerurl): mask user, password, query values and decoded forms without residue (#159) #213 meet. fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210 changed these non-test files in the ingestor:
main.go(only this function) andmqtt_watchdog.go(newAsyncEmitmutex andstoppedflag, and two comments). I read themqtt_watchdog.godiff in full: it adds no log line that carries an error text. The only watchdog line emitted after the call is the constantWATCHDOG reconnect attempt issued. fix(ingestor): quiet watchdog retry noise and make force-reconnect shutdown-safe (#102, #103) #210'scmd/serverchanges (store.go,hash_migrate.go, tracked-bytes tests) do not touch the broker URL. I also listed everylog.Printfin the merged ingestor MQTT files that prints an error: the remaining raw ones are the pre-existing DB/prune/decode lines and the subscribe line (N2). None of them can quote a broker URL, and git did not auto-merge any new one past the masking. [A]
2. Round 2
- F1 (authority reading) [T]. I ran each case through
MaskText(MaskSecrets(text, Secrets(broker)...)):
| Broker | Text | Result |
|---|---|---|
tcp://dev-user:hunter2@broker.example:1883?mail=a@b |
bad password hunter2 for dev-user |
bad password **** for **** |
tcp://h?token=abc123&mail=a@b |
token abc123 expired |
token **** expired |
tcp://dev-user:hun/ter2@h:1883 |
bad password hun/ter2 / ter2 hun |
bad password **** / ter2 hun (the whole password is masked, unrelated text is kept) |
wss://dev-user:hunter2@h/p@x?token=abc123&mail=a@b#frag=zzz999 (@ in path and query) |
hunter2 abc123 zzz999 dev-user |
**** **** **** **** |
tcp://u1x:p%40ss99@h |
auth p@ss99 p%40ss99 |
auth **** **** |
tcp://u1x:pw@h?k=v1x2 |
uptime 1h pw ok v1x2 |
uptime 1h pw ok **** (short pw untouched) |
Both readings are needed: removing either one is killed (mutants F1 and F1b below). Also see N1.
- F4 (invalid UTF-8) [T].
Secrets("tcp://u1x:%A6abc@h")returns["u1x:%A6abc" "u1x" "%A6abc"], with no decoded"\xa6abc", anduser æabc herestays unchanged. Removing theutf8.ValidStringcheck is killed byTestSecretsandTestMaskSecrets_159. - F7 (wiring) [T].
main()now callssetup.attachClient(client), andTestAttachClientWiresSecretsToForceReconnect_159runsprepareMQTTSource→attachClient(fake)→ForceReconnectFn()and asserts a masked line. Macmini's M5 (watchdog wired without the secrets) is now killed, both in itsattachClientform (my mutant M5) and byTestMainWiresSourceSecrets_159, which also fails when the initial connect log drops the secrets (mutant W) or whenmain()assignsForceReconnectFnitself.attachClientusess.liveness.Tag, whichprepareMQTTSourcesets from the sametag, so the log prefix is unchanged. [T][A] - F6 is done as asked:
mqttSourceSecretsis computed once inprepareMQTTSourceand carried inmqttSourceSetup. F2 is documented onSecrets, and F3 and F5 are accepted as in round 1.
3. Tests and mutants
internal/brokerurl:go test -race -count=1 ./...passed on the merged treeb7c4bed5(origin/master376d51c8plus head). [T]cmd/ingestor:go test -race -count=1 -timeout 40m ./...on the merged tree, run once, go1.26.3 darwin/arm64:ok github.com/corescope/ingestor 382.007s, 0DATA RACE. [T]- CI run 37198838508 on head was green (Go Build & Test, Playwright E2E, Docker image). [K]
cmd/serverandtest-all.shwere not re-run:cmd/serveris not touched by this PR, and the author reports both green. [K]- My own mutants, applied one at a time to a copy of the merged tree, run against
internal/brokerurl(full) and the ingestor tests matching_159|_118|ForceReconnect|Disconnect|ErrForLog|Watchdog, without-race. I confirmed each one changed the file and compiled. [T]
| Mutant | Result |
|---|---|
R1: retry-pending line logs the raw err |
Killed by TestBuildForceReconnectFnMasksRetryPendingLine_159 |
R2: connectRetryInProgress is given the masked error |
Killed by TestBuildForceReconnectFnMasksRetryPendingLine_159 |
R3: Connect() failed line logs the raw err |
Killed by TestBuildForceReconnectFnMasksConnectError_159 and TestAttachClientWiresSecretsToForceReconnect_159 |
F1: Secrets reads only the conservative split |
Killed by TestSecrets, TestMaskSecrets_159, TestErrForLogMasksSecretParts_159 |
F1b: Secrets reads only the RFC 3986 authority split |
Killed by TestSecrets |
F4: utf8.ValidString check removed from decoded |
Killed by TestSecrets, TestMaskSecrets_159 |
M5: attachClient calls buildForceReconnectFn without the secrets |
Killed by TestAttachClientWiresSecretsToForceReconnect_159 |
W: initial connect log in main() without setup.secrets |
Killed by TestMainWiresSourceSecrets_159 |
S: MinSecretLen 3 → 1 |
Killed by TestMaskSecrets_159, TestErrForLogMasksSecretParts_159 |
All nine were killed; none survived.
4. Rules [A]
- Fork guard
github.repository == 'Kpa-clawbot/CoreScope': 9 times indeploy.ymland once inrelease-fast-path.ymlon the merged tree..github/has no diff against master. - No new
map[string]interface{}and nointerface{}at all in the added lines. - No closing keywords in the PR title, the body or any of the seven commit messages (the body says
Relates to #159). - All seven commits have author and committer
dborup <kontakt@meshview.dk>. The merge commitd8a84c6bhas parentsd9b8ebaeanda0086bdd; no rebase or force-push is visible in the history. - The diff against
origin/master(376d51c8) contains only the PR's eight files:cmd/ingestor/{main.go, mqtt_client_id.go, mqtt_credentials_159_test.go, mqtt_credentials_r3_118_test.go, mqtt_source.go, mqtt_status_credentials_118_test.go}andinternal/brokerurl/{brokerurl.go, brokerurl_test.go}.cmd/serveris untouched, and there are no DB writes. - No IP addresses, real credentials or infrastructure details in the diff.
Not verified
main()at runtime. I ran no live broker or ingestor. The wiring is covered byattachClient's test and by the source-text guard (N3), not by runningmain().- What paho and Go's net/tls/websocket layers actually put in connect errors. The masking is checked against synthetic error text only.
cmd/servertests,test-all.shand E2E: not re-run (see above). There is no UI change, so no browser check.- CI on the merged-with-current-master tree: CI ran on head
120c1e31only.
Relates to #159
Summary
Free-text masking of broker credentials (
errForLogin the ingestor) relied onbrokerurl.Secrets, which returned only the whole user-info, the whole query and the whole fragment. Callers then replaced those exact strings, longest first. This PR addresses the three gaps in #159 and the masking gap reported in the issue comment:Secretsnow also returns the user name and the password (split at the first:), each query value and each fragment value. Every part comes raw and, where it differs, %-decoded both as a path and as a query (+decodes to a space). Duplicates and empty values are left out.brokerurl.MaskSecrets(s, secrets...)collects the match intervals of all secrets, including overlapping matches of a single secret. It merges intervals that overlap or touch, and replaces each merged interval with one****in a single pass. A marker is never scanned again.MaskSecretsskips secrets shorter thanMinSecretLen = 3runes, and the rule is documented on the constant. A URL that holds such a value is still masked whole byMask/MaskText. A longer secret that contains it (such as the user-infou:pw) is still masked byMaskSecrets.buildForceReconnectFntakes the source's secrets and logsConnect()'s error througherrForLog, like every other connect path.main()computesmqttSourceSecrets(source)once per source and passes it to both connect paths.MaskandMaskTextare unchanged.errForLogis nowMaskText(MaskSecrets(err, secrets...)). The server never callsSecrets; it only appliesMask/MaskTextto the stats file, so its behaviour does not change.Plan
Secretsparts and decoded forms,MaskSecretswith interval merging andMinSecretLen,errForLogon top of it, and the watchdog log line.internal/brokerurl,cmd/ingestor,cmd/server) andsh test-all.sh.Tests
internal/brokerurl/brokerurl_test.goTestSecretsis extended: user and password,p%40ss→p@ss, query values with+and%21, fragment values, an empty user, and a bad escape.TestMaskSecrets_159covers the five examples from fix(brokerurl): free-text masking misses user/pass/query parts, decoded forms and overlapping secrets #159 plus self-overlap (aaaonaaaa), adjacency, containment, multibyte length and a marker that is not re-scanned.TestMaskSecretsThenMaskTextLeavesNoResidue_159: thep@ss→ssresidue.TestMaskSecretsKeepsMaskOutput_159: full-URLMaskoutput is unchanged.cmd/ingestor/mqtt_credentials_159_test.goTestErrForLogMasksSecretParts_159runs the issue's examples throughmqttSourceSecrets+errForLog, including a configured password that overlaps the URL user.TestBuildForceReconnectFnMasksConnectError_159forces the watchdogConnect()error path withuser:pass@and?token=in the error, and asserts that neither shows up in the log.TestErrForLogMasksKnownSecrets_118,TestDisconnectErrorMasksKnownSecrets_118and others) pass unchanged.Performance
This is not a hot path.
Secretsruns once per source at startup (mqttSourceSecrets).MaskSecretsruns only when a connect or disconnect error is logged or stored. Its cost is O(k·n) for k secrets over an error of n bytes, plus sorting the matches. The server's request path (Mask/MaskText) is untouched.Notes
map[string]interface{}.cmd/serveris untouched..github/workflows/deploy.ymlis untouched. The fork guardgithub.repository == 'Kpa-clawbot/CoreScope'is still present 9 times.🤖 Generated with Claude Code