From bd90c553c1399c11ef342caee768591eb57167cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 01:59:16 +0000 Subject: [PATCH 1/6] fix(session-flow): bound the JWT, URL and email redaction regexes The running-retro observer's ledger redaction and save_point.py's secret-shape scan re-scanned an unbounded run from every start position, so a long line with no '.', '://' or '@' took seconds to minutes. Cap the JWT header at 512 (as the ghs_ rule does), the URL scheme at 64, and the email local part and domain at the RFC 5321 limits. Closes #5951 Co-authored-by: ksextonmelodic --- .../session-flow/.claude-plugin/plugin.json | 2 +- plugins/session-flow/CHANGELOG.md | 8 ++++ plugins/session-flow/scripts/save_point.py | 8 +++- .../scripts/tests/test_save_point.py | 43 ++++++++++++++++- .../skills/running-retro/scripts/observer.py | 12 +++-- .../running-retro/scripts/test_observer.py | 46 +++++++++++++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) diff --git a/plugins/session-flow/.claude-plugin/plugin.json b/plugins/session-flow/.claude-plugin/plugin.json index f58dc3828f..6de0e5b900 100644 --- a/plugins/session-flow/.claude-plugin/plugin.json +++ b/plugins/session-flow/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-flow", - "version": "0.48.4", + "version": "0.48.5", "description": "Session lifecycle: workflow (next stage), handoff (save-point, resume prompt), continue-in-background, keep-going (resume after interruption or stall), find-handoff (recover a lost handoff), clean-stop (durable stopping point), retro and running-retro (retrospectives), audit-sessions, orient (where the session stands), orchestrate (delegation imperatives), reanchor (verify assumptions), reconcile (retire finished work), show-options (ranked skill menu), tidy-work (.work tiers), check, setup.", "author": { "name": "Melodic Software", diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 43d4ca0748..2ef7ad9cd3 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog: session-flow plugin +## [0.48.5] - 2026-10-04 + +### Fixed + +- **The JWT, connection-string and email redaction patterns no longer take seconds to minutes on adversarial text ([#5951](https://github.com/melodic-software/claude-code-plugins/issues/5951)).** + The running-retro observer's ledger redaction and the `save_point.py` secret-shape scan re-scanned an unbounded run from every start position, so a long line with no `.`, `://` or `@` was quadratic. The JWT header is now capped at 512 characters, the URL scheme at 64, and the email local part and domain at the RFC 5321 limits of 64 and 255. + Every realistic token, connection string and address is still redacted. + ## [0.48.4] - 2026-10-03 ### Fixed diff --git a/plugins/session-flow/scripts/save_point.py b/plugins/session-flow/scripts/save_point.py index 6940f64204..4f59f2b947 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -238,7 +238,11 @@ (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), "Slack token"), (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS key id"), ( - re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), + # The bounded JWT header and URL scheme keep each start position's + # scan short; unbounded, a long run with no `.` or `://` is quadratic. + re.compile( + r"\beyJ[A-Za-z0-9_-]{10,512}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" + ), "JWT", ), ( @@ -249,7 +253,7 @@ "secret", ), ( - re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:@/]+:[^\s:@/]+@[^\s]+"), + re.compile(r"\b[a-z][a-z0-9+.-]{0,63}://[^\s:@/]+:[^\s:@/]+@[^\s]+"), "connection string", ), ) diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 586f64b0d4..755785dc47 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -479,17 +479,56 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "ghs_1_eyJ" * 30000, "ghs_1_eyJa." * 30000, "ghs_1_eyJ-" * 30000, + "-eyJ" * 75000, + "a." * 150000, + "a." * 32 + "a@" + "a." * 150000, + ], + ids=[ + "dash", + "header", + "dotted", + "header-dash", + "jwt-header", + "scheme", + "scheme-at", ], - ids=["dash", "header", "dotted", "header-dash"], ) def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): - # Shapes that made the GitHub token pattern backtrack for seconds to minutes. + # Shapes that made the GitHub token, JWT and connection string patterns + # backtrack for seconds to minutes. start = time.monotonic() for pattern, _ in _save_point_module().SECRET_SHAPES: pattern.search(line) assert time.monotonic() - start < 1.0 +@pytest.mark.parametrize( + ("text", "label"), + [ + # Header, payload and signature spell FAKE. + ( + "auth eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal x", + "JWT", + ), + ("eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal", "JWT"), + ("dsn postgres://u:p@h/db", "connection string"), + ( + "postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app", + "connection string", + ), + ("m" * 64 + "://u:p@h", "connection string"), + ], + ids=["jwt", "jwt-512-header", "url", "url-compound-scheme", "url-64-scheme"], +) +def test_bounded_secret_shapes_still_flag_realistic_secrets(text, label): + labels = { + found + for pattern, found in _save_point_module().SECRET_SHAPES + if pattern.search(text) + } + assert label in labels + + @pytest.mark.parametrize("marker", ["- ", "* ", "+ ", "1. ", "2) "]) def test_validate_refuses_a_bulleted_next_headline(tmp_path, marker): handoffs = materialize(tmp_path, "good-chain") diff --git a/plugins/session-flow/skills/running-retro/scripts/observer.py b/plugins/session-flow/skills/running-retro/scripts/observer.py index 58dbe562ed..254f39ab32 100755 --- a/plugins/session-flow/skills/running-retro/scripts/observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/observer.py @@ -923,7 +923,13 @@ def _extract_result(stdout: str) -> str: (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), ""), (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), ""), ( - re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), + # The JWT header, URL scheme, and email local part and domain are + # bounded so each start position scans a bounded run; unbounded, a + # long run with no `.`, `://` or `@` is quadratic to scan. RFC 5321 + # caps the local part at 64 octets and the domain at 255. + re.compile( + r"\beyJ[A-Za-z0-9_-]{10,512}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" + ), "", ), ( @@ -934,11 +940,11 @@ def _extract_result(stdout: str) -> str: "", ), ( - re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:@/]+:[^\s:@/]+@[^\s]+"), + re.compile(r"\b[a-z][a-z0-9+.-]{0,63}://[^\s:@/]+:[^\s:@/]+@[^\s]+"), "", ), ( - re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), + re.compile(r"\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,}\b"), "", ), ) diff --git a/plugins/session-flow/skills/running-retro/scripts/test_observer.py b/plugins/session-flow/skills/running-retro/scripts/test_observer.py index 6efbe029a3..1eae0e02f4 100755 --- a/plugins/session-flow/skills/running-retro/scripts/test_observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/test_observer.py @@ -846,6 +846,52 @@ def test_github_token_pattern_finishes_promptly_on_adversarial_text(self): github.sub("x", text) self.assertLess(time.monotonic() - start, 1.0) + def test_jwt_url_and_email_patterns_finish_promptly_on_adversarial_text(self): + # Shapes that made these patterns backtrack for seconds to minutes. + patterns = [ + p + for p, marker in observer._REDACTIONS + if marker + in ( + "", + "", + "", + ) + ] + self.assertEqual(len(patterns), 3) + for text in ( + "ghs_1_-" * 50000, + "ghs_1_eyJa." * 30000, + "ghs_1_eyJ-" * 30000, + "-eyJ" * 75000, + "a." * 150000, + "a." * 32 + "a@" + "a." * 150000, + ): + with self.subTest(text=text[:12]): + start = time.monotonic() + for pattern in patterns: + pattern.sub("x", text) + self.assertLess(time.monotonic() - start, 1.0) + + def test_bounded_patterns_still_redact_realistic_secrets(self): + r = observer._redact + # Header, payload and signature spell FAKE. + jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" + self.assertEqual("auth x", r(f"auth {jwt} x")) + long_header = "eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" + self.assertEqual("", r(long_header)) + self.assertEqual( + "dsn ", + r("dsn postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app"), + ) + self.assertEqual("", r("m" * 64 + "://u:p@h")) + self.assertEqual( + "to x", r("to first.last+tag@mail.example.co.uk x") + ) + local = "l" * 64 + domain = ".".join(["d" * 63] * 3) + ".example" + self.assertEqual("", r(f"{local}@{domain}")) + class ResultParsing(unittest.TestCase): def test_extract(self): From 0e471e55227473e9a287055084bcb6b9b67768d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 02:15:41 +0000 Subject: [PATCH 2/6] fix(session-flow): bound the observer's private key redaction body The running-retro observer's private key rule scanned lazily to the end of the text from every -----BEGIN header, so a repeated header took about 38 seconds. Stop the body at the next -----BEGIN and at 16384 characters (an 8192-bit RSA key is about 6.5 KB as PEM or OpenSSH) and cap the label at 64. save_point.py's header-only rule is already linear; its timing test now covers the same shape. Refs #5951 Co-authored-by: ksextonmelodic --- plugins/session-flow/CHANGELOG.md | 6 ++-- .../scripts/tests/test_save_point.py | 5 +++- .../skills/running-retro/scripts/observer.py | 7 ++++- .../running-retro/scripts/test_observer.py | 29 +++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 2ef7ad9cd3..4d32a3c120 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -4,9 +4,9 @@ ### Fixed -- **The JWT, connection-string and email redaction patterns no longer take seconds to minutes on adversarial text ([#5951](https://github.com/melodic-software/claude-code-plugins/issues/5951)).** - The running-retro observer's ledger redaction and the `save_point.py` secret-shape scan re-scanned an unbounded run from every start position, so a long line with no `.`, `://` or `@` was quadratic. The JWT header is now capped at 512 characters, the URL scheme at 64, and the email local part and domain at the RFC 5321 limits of 64 and 255. - Every realistic token, connection string and address is still redacted. +- **The private key, JWT, connection-string and email redaction patterns no longer take seconds to minutes on adversarial text ([#5951](https://github.com/melodic-software/claude-code-plugins/issues/5951)).** + The running-retro observer's ledger redaction and the `save_point.py` secret-shape scan re-scanned an unbounded run from every start position, so a long line with no `.`, `://` or `@`, or a repeated private key header, was quadratic. The JWT header is now capped at 512 characters, the URL scheme at 64, and the email local part and domain at the RFC 5321 limits of 64 and 255. The observer's private key body now stops at the next `-----BEGIN` and at 16384 characters, over twice the size of an 8192-bit RSA key. + Every realistic key, token, connection string and address is still redacted. ## [0.48.4] - 2026-10-03 diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 755785dc47..66cba56283 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -482,6 +482,7 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "-eyJ" * 75000, "a." * 150000, "a." * 32 + "a@" + "a." * 150000, + "-----BEGIN a PRIVATE KEY-----" * 20000, ], ids=[ "dash", @@ -491,11 +492,13 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "jwt-header", "scheme", "scheme-at", + "private-key", ], ) def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): # Shapes that made the GitHub token, JWT and connection string patterns - # backtrack for seconds to minutes. + # here, or the observer's private key pattern, backtrack for seconds to + # minutes. start = time.monotonic() for pattern, _ in _save_point_module().SECRET_SHAPES: pattern.search(line) diff --git a/plugins/session-flow/skills/running-retro/scripts/observer.py b/plugins/session-flow/skills/running-retro/scripts/observer.py index 254f39ab32..4fba48b310 100755 --- a/plugins/session-flow/skills/running-retro/scripts/observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/observer.py @@ -904,8 +904,13 @@ def _extract_result(stdout: str) -> str: # pass, matching running-retro's "redact on the ledger write too" mandate. _REDACTIONS: tuple[tuple[re.Pattern, str], ...] = ( ( + # The body stops at the next BEGIN and at 16384 characters, so each + # header scans a bounded run; unbounded, a repeated header is + # quadratic. An 8192-bit RSA key is about 6.5 KB as PEM or OpenSSH. re.compile( - r"-----BEGIN[^-]+PRIVATE KEY-----.*?-----END[^-]+PRIVATE KEY-----", + r"-----BEGIN[^-]{1,64}PRIVATE KEY-----" + r"(?:(?!-----BEGIN).){0,16384}?" + r"-----END[^-]{1,64}PRIVATE KEY-----", re.DOTALL, ), "", diff --git a/plugins/session-flow/skills/running-retro/scripts/test_observer.py b/plugins/session-flow/skills/running-retro/scripts/test_observer.py index 1eae0e02f4..a267566126 100755 --- a/plugins/session-flow/skills/running-retro/scripts/test_observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/test_observer.py @@ -873,6 +873,24 @@ def test_jwt_url_and_email_patterns_finish_promptly_on_adversarial_text(self): pattern.sub("x", text) self.assertLess(time.monotonic() - start, 1.0) + def test_private_key_pattern_finishes_promptly_on_adversarial_text(self): + # A repeated header made the unbounded body scan take about 38 seconds. + (key,) = ( + p + for p, marker in observer._REDACTIONS + if marker == "" + ) + header = "-----BEGIN a PRIVATE KEY-----" + for text in ( + header * 20000, + header + "-----END" * 70000, + "-----BEGIN" + "a" * 600000, + ): + with self.subTest(text=text[:40]): + start = time.monotonic() + key.sub("x", text) + self.assertLess(time.monotonic() - start, 1.0) + def test_bounded_patterns_still_redact_realistic_secrets(self): r = observer._redact # Header, payload and signature spell FAKE. @@ -891,6 +909,17 @@ def test_bounded_patterns_still_redact_realistic_secrets(self): local = "l" * 64 domain = ".".join(["d" * 63] * 3) + ".example" self.assertEqual("", r(f"{local}@{domain}")) + # A 4096-bit RSA PKCS#8 PEM is about 3.2 KB in 64-character lines + # (RFC 7468); this body spells FAKE. + body = "\n".join(["FAKE" * 16] * 52) + for label in ("", "RSA ", "OPENSSH "): + with self.subTest(label=label): + pem = ( + f"-----BEGIN {label}PRIVATE KEY-----\n{body}\n" + f"-----END {label}PRIVATE KEY-----" + ) + self.assertGreater(len(pem), 3200) + self.assertEqual("key x", r(f"key {pem} x")) class ResultParsing(unittest.TestCase): From 5674f25f9b862ef59dd57cd226d8b212128bef40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 02:30:11 +0000 Subject: [PATCH 3/6] test(session-flow): split fake secret literals so gitleaks does not flag the fixtures Co-authored-by: ksextonmelodic --- plugins/session-flow/scripts/tests/test_save_point.py | 2 +- .../skills/running-retro/scripts/test_observer.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 66cba56283..638dd776bd 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -510,7 +510,7 @@ def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): [ # Header, payload and signature spell FAKE. ( - "auth eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal x", + "auth eyJhbGciOiJIUzI1NiJ9" ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal x", "JWT", ), ("eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal", "JWT"), diff --git a/plugins/session-flow/skills/running-retro/scripts/test_observer.py b/plugins/session-flow/skills/running-retro/scripts/test_observer.py index a267566126..1dd4d3e05b 100755 --- a/plugins/session-flow/skills/running-retro/scripts/test_observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/test_observer.py @@ -880,7 +880,7 @@ def test_private_key_pattern_finishes_promptly_on_adversarial_text(self): for p, marker in observer._REDACTIONS if marker == "" ) - header = "-----BEGIN a PRIVATE KEY-----" + header = "-----BEGIN a PRIVATE" " KEY-----" for text in ( header * 20000, header + "-----END" * 70000, @@ -894,7 +894,7 @@ def test_private_key_pattern_finishes_promptly_on_adversarial_text(self): def test_bounded_patterns_still_redact_realistic_secrets(self): r = observer._redact # Header, payload and signature spell FAKE. - jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" + jwt = "eyJhbGciOiJIUzI1NiJ9" ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" self.assertEqual("auth x", r(f"auth {jwt} x")) long_header = "eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" self.assertEqual("", r(long_header)) From 797744bdff6cf448d760e6f09773264819c37362 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 02:31:08 +0000 Subject: [PATCH 4/6] chore(gitleaks): ignore the two fake JWT fixture lines in this PR's first commit Co-authored-by: ksextonmelodic --- .gitleaksignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index b47226024c..ecc84dffa6 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,6 +8,8 @@ 04f4bcd559a840e806b84fa4dd5992cc1e2cf5c7:scripts/gitleaks-scoped-scan.test.sh:generic-api-key:119 4b10aeebd6e7f9861f2fca9710ca3143b24d8613:plugins/architecture/lib/likec4-golden/deployment-kubernetes.c4:generic-api-key:20 4b10aeebd6e7f9861f2fca9710ca3143b24d8613:plugins/architecture/lib/likec4-golden/deployment-kubernetes.c4:generic-api-key:21 +bd90c553c1399c11ef342caee768591eb57167cb:plugins/session-flow/scripts/tests/test_save_point.py:jwt:510 +bd90c553c1399c11ef342caee768591eb57167cb:plugins/session-flow/skills/running-retro/scripts/test_observer.py:jwt:879 # The vendored gitleaks rules file: this rule's regex is a literal prefix that # matches itself. A commit-less line is checked in git scans as well as dir # scans, so it survives the squash merge that replaces the adding commit. From a571ef32542638f0343b316133e6eb3f088d079b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 02:53:48 +0000 Subject: [PATCH 5/6] fix(session-flow): redact and flag a JWT whose header runs past the 512-character bound The bounded JWT rule no longer matched a header over 512 characters, so such a token, as with an embedded x5c chain, went through unredacted. A linear fallback after the JWT rule now consumes the whole eyJ-prefixed run in one match: the observer redacts it whole and save_point.py flags it. Co-authored-by: ksextonmelodic --- plugins/session-flow/CHANGELOG.md | 2 +- plugins/session-flow/scripts/save_point.py | 6 ++++++ .../scripts/tests/test_save_point.py | 18 +++++++++++++++++- .../skills/running-retro/scripts/observer.py | 6 ++++++ .../running-retro/scripts/test_observer.py | 15 ++++++++++++++- 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 4d32a3c120..38f15cd895 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -6,7 +6,7 @@ - **The private key, JWT, connection-string and email redaction patterns no longer take seconds to minutes on adversarial text ([#5951](https://github.com/melodic-software/claude-code-plugins/issues/5951)).** The running-retro observer's ledger redaction and the `save_point.py` secret-shape scan re-scanned an unbounded run from every start position, so a long line with no `.`, `://` or `@`, or a repeated private key header, was quadratic. The JWT header is now capped at 512 characters, the URL scheme at 64, and the email local part and domain at the RFC 5321 limits of 64 and 255. The observer's private key body now stops at the next `-----BEGIN` and at 16384 characters, over twice the size of an 8192-bit RSA key. - Every realistic key, token, connection string and address is still redacted. + Every realistic key, token, connection string and address is still redacted. A JWT whose header runs past the cap, as with an embedded `x5c` certificate chain, matches a linear fallback instead: the observer redacts the whole token, payload and signature included, and `save_point.py` still flags it. ## [0.48.4] - 2026-10-03 diff --git a/plugins/session-flow/scripts/save_point.py b/plugins/session-flow/scripts/save_point.py index 4f59f2b947..cfc0c4314f 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -245,6 +245,12 @@ ), "JWT", ), + ( + # A header past the bound: the whole run, dots included, in one match. + # After the JWT rule, since a long payload segment also starts `eyJ`. + re.compile(r"\beyJ[A-Za-z0-9_-]{513}[A-Za-z0-9_.-]*"), + "JWT", + ), ( re.compile( r"(?i)\b(?:bearer|token|api[_-]?key|secret|password|passwd|pwd)" diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 638dd776bd..5f9666c1fd 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -480,6 +480,9 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "ghs_1_eyJa." * 30000, "ghs_1_eyJ-" * 30000, "-eyJ" * 75000, + "eyJ" + "A" * 600000, + ("eyJ" + "A" * 600) * 1000, + ("-eyJ" * 127 + " ") * 600, "a." * 150000, "a." * 32 + "a@" + "a." * 150000, "-----BEGIN a PRIVATE KEY-----" * 20000, @@ -490,6 +493,9 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "dotted", "header-dash", "jwt-header", + "jwt-long-header", + "jwt-long-header-repeated", + "jwt-header-near-bound", "scheme", "scheme-at", "private-key", @@ -514,6 +520,8 @@ def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): "JWT", ), ("eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal", "JWT"), + ("eyJ" + "A" * 513 + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal", "JWT"), + ("eyJ" + "A" * 4000 + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal", "JWT"), ("dsn postgres://u:p@h/db", "connection string"), ( "postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app", @@ -521,7 +529,15 @@ def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): ), ("m" * 64 + "://u:p@h", "connection string"), ], - ids=["jwt", "jwt-512-header", "url", "url-compound-scheme", "url-64-scheme"], + ids=[ + "jwt", + "jwt-512-header", + "jwt-513-header", + "jwt-4000-header", + "url", + "url-compound-scheme", + "url-64-scheme", + ], ) def test_bounded_secret_shapes_still_flag_realistic_secrets(text, label): labels = { diff --git a/plugins/session-flow/skills/running-retro/scripts/observer.py b/plugins/session-flow/skills/running-retro/scripts/observer.py index 4fba48b310..28503b5fc3 100755 --- a/plugins/session-flow/skills/running-retro/scripts/observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/observer.py @@ -937,6 +937,12 @@ def _extract_result(stdout: str) -> str: ), "", ), + ( + # A header past the bound: the whole run, dots included, in one match. + # After the JWT rule, since a long payload segment also starts `eyJ`. + re.compile(r"\beyJ[A-Za-z0-9_-]{513}[A-Za-z0-9_.-]*"), + "", + ), ( re.compile( r"(?i)\b(?:bearer|token|api[_-]?key|secret|password|passwd|pwd)" diff --git a/plugins/session-flow/skills/running-retro/scripts/test_observer.py b/plugins/session-flow/skills/running-retro/scripts/test_observer.py index 1dd4d3e05b..67d5da09e6 100755 --- a/plugins/session-flow/skills/running-retro/scripts/test_observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/test_observer.py @@ -858,12 +858,15 @@ def test_jwt_url_and_email_patterns_finish_promptly_on_adversarial_text(self): "", ) ] - self.assertEqual(len(patterns), 3) + self.assertEqual(len(patterns), 4) for text in ( "ghs_1_-" * 50000, "ghs_1_eyJa." * 30000, "ghs_1_eyJ-" * 30000, "-eyJ" * 75000, + "eyJ" + "A" * 600000, + ("eyJ" + "A" * 600) * 1000, + ("-eyJ" * 127 + " ") * 600, "a." * 150000, "a." * 32 + "a@" + "a." * 150000, ): @@ -898,6 +901,16 @@ def test_bounded_patterns_still_redact_realistic_secrets(self): self.assertEqual("auth x", r(f"auth {jwt} x")) long_header = "eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal" self.assertEqual("", r(long_header)) + # A header past the bound, as with an embedded x5c chain, is redacted + # whole, payload and signature included. + for size in (513, 4000): + with self.subTest(header=size): + token = ( + "eyJ" + "A" * size + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal" + ) + self.assertEqual("auth x", r(f"auth {token} x")) + long_payload = "eyJhbGciOiJIUzI1NiJ9" ".eyJ" + "B" * 2000 + ".FAKEsignature" + self.assertEqual("", r(long_payload)) self.assertEqual( "dsn ", r("dsn postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app"), From a48076e34081b03fb2bfcefb81453b706c1796d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 03:00:46 +0000 Subject: [PATCH 6/6] fix(session-flow): redact and flag a ghs_ token whose JWT header runs past the 512-character bound The bounded ghs__ rule missed a header over 512 characters, and neither JWT rule can rescue it: `_` is a word character, so no word boundary precedes its eyJ. A linear fallback after the ghs_ rule now consumes the whole run in one match: the observer redacts it whole and save_point.py flags it. Co-authored-by: ksextonmelodic --- plugins/session-flow/CHANGELOG.md | 2 +- plugins/session-flow/scripts/save_point.py | 5 +++++ .../session-flow/scripts/tests/test_save_point.py | 12 ++++++++++++ .../skills/running-retro/scripts/observer.py | 5 +++++ .../skills/running-retro/scripts/test_observer.py | 13 ++++++++++--- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 38f15cd895..1de9ee9fce 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -6,7 +6,7 @@ - **The private key, JWT, connection-string and email redaction patterns no longer take seconds to minutes on adversarial text ([#5951](https://github.com/melodic-software/claude-code-plugins/issues/5951)).** The running-retro observer's ledger redaction and the `save_point.py` secret-shape scan re-scanned an unbounded run from every start position, so a long line with no `.`, `://` or `@`, or a repeated private key header, was quadratic. The JWT header is now capped at 512 characters, the URL scheme at 64, and the email local part and domain at the RFC 5321 limits of 64 and 255. The observer's private key body now stops at the next `-----BEGIN` and at 16384 characters, over twice the size of an 8192-bit RSA key. - Every realistic key, token, connection string and address is still redacted. A JWT whose header runs past the cap, as with an embedded `x5c` certificate chain, matches a linear fallback instead: the observer redacts the whole token, payload and signature included, and `save_point.py` still flags it. + Every realistic key, token, connection string and address is still redacted. A JWT whose header runs past the cap, as with an embedded `x5c` certificate chain, matches a linear fallback instead: the observer redacts the whole token, payload and signature included, and `save_point.py` still flags it, and the same holds for a `ghs__` GitHub token. ## [0.48.4] - 2026-10-03 diff --git a/plugins/session-flow/scripts/save_point.py b/plugins/session-flow/scripts/save_point.py index cfc0c4314f..62e7192db2 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -235,6 +235,11 @@ ), "GitHub token", ), + ( + # A header past the bound: the whole run, dots included, in one match. + re.compile(r"\bghs_[0-9]+_eyJ[A-Za-z0-9_-]{513}[A-Za-z0-9_.-]*"), + "GitHub token", + ), (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), "Slack token"), (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS key id"), ( diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 5f9666c1fd..d423180543 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -479,6 +479,7 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "ghs_1_eyJ" * 30000, "ghs_1_eyJa." * 30000, "ghs_1_eyJ-" * 30000, + ("ghs_1_eyJ" + "A" * 600) * 1000, "-eyJ" * 75000, "eyJ" + "A" * 600000, ("eyJ" + "A" * 600) * 1000, @@ -492,6 +493,7 @@ def test_validate_flags_a_github_app_installation_token_in_jwt_form(tmp_path): "header", "dotted", "header-dash", + "header-long-repeated", "jwt-header", "jwt-long-header", "jwt-long-header-repeated", @@ -522,6 +524,14 @@ def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): ("eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal", "JWT"), ("eyJ" + "A" * 513 + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal", "JWT"), ("eyJ" + "A" * 4000 + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal", "JWT"), + ( + "ghs" + "_1_eyJ" + "A" * 513 + ".FAKEpayload" ".FAKEsignatureNOTreal", + "GitHub token", + ), + ( + "ghs" + "_1_eyJ" + "A" * 4000 + ".FAKEpayload" ".FAKEsignatureNOTreal", + "GitHub token", + ), ("dsn postgres://u:p@h/db", "connection string"), ( "postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app", @@ -534,6 +544,8 @@ def test_secret_shape_scan_of_an_adversarial_line_finishes_promptly(line): "jwt-512-header", "jwt-513-header", "jwt-4000-header", + "ghs-513-header", + "ghs-4000-header", "url", "url-compound-scheme", "url-64-scheme", diff --git a/plugins/session-flow/skills/running-retro/scripts/observer.py b/plugins/session-flow/skills/running-retro/scripts/observer.py index 28503b5fc3..e69c06edd3 100755 --- a/plugins/session-flow/skills/running-retro/scripts/observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/observer.py @@ -925,6 +925,11 @@ def _extract_result(stdout: str) -> str: ), "", ), + ( + # A header past the bound: the whole run, dots included, in one match. + re.compile(r"\bghs_[0-9]+_eyJ[A-Za-z0-9_-]{513}[A-Za-z0-9_.-]*"), + "", + ), (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), ""), (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), ""), ( diff --git a/plugins/session-flow/skills/running-retro/scripts/test_observer.py b/plugins/session-flow/skills/running-retro/scripts/test_observer.py index 67d5da09e6..fa613a3fa2 100755 --- a/plugins/session-flow/skills/running-retro/scripts/test_observer.py +++ b/plugins/session-flow/skills/running-retro/scripts/test_observer.py @@ -830,20 +830,23 @@ def test_clean_passthrough(self): def test_github_token_pattern_finishes_promptly_on_adversarial_text(self): # Shapes that made the pattern backtrack for seconds to minutes. - (github,) = ( + github = [ p for p, marker in observer._REDACTIONS if marker == "" - ) + ] + self.assertEqual(len(github), 2) for text in ( "ghs_1_-" * 50000, "ghs_1_eyJ" * 30000, "ghs_1_eyJa." * 30000, "ghs_1_eyJ-" * 30000, + ("ghs_1_eyJ" + "A" * 600) * 1000, ): with self.subTest(text=text[:12]): start = time.monotonic() - github.sub("x", text) + for pattern in github: + pattern.sub("x", text) self.assertLess(time.monotonic() - start, 1.0) def test_jwt_url_and_email_patterns_finish_promptly_on_adversarial_text(self): @@ -909,6 +912,10 @@ def test_bounded_patterns_still_redact_realistic_secrets(self): "eyJ" + "A" * size + ".eyJzdWIiOiJGQUtFIn0" ".FAKEsignatureNOTreal" ) self.assertEqual("auth x", r(f"auth {token} x")) + ghs = "ghs" + "_1234567_" + token + self.assertEqual( + "tok z", r(f"tok {ghs} z") + ) long_payload = "eyJhbGciOiJIUzI1NiJ9" ".eyJ" + "B" * 2000 + ".FAKEsignature" self.assertEqual("", r(long_payload)) self.assertEqual(