@@ -17,6 +17,7 @@ package redaction
1717
1818import (
1919 "context"
20+ "encoding/json"
2021 "regexp"
2122 "strconv"
2223 "strings"
@@ -42,6 +43,7 @@ const (
4243 fakeAWSKey = "AKIA" + "4G7TI63VCBIRS4GW"
4344 fakeAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn"
4445 fakeGitHubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf"
46+ fakeJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiJmYWtlLXVwbG9hZCIsImV4cCI6MTc5MDAwMDAwMH0" + ".c2lnbmF0dXJlLWZha2UtZm9yLXJlcHJv"
4547)
4648
4749var fakeAnthropicKey = "sk-ant-api03-" + strings .Repeat ("a" , 93 ) + "AA"
@@ -279,6 +281,101 @@ func TestRedactCredentialInURIConverges(t *testing.T) {
279281 assert .False (t , report .Changed ())
280282}
281283
284+ // TestRedactJWTKeepsSurroundingText covers a JWT that is followed by a JSON
285+ // escape sequence in its string leaf. The jwt rule allows a backslash in its
286+ // last segments, so the scanner reports the secret with the backslash that
287+ // starts the escape. Only the JWT must be replaced: the text around it, such as
288+ // the host of a presigned URL, is what lets policies tell a short-lived URL
289+ // signature from a leaked credential.
290+ func TestRedactJWTKeepsSurroundingText (t * testing.T ) {
291+ scanner , err := DefaultScanner ()
292+ require .NoError (t , err )
293+
294+ testCases := []struct {
295+ name string
296+ leaf string
297+ want string
298+ }{
299+ {
300+ // A tool result that holds a JSON document as text: the quote after
301+ // the JWT is escaped in the leaf.
302+ name : "followed by an escaped quote in a nested JSON string" ,
303+ leaf : `{"url":"https://uploads.example.com/o/a/b?signature=` + fakeJWT + `"}` ,
304+ want : `{"url":"https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]"}` ,
305+ },
306+ {
307+ name : "followed by an escaped newline" ,
308+ leaf : "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\n next line" ,
309+ want : "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\n next line" ,
310+ },
311+ {
312+ name : "followed by an escaped carriage return" ,
313+ leaf : "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\r next line" ,
314+ want : "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\r next line" ,
315+ },
316+ {
317+ name : "followed by an escaped tab" ,
318+ leaf : "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\t next line" ,
319+ want : "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\t next line" ,
320+ },
321+ {
322+ // The literal backslash is a complete `\\` escape, which is kept in
323+ // the secret: the leaf loses that one character but not its context.
324+ name : "followed by a literal backslash" ,
325+ leaf : `https://uploads.example.com/o/a/b?signature=` + fakeJWT + `\"` ,
326+ want : `https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]"` ,
327+ },
328+ }
329+
330+ for _ , tc := range testCases {
331+ t .Run (tc .name , func (t * testing.T ) {
332+ doc , err := json .Marshal (map [string ]string {"text" : tc .leaf })
333+ require .NoError (t , err )
334+
335+ once , report , err := New (scanner ).Redact (context .Background (), doc )
336+ require .NoError (t , err )
337+ require .True (t , report .Changed ())
338+ assert .Equal (t , map [string ]int {"jwt" : 1 }, report .ByRule )
339+
340+ var got map [string ]string
341+ require .NoError (t , json .Unmarshal (once , & got ))
342+ assert .Equal (t , tc .want , got ["text" ], "only the JWT is replaced" )
343+
344+ twice , report , err := New (scanner ).Redact (context .Background (), once )
345+ require .NoError (t , err )
346+ assert .Equal (t , string (once ), string (twice ))
347+ assert .False (t , report .Changed ())
348+ })
349+ }
350+ }
351+
352+ // TestCutAtTerminatorEscape checks that the cut is made only at a real escape.
353+ // In JSON-encoded text a backslash that belongs to a `\\` escape does not start
354+ // another escape, so `\\n` is a literal backslash followed by the letter n.
355+ func TestCutAtTerminatorEscape (t * testing.T ) {
356+ testCases := []struct {
357+ name string
358+ secret string
359+ want string
360+ }{
361+ {name : "no escape" , secret : `plain` , want : `plain` },
362+ {name : "escaped quote" , secret : `quote\"after` , want : `quote` },
363+ {name : "escaped newline" , secret : `newline\nafter` , want : `newline` },
364+ {name : "escaped carriage return" , secret : `return\rafter` , want : `return` },
365+ {name : "escaped tab" , secret : `tab\tafter` , want : `tab` },
366+ {name : "escaped backslash then n is not a newline" , secret : `bs\\nafter` , want : `bs\\nafter` },
367+ {name : "escaped backslash then quote escape" , secret : `bsquote\\\"after` , want : `bsquote\\` },
368+ {name : "escaped slash is kept" , secret : `slash\/kept\nafter` , want : `slash\/kept` },
369+ {name : "trailing lone backslash is left to the engine" , secret : `lone\` , want : `lone\` },
370+ }
371+
372+ for _ , tc := range testCases {
373+ t .Run (tc .name , func (t * testing.T ) {
374+ assert .Equal (t , tc .want , cutAtTerminatorEscape (tc .secret ))
375+ })
376+ }
377+ }
378+
282379// TestDefaultScannerUsesRE2 pins the regex engine. The library default is the
283380// standard library engine, which is several times slower on the default ruleset
284381// and made large sessions take tens of minutes to redact.
0 commit comments