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. 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..1de9ee9fce 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 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, and the same holds for a `ghs__` GitHub token. + ## [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..62e7192db2 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -235,10 +235,25 @@ ), "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"), ( - 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", + ), + ( + # 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", ), ( @@ -249,7 +264,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..d423180543 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -479,17 +479,87 @@ 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, + ("-eyJ" * 127 + " ") * 600, + "a." * 150000, + "a." * 32 + "a@" + "a." * 150000, + "-----BEGIN a PRIVATE KEY-----" * 20000, + ], + ids=[ + "dash", + "header", + "dotted", + "header-dash", + "header-long-repeated", + "jwt-header", + "jwt-long-header", + "jwt-long-header-repeated", + "jwt-header-near-bound", + "scheme", + "scheme-at", + "private-key", ], - 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 + # 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) 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"), + ("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", + "connection string", + ), + ("m" * 64 + "://u:p@h", "connection string"), + ], + ids=[ + "jwt", + "jwt-512-header", + "jwt-513-header", + "jwt-4000-header", + "ghs-513-header", + "ghs-4000-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..e69c06edd3 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, ), "", @@ -920,10 +925,27 @@ 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"), ""), ( - 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,}" + ), + "", + ), + ( + # 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_.-]*"), "", ), ( @@ -934,11 +956,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..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,22 +830,117 @@ 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): + # 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), 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, + ): + 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_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. + 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)) + # 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")) + 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( + "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}")) + # 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): def test_extract(self):