Repository navigation
Slice 125: the credential filter cannot be switched off by a log line - #102
Merged
Merged
Conversation
… then removed
Eleven failures, committed before any fix.
The defect: redact_msg/1 calls Enum.map/2 on the message body. OTP chardata is
routinely an improper list whose tail is a binary rather than [], Enum.map/2
raises FunctionClauseError on one, and OTP responds to a raising primary filter
by removing it from the handler permanently. Everything logged afterwards is
unredacted and nothing says so.
The first test feeds it the exact event from the live log, improper tail and
all:
msg: {:string, ["Exqlite.Connection", 32, 40, "#PID<0.810.0> (\"db_conn_2\")",
") failed to connect: " | "** (Exqlite.Error) database is locked"]}
** (FunctionClauseError) no function clause matching in Enum."-map/2-lists^map/1-1-"/2
A second defect the property found, which I had guessed at earlier and wrongly
dismissed: an arbitrary binary raises too. redact/1 runs Regex.replace on it and
a binary that is not valid UTF-8 is exactly what key material arrives as.
property "filter/2 returns for an arbitrary term in msg or meta"
** (ArgumentError) argument error, after 92 successful runs
The suite never saw either because it only ever fed the filter proper lists of
valid UTF-8. StreamData's own chardata/0 builds proper lists, so a property over
it would have gone on passing while the filter died in production; the generator
here builds improper tails deliberately.
AC5 is the half the original slice did not cover and is the more important one:
a filter that cannot redact must not be silently dropped. Boot fails, or the
handler is replaced. Never left unfiltered.
Trinity.Application.verify_log_redaction!/0 does not exist yet, so that test
fails undefined.
Signed-off-by: Ayla Croft <aylacroft@proton.me>
… chardata)
The credential-masking Logger filter could be killed by ordinary log events, and
OTP's response to a raising primary filter is to remove it permanently and carry
on. Everything logged afterwards was unredacted while the system believed it was
not.
Two input classes did it, not one.
Improper chardata, which the slice was opened for. redact_msg({format, args})
when is_list(args) caught {:string, iolist}, because an iolist is a list, and
Enum.map/2 raises FunctionClauseError on an improper list. OTP chardata is
routinely improper: a db_connection disconnect produces one, and so does an
Exqlite.Error at boot.
Binaries that are not valid UTF-8, which the property found after 92 runs.
redact/1 runs Regex.replace/3 on any binary and that raises. I had this
hypothesis at the start of the session and threw it away on a bad test: I
checked redact/1 against one 8-byte invalid binary, saw it return, and concluded
UTF-8 was not the problem. Three hand-picked inputs are not a population. This
is the more serious of the two, because raw bytes are the shape key material
most often takes, so the filter raised on exactly the input class most likely to
be a secret, and failed in the direction that disabled redaction entirely.
The fix:
- {:string, chardata} is flattened rather than walked. The clause sits before
{format, args}, which would otherwise catch it.
- redact_list/1 replaces Enum.map/2 wherever a list may be improper, keeping the
shape and redacting the tail rather than walking off the end of it.
- An invalid binary is replaced rather than matched, keeping its byte size so
the line still says something.
- filter/2 wraps its own work and degrades to an event whose content is
withheld, keeping only metadata that cannot carry a credential.
- Trinity.Application.verify_log_redaction!/0 reads the primary filters from the
logger and refuses to boot without this one. install_log_redaction/0
previously swallowed every error including a failure to install, so an
application that could not redact started anyway and said nothing.
The assertion that matters is end to end, because calling filter/2 directly is
not how it dies: OTP calls it, OTP catches the raise, OTP removes it. Against
the pre-fix module that test reports "OTP removed the filter after logging
improper chardata", and a companion test asserts the line OTP did log was
masked, so "still installed" cannot be satisfied by a filter that no longer
redacts.
Confirmed on a running server, which is how both of this session's defects were
found: zero removed_failing_filter across a full approval turn, against a
pre-fix run where the filter was removed at boot.
Note for the next property written here: StreamData's own chardata/0 generates
proper lists only, so a property written the obvious way would have gone on
passing while the filter died in production. The generator in this slice builds
improper tails deliberately.
Signed-off-by: Ayla Croft <aylacroft@proton.me>
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.
The credential-masking Logger filter could be killed by ordinary log events, and the way it failed was the worst available: OTP removes a primary filter that raises, permanently, and carries on. Every line written after that point was unmasked, and nothing anywhere said so.
Found on a running server, twice, including at boot. Never by a test.
Two defects, not one
Improper chardata, which the slice was opened for
redact_msg({format, args}) when is_list(args)caught{:string, iolist}, because an iolist is a list, andEnum.map/2raisesFunctionClauseErroron an improper list. OTP chardata is routinely improper. The event from the live log,|and all:A
db_connectiondisconnect produces one. So does anExqlite.Errorat boot.Binaries that are not valid UTF-8, which the property found
redact/1runsRegex.replace/3on any binary, and that raises on invalid UTF-8. The property failed after 92 successful runs.I had this hypothesis at the start of the session and threw it away on a bad test. I checked
redact/1against a single 8-byte invalid binary, saw it return, and concluded UTF-8 was not the problem. Three hand-picked inputs are not a population, and the check I wrote for a claim about all binaries used one binary.It is the more serious of the two. Raw bytes are the shape key material most often takes, so the filter raised on exactly the input class most likely to be a secret, and failed in the direction that disabled redaction entirely.
The fix
{:string, chardata}is flattened rather than walked, which handles improper tails, nesting, charlists and codepoints in one step. The clause sits before{format, args}, which would otherwise catch it.redact_list/1replacesEnum.map/2wherever a list may be improper, keeping the shape and redacting the tail rather than walking off the end of it.filter/2wraps its own work and degrades to an event whose content is withheld, keeping only metadata that cannot carry a credential. Losing a line's detail is recoverable; running unfiltered is not.Trinity.Application.verify_log_redaction!/0reads the primary filters from the logger and refuses to boot without this one. The previousinstall_log_redaction/0swallowed every error including a failure to install, so an application that could not redact its logs started anyway and said nothing.The test that matters is end to end
Everything else calls
filter/2directly, and that is not how it dies: OTP calls it, OTP catches the raise, OTP removes it. So one test logs improper chardata through the real logger and asserts the filter is still installed afterwards.Red against the pre-fix module, restored from
HEADand re-run:A companion test asserts the line OTP did log was masked, so "still installed" cannot be satisfied by a filter that no longer redacts.
A note for the next property written here
StreamData's own
chardata/0generates proper lists only. A property written the obvious way would have gone on passing while the filter died in production. The generator in this slice builds improper tails deliberately.Confirmed on a running server
Which is how both of this session's defects were found, so the fix is confirmed the same way rather than only in the suite:
Against a pre-fix run of the same script where the filter was removed at boot.
For the reviewer
[REDACTED-BINARY n bytes]is a judgement call worth confirming. An invalid binary is now replaced outright, which makes a legitimately binary log payload disappear. I think that is right for a filter whose job is to stop key material reaching disk; NOTES.md records the alternative.verify_log_redaction!/0raises at boot. Confirm that refusing to start is what you want over starting with a loud warning.