Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/session-flow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions plugins/session-flow/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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_<APPID>_<JWT>` GitHub token.

## [0.48.4] - 2026-10-03

### Fixed
Expand Down
19 changes: 17 additions & 2 deletions plugins/session-flow/scripts/save_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
(
Expand All @@ -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",
),
)
Expand Down
74 changes: 72 additions & 2 deletions plugins/session-flow/scripts/tests/test_save_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
30 changes: 26 additions & 4 deletions plugins/session-flow/skills/running-retro/scripts/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
"<REDACTED: private key>",
Expand All @@ -920,10 +925,27 @@ def _extract_result(stdout: str) -> str:
),
"<REDACTED: 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_.-]*"),
"<REDACTED: GitHub token>",
),
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), "<REDACTED: Slack token>"),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "<REDACTED: AWS key id>"),
(
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,}"
),
"<REDACTED: 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_.-]*"),
"<REDACTED: JWT>",
),
(
Expand All @@ -934,11 +956,11 @@ def _extract_result(stdout: str) -> str:
"<REDACTED: 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]+"),
"<REDACTED: connection string>",
),
(
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"),
"<REDACTED: email>",
),
)
Expand Down
101 changes: 98 additions & 3 deletions plugins/session-flow/skills/running-retro/scripts/test_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "<REDACTED: GitHub token>"
)
]
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 (
"<REDACTED: JWT>",
"<REDACTED: connection string>",
"<REDACTED: email>",
)
]
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 == "<REDACTED: private key>"
)
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 <REDACTED: JWT> x", r(f"auth {jwt} x"))
long_header = "eyJ" + "A" * 509 + ".eyJzdWIiOiJGQUtFIn0.FAKEsignatureNOTreal"
self.assertEqual("<REDACTED: JWT>", 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 <REDACTED: JWT> x", r(f"auth {token} x"))
ghs = "ghs" + "_1234567_" + token
self.assertEqual(
"tok <REDACTED: GitHub token> z", r(f"tok {ghs} z")
)
long_payload = "eyJhbGciOiJIUzI1NiJ9" ".eyJ" + "B" * 2000 + ".FAKEsignature"
self.assertEqual("<REDACTED: JWT>", r(long_payload))
self.assertEqual(
"dsn <REDACTED: connection string>",
r("dsn postgresql+psycopg2://user:FAKEpass@db.example.com:5432/app"),
)
self.assertEqual("<REDACTED: connection string>", r("m" * 64 + "://u:p@h"))
self.assertEqual(
"to <REDACTED: email> x", r("to first.last+tag@mail.example.co.uk x")
)
local = "l" * 64
domain = ".".join(["d" * 63] * 3) + ".example"
self.assertEqual("<REDACTED: email>", 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 <REDACTED: private key> x", r(f"key {pem} x"))


class ResultParsing(unittest.TestCase):
def test_extract(self):
Expand Down
Loading