Skip to content

fix(redaction): make sensitive-key, JWT, and private-key matching linear-time - #172

Merged
rng1995 merged 3 commits into
mainfrom
naren/fix-redaction-regex-redos
Sep 30, 2026
Merged

rng1995 merged 3 commits into
mainfrom
naren/fix-redaction-regex-redos

Conversation

@rng1995

@rng1995 rng1995 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

redact_sensitive_text() runs three assignment regexes (quoted, colon, equals) over skill and plugin files, hook scripts and LLM output. Each embeds \b plus _SENSITIVE_KEY_PATTERN, [a-z0-9_.-]*(?:api[_-]?key|secret|…|token|…)[a-z0-9_.-]*, from src/skillevaluator/utils/redaction.py. Trouble starts when a long run of key characters ([a-z0-9_.-]) is never followed by a complete assignment:

  • Quadratic: the engine retries the key at every word boundary inside the run, and a-a-a-… has one at every offset. Each retry rescans to the end of the run.
  • Cubic: for each start it also retries every way of splitting the run around a sensitive word (token-token-…), rescanning the rest of the run each time.
  • Quoted form: it repeats its lazy [^\r\n]*?(?P=quote) scan to the end of the line on every one of those retries.

Review found that the JWT and private-key (PEM) regexes, which run on the same text, were quadratic too. Neither is new; both behave the same on main:

  • JWT: \beyJ matches after every -, so in "eyJ-" * n each start scans to the end of the run looking for ..
  • PEM label: a label word, [A-Z0-9][A-Z0-9-]*, can contain -----. On "-----BEGIN A" * n, each header's label runs on across every later header.
  • PEM label pairs: (?:word )*PRIVATE KEY retries every later PRIVATE KEY pair and rescans the words after it ("-----BEGIN " + "PRIVATE KEY " * n).
  • Unterminated PEM: the (?![\s\S]*-----END ) lookahead rescans the rest of the text from every header, so many headers before a single -----END near the end is quadratic.

I found the assignment problem while reviewing #171, which works around it in one caller by truncating the input first (_bounded in plugin_component_risk.py). Every other caller of redact_sensitive_text() on main is still exposed.

Timings

One redact_sensitive_text() call, Python 3.13.14, Apple Silicon. Each measurement ran in a fresh process with a 60 s cap (20 s for the JWT and PEM rows).

Input Size Before After
"a-" * n 8 KB 6.5 s 3 ms
16 KB 27.0 s 5 ms
32 KB > 60 s 11 ms
"token" * n 16 KB 1.8 s 2 ms
32 KB 7.3 s 4 ms
"token-" * n 2 KB 5.4 s < 1 ms
4 KB 42.9 s 1 ms
8 KB > 60 s (each regex alone > 60 s) 1 ms
"token-" * n + "token='" + "x" * m (unterminated quote) 4 KB 8.9 s 1 ms
8 KB > 60 s (quoted regex alone 43.4 s) 2 ms
Any of the above 1 MB n/a 0.12–0.35 s

The before time is split roughly evenly across the three regexes. For example, "a-" * 8000 (16 KB) takes 8.7 s, 8.9 s and 8.7 s.

JWT and PEM inputs. "Before" is this PR's first commit (53eeb81), whose JWT and PEM regexes are the same as main's.

Input Before, 64 KB Before, 256 KB After, 256 KB After, 1 MB
"eyJ-" * n 0.80 s 12.4 s 0.09 s 0.34 s
"-----BEGIN A" * n 1.9 s > 20 s 0.10 s 0.39 s
"-----BEGIN PRIVATE KEY-----" * n 0.97 s 15.6 s < 0.01 s 0.01 s
"-----BEGIN " + "PRIVATE KEY " * n 9.8 s > 20 s 0.11 s 0.43 s
"-----BEGIN PRIVATE KEY-----\n" * n + "-----END x" 0.29 s 4.2 s 0.10 s 0.37 s
"-----BEGIN PRIVATE KEY X-" * n > 20 s at 16 KB — 0.02 s 0.06 s

Fix

The code change is confined to redaction.py. The three assignment regexes keep their names, and _redact_sensitive_assignment keeps its contract.

  • Key starts at the run start. A key now only starts matching at the first character of a key-character run ((?<![a-z0-9_.-])). The old key always began at the run's first word boundary. The characters before that boundary go into a new lead group, and the replacement writes them back unchanged. In --api-key=… that is the --. When a run is glued to a non-ASCII word character, as in étoken.secret=…, it is the leading [a-z0-9_] part.
  • No re-splitting. An atomic group plus a possessive quantifier, (?>[a-z0-9_.-]*?WORD)[a-z0-9_.-]*+, finds the first sensitive word and consumes the rest of the run without ever re-splitting it.
  • Quoted values. The lazy value scan plus back-reference, (?P<quote>["'])(?P<value>[^\r\n]*?)(?P=quote), becomes explicit per-quote alternatives: "[^"\r\n]*+"|'[^'\r\n]*+'. Nothing used the quote and value groups.
  • Whitespace. \s* is possessive wherever the next token can't match whitespace. The colon form keeps a backtracking \s* after :, because token: , has always matched its last blank as the value.
  • JWT. Same idea as the key: a match starts only at the beginning of a run of JWT characters ((?<![A-Za-z0-9_-])). The part of the run before its first \beyJ goes into a lead group that the replacement writes back. So x-eyJ… is still redacted and éeyJ… is still left alone, which a plain (?<![A-Za-z0-9_-])eyJ would change. Later starts in the same run are never tried, because they reach the same . with a shorter first segment and could only fail where the first start failed.
  • PEM labels. A label word is now [A-Z0-9]++(?:-[A-Z0-9]++)*+: letters and digits joined by single -, the RFC 7468 label shape. So a label can no longer run across -----. The label also commits, atomically, to its first PRIVATE KEY pair followed by a space or -----. A later pair can only reach the same label end.
  • Unterminated PEM. The third PEM rule is now _redact_unterminated_private_key(). It redacts from the first private-key header that ends after the last -----END in the text, which is exactly where the old lookahead succeeded, without rescanning the text from every header.

requires-python is >=3.12, so atomic groups and possessive quantifiers (3.11+) are available.

Behaviour

Every assignment match yields the same key, sep and end as before, and the same start once lead is skipped, so the re.sub output is byte-for-byte identical. I checked this with a differential run against the previous patterns on two input sets:

  • 840k random inputs, with about 520k matches;
  • all 813k sequences of up to 5 units from a 15-unit alphabet: keywords, -, ., _, é, ſ, quotes, :, =, blanks, newlines and ,.

For each input, each regex returned the same match list and sub output, and redact_sensitive_text() returned the same string. I also checked every Unicode code point to confirm the case-insensitive key class splits exactly into word characters ([a-z0-9_]) and non-word characters ([.-]). Under re.I that class also contains İ ı ſ K.

For the JWT and PEM rewrite, a second differential run compared the new code with 53eeb81 and main on 11.6M inputs:

  • 2M random JWT/PEM token soups and mutated blocks;

  • 1M realistic texts with RFC 7468 labels only;

  • all 5.4M sequences of up to 7 units from a 9-unit JWT alphabet;

  • all 3.3M sequences of up to 6 units from a 12-unit PEM alphabet.

  • JWT: same output as before on every input.

  • PEM and redact_sensitive_text(): same output, at every stage, as a reference that differs from 53eeb81 only in the label word grammar. The atomic label and the new unterminated-key function change nothing else.

  • Where the output changes: only where the old code read a label that the new word grammar rejects. This is the one intended behaviour change. A header no longer counts as a private-key header when:

    • a label word has -- or a trailing - (-----BEGIN X- PRIVATE KEY-----); or
    • the label only reaches PRIVATE KEY by running into the next delimiter on the same line (-----BEGIN CERTIFICATE----------END RSA PRIVATE KEY-----).

    RFC 7468 labels do neither. All standard labels are handled as before: bare, RSA, EC, DSA, OPENSSH, ENCRYPTED, PGP … BLOCK, and dashed words such as X-Y.

Tests

New file tests/test_redaction_assignments.py:

  • Representative outputs, 15 cases. Covers quoted values in both quote styles, mixed quotes (' inside "…" and the reverse), colon and equals forms, CLI flags (--api-key=), leading dots, a key glued to a non-ASCII word, and token-count keys that must stay visible. Two multiline cases cover a colon value on the next line and a mixed block. The expected strings match what the previous implementation produced.
  • JWT positions, 12 cases. Covers start of text, after a space, a quote, = or Bearer, glued to - or to a dashed run, and a run that starts with eyJ-. JWTs glued to _, an ASCII letter or é stay unredacted.
  • Private-key labels, 8 cases. Covers dashed label words, words before and after PRIVATE KEY, a later PRIVATE KEY pair, a single-line block, a block glued to an earlier header, and truncated keys after an earlier -----END , running into -----END, or with overlapping headers. All outputs match the previous implementation.
  • Timing bound on 13 crafted worst-case inputs. There are 7 assignment inputs and 6 JWT/PEM inputs of 60–200 KB. The bound is generous (< 1 s), and each test also asserts the expected output. With the previous implementation each input takes seconds (the JWT/PEM ones 2.4 s to more than 15 s), so these tests fail on main. They now take 0–80 ms.
  • Long key followed by a value. One test checks that a value after a 10k-character key run is still redacted, within the same bound.

The existing redaction tests pass unchanged.

The JWT and PEM fixtures are assembled from split literals, like the existing redaction tests, so Gitleaks doesn't flag them. The PR check scans every commit in the branch, so .gitleaks.toml allowlists the literal fixtures that 97a0616 committed, for that commit and file only.

  • uv run ruff check .: all checks passed.
  • uv run pytest -q -n auto -p no:cacheprovider: 9155 passed, 18 skipped, 0 failed.

Relation to #171

#171's _bounded truncation in plugin_component_risk.py should stay. An earlier version of this description said it was no longer needed for performance. That was wrong, because the JWT and PEM regexes were still quadratic. With this PR, every quadratic shape found in review stays under 0.5 s at 1 MB. Still, redact_sensitive_text() makes 14 passes over its input, so capping report text is still worthwhile.

🤖 Generated with Claude Code

The quoted, colon and equals assignment regexes in utils/redaction.py
embedded the key pattern \b[a-z0-9_.-]*(?:word)[a-z0-9_.-]*. On a long
run of key characters whose assignment never completes, the engine
retried the key at every word boundary inside the run and for every
split of the run around a sensitive word, and the quoted form rescanned
the rest of the line for a closing quote on each retry. A single
redact_sensitive_text() call took 6.5 s on "a-" * 4000 (8 KB) and 43 s
on "token-" * 666 (4 KB).

Match a key only from the first character of a key-character run. The
characters before the run's first word boundary (the "--" of
"--api-key=") go into a new `lead` group that the replacement writes
back unchanged. An atomic group and possessive quantifiers mean the key
is never re-split. The lazy value scan plus back-reference becomes
explicit per-quote alternatives.

The captured key, separator and match end are unchanged, so redaction
output is identical. A differential run against the previous patterns
found no difference in matches or output over ~1.65M generated inputs.
The inputs above now take milliseconds, and 1 MB of adversarial text
takes under 0.4 s.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 53eeb81. The rewrite checks out.

  • Same output as main. My own differential fuzz found no differences between main and this branch, for each of the three regexes' finditer/sub and for redact_sensitive_text(). It used a different generator from yours:
    • 1M grammar-built and mutated config, CLI, env, JSON and YAML inputs, with about 1.19M matches. They cover \r\n, \r, tabs, Unicode word characters, escaped and unbalanced quotes, --api-key=, and ., -, _ or digit leads.
    • An exhaustive run over all 8.3M sequences of up to 5 units from a 24-unit alphabet.
  • Python versions. The fuzz and the new tests also pass on 3.12.0, 3.12.11, 3.12.13, 3.13.0, 3.13.5 and 3.13.14.
  • Linear time. All 30 adversarial shapes I tried against the new regexes stay linear up to 1 MB (≤ 0.7 s). That includes the colon form with long newline runs after :.
  • Tests. On main, each of the 8 new timing tests takes 10–20 s and fails. On this branch each takes ≤ 10 ms against the 1 s bound, so they're unlikely to flake on slow runners.

One issue inline: redact_sensitive_text() as a whole is still quadratic through the unchanged PEM and JWT regexes.

Comment thread src/skillevaluator/utils/redaction.py Outdated
rng1995 and others added 2 commits September 30, 2026 11:36
After the assignment-regex fix, redact_sensitive_text() was still
quadratic through the JWT and PEM regexes:

- JWT: \beyJ matched after every "-" of a run such as "eyJ-" * n, and
  each start scanned to the end of the run looking for ".". A match now
  starts only at the beginning of a run of JWT characters. The part of
  the run before its first \beyJ goes into a `lead` group that the
  replacement writes back, so a JWT glued to a "-" (x-eyJ...) is still
  redacted. Later starts in the same run are skipped because they could
  only fail where the first one failed.
- PEM labels: a label word, [A-Z0-9][A-Z0-9-]*, could contain "-----",
  so on "-----BEGIN A" * n each header's label ran on across every later
  header. Label words are now RFC 7468-shaped: letters and digits joined
  by single "-". The label also commits to its first "PRIVATE KEY" pair
  followed by a space or "-----", which fixes
  "-----BEGIN " + "PRIVATE KEY " * n.
- Unterminated PEM: the (?![\s\S]*-----END ) lookahead rescanned the
  rest of the text from every header. That rule is now a function that
  compares each header's end with the last "-----END " in the text.

Before, one call on 256 KB took 12.4 s for "eyJ-" * n and 15.6 s for
"-----BEGIN PRIVATE KEY-----" * n, and more than 20 s for
"-----BEGIN A" * n. "-----BEGIN PRIVATE KEY X-" * n took more than 20 s
at 16 KB. Now every such input takes at most 0.45 s at 1 MB.

Intended behaviour change: a header no longer counts as a private-key
header if a label word contains "--" or ends with "-"
("X- PRIVATE KEY"), or if its label only reaches "PRIVATE KEY" by
running into the next delimiter on the same line
("-----BEGIN CERTIFICATE----------END RSA PRIVATE KEY-----"). RFC 7468
labels do neither. Everything else is unchanged. A differential fuzz
over 11.6M random and exhaustive inputs found no JWT output change. Its
PEM stages and redact_sensitive_text() output matched a reference that
changes only the label word grammar. Every difference from the previous
output traced back to such a label.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gitleaks flagged the synthetic JWT, the PEM body variable and four
private-key blocks that 97a0616 added to
tests/test_redaction_assignments.py. Build them from split literals
instead, as tests/test_safety_boundaries.py and
tests/tier3/test_behavior_evidence.py already do. The test inputs keep
the same shapes.

The PR check scans every commit reachable from the head, so the
findings in 97a0616 stay in history. Allowlist them in .gitleaks.toml
for that commit and that file only.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rng1995 rng1995 changed the title fix(redaction): make sensitive-key matching linear-time fix(redaction): make sensitive-key, JWT, and private-key matching linear-time Sep 30, 2026
@rng1995
rng1995 enabled auto-merge (squash) September 30, 2026 18:51
@rng1995
rng1995 merged commit a2b01c7 into main Sep 30, 2026
17 checks passed
@rng1995
rng1995 deleted the naren/fix-redaction-regex-redos branch September 30, 2026 18:57
rng1995 added a commit that referenced this pull request Sep 30, 2026
Bring in #172, #173, and #174 and resolve the conflicts:

- reporting/cli.py: keep both imports, and move the plugin sections'
  three remaining rich_escape calls to escape_markup (#173 removed the
  rich.markup import).
- tier3/harbor/progress.py: keep _LIVE_MIN_EVENT_ROWS and drop the
  local escape regexes; #173 moved them to utils/rich_markup and
  redact_progress_detail already uses strip_terminal_controls.
- eval_core/checks.py and harbor/templates/eval.py, kept identical:
  - keep this branch's extra protected write paths;
  - combine _mentions_sensitive_path with #174's `not patch_entry` guard;
  - drop the trailing slash from "/etc/sudoers.d/", "~/.config/opencode/",
    and "~/.azure/", because #174 matches apply_patch targets on path
    segments and "entry/" never matched a file inside the directory.
- tests/tier3/test_apply_patch_security.py: a write to
  ~/.ssh/authorized_keys now reports this branch's more specific
  "~/.ssh/authorized_keys" entry instead of "~/.ssh" (still critical),
  and there are cases for writes into the opencode, azure, and
  claude-settings entries.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rng1995 added a commit that referenced this pull request Sep 30, 2026
…al-stage-c

Bring in the base branch's merge of main (#172, #173, #174) and resolve
the conflicts:

- .gitleaks.toml: keep both new allowlist entries (the plugin hook
  inline-secret fixture and the PR #172 redaction fixture commit).
- reporting/cli.py: import static_risk_view and escape_markup, and move
  every remaining rich_escape call in the plugin static-risk, CVE,
  parity, endpoint, and Tier 3 sections to escape_markup (#173 removed
  the rich.markup import). The bracketed hook matcher is still escaped
  as a whole, so it prints literally.
- cli.py: the --probe-mcp proof lines print plugin- and network-derived
  server names and details, so they now use escape_markup instead of a
  local rich.markup.escape import. That adds the Windows-path and
  terminal-control handling.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chrisknvidia added a commit that referenced this pull request Sep 30, 2026
Gitleaks flagged four synthetic fixtures added by the review-fix port:
two "curl -u user:password" commands and one cloud-style secret in the
security-evidence tests, and a URL token in the MCP hardening tests.

Assemble the basic-auth pairs and tokens from split literals so the
current files no longer match, and allowlist the two commits that first
added them, the same way #172 handled its redaction fixtures. The
strings the tests build at run time are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
rng1995 added a commit that referenced this pull request Sep 30, 2026
* fix(tier3): make log JWT redaction linear-time

LOG_JWT_RE, which redact_secrets_in_log_line() runs over Tier 3 command
output, progress details and evidence, could start a match at every
"\beyJ". In a run of JWT characters such as "eyJ-" * n that is one
start after every "-", and each start rescanned the rest of the run
looking for ".", then the following segments. One 256 KB line took
12.6 s and a 1 MB line 211 s.

Start a match only at the beginning of a run of JWT characters, as #172
does for utils/redaction.py. The part of the run before its first
"\beyJ" is captured as "lead" and written back by the replacement, so
x-eyJ... is still redacted and the output is unchanged. Later starts in
the same run could only fail where the first one failed. "lead" stops
at the first "\beyJ" and every earlier offset fails "\beyJ", so the
pattern stays linear without atomic groups or possessive quantifiers.
The Harbor verifier copy runs on the task image's python3, and custom
Dockerfiles can keep their own base image, so it must still compile
before Python 3.11.

The new pattern costs more per scanned character than the old one, so
redact_secrets_in_log_line() now skips the JWT scan when the line has
no "eyJ", which every match contains. Ordinary lines cost no more than
before.

Apply the change identically to eval_core/secret_redaction.py and the
Harbor template. The parity tests keep the two copies in sync.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(gitleaks): drop the PR #172 commit allowlist

The allowlist covered synthetic JWT and private-key fixtures in
97a0616, the first revision of tests/test_redaction_assignments.py on
the PR #172 branch. #172 was squash-merged as a2b01c7, whose test file
builds the fixtures from split literals, and its head branch has been
deleted. 97a0616 is no longer reachable from main, from any other
branch or from any tag on origin. So neither the pull_request and push
scans (history reachable from HEAD) nor the scheduled and manual audits
(--all) can reach it.

Gitleaks 8.30.0 with this config finds no leaks in the history
reachable from this branch. The 6 findings the entry suppressed only
appear when 97a0616 itself is scanned.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
rng1995 added a commit that referenced this pull request Sep 30, 2026
Pick up #175 and #176. Resolve .gitleaks.toml by dropping the allowlist for #172's pre-squash commit, as main did, and keeping the allowlist for this branch's security-evidence and MCP-hardening test commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <32655573+rng1995@users.noreply.github.com>
rng1995 added a commit that referenced this pull request Sep 30, 2026
Pick up main's #175 and #176 through the base branch. Resolve .gitleaks.toml by keeping Stage C's hook inline-secret allowlist and dropping the allowlist for #172's pre-squash commit, as the base branch did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <32655573+rng1995@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant