Repository navigation
fix(redaction): make sensitive-key, JWT, and private-key matching linear-time - #172
Merged
Merged
Conversation
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
requested review from
chrisknvidia,
mosheabr and
rmalani-nv
as code owners
September 30, 2026 17:17
rng1995
commented
Sep 30, 2026
rng1995
left a comment
Collaborator
Author
There was a problem hiding this comment.
Reviewed at 53eeb81. The rewrite checks out.
- Same output as
main. My own differential fuzz found no differences betweenmainand this branch, for each of the three regexes'finditer/suband forredact_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.
- 1M grammar-built and mutated config, CLI, env, JSON and YAML inputs, with about 1.19M matches. They cover
- 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.
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
enabled auto-merge (squash)
September 30, 2026 18:51
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
redact_sensitive_text()runs three assignment regexes (quoted, colon, equals) over skill and plugin files, hook scripts and LLM output. Each embeds\bplus_SENSITIVE_KEY_PATTERN,[a-z0-9_.-]*(?:api[_-]?key|secret|…|token|…)[a-z0-9_.-]*, fromsrc/skillevaluator/utils/redaction.py. Trouble starts when a long run of key characters ([a-z0-9_.-]) is never followed by a complete assignment:a-a-a-…has one at every offset. Each retry rescans to the end of the run.token-token-…), rescanning the rest of the run each time.[^\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:\beyJmatches after every-, so in"eyJ-" * neach start scans to the end of the run looking for..[A-Z0-9][A-Z0-9-]*, can contain-----. On"-----BEGIN A" * n, each header's label runs on across every later header.(?:word )*PRIVATE KEYretries every laterPRIVATE KEYpair and rescans the words after it ("-----BEGIN " + "PRIVATE KEY " * n).(?![\s\S]*-----END )lookahead rescans the rest of the text from every header, so many headers before a single-----ENDnear the end is quadratic.I found the assignment problem while reviewing #171, which works around it in one caller by truncating the input first (
_boundedinplugin_component_risk.py). Every other caller ofredact_sensitive_text()onmainis 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)."a-" * n"token" * n"token-" * n"token-" * n + "token='" + "x" * m(unterminated quote)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."eyJ-" * n"-----BEGIN A" * n"-----BEGIN PRIVATE KEY-----" * n"-----BEGIN " + "PRIVATE KEY " * n"-----BEGIN PRIVATE KEY-----\n" * n + "-----END x""-----BEGIN PRIVATE KEY X-" * nFix
The code change is confined to
redaction.py. The three assignment regexes keep their names, and_redact_sensitive_assignmentkeeps its contract.(?<![a-z0-9_.-])). The old key always began at the run's first word boundary. The characters before that boundary go into a newleadgroup, 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.(?>[a-z0-9_.-]*?WORD)[a-z0-9_.-]*+, finds the first sensitive word and consumes the rest of the run without ever re-splitting it.(?P<quote>["'])(?P<value>[^\r\n]*?)(?P=quote), becomes explicit per-quote alternatives:"[^"\r\n]*+"|'[^'\r\n]*+'. Nothing used thequoteandvaluegroups.\s*is possessive wherever the next token can't match whitespace. The colon form keeps a backtracking\s*after:, becausetoken: ,has always matched its last blank as the value.(?<![A-Za-z0-9_-])). The part of the run before its first\beyJgoes into aleadgroup that the replacement writes back. Sox-eyJ…is still redacted andéeyJ…is still left alone, which a plain(?<![A-Za-z0-9_-])eyJwould 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.[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 firstPRIVATE KEYpair followed by a space or-----. A later pair can only reach the same label end._redact_unterminated_private_key(). It redacts from the first private-key header that ends after the last-----ENDin the text, which is exactly where the old lookahead succeeded, without rescanning the text from every header.requires-pythonis>=3.12, so atomic groups and possessive quantifiers (3.11+) are available.Behaviour
Every assignment match yields the same
key,sepand end as before, and the same start onceleadis skipped, so there.suboutput is byte-for-byte identical. I checked this with a differential run against the previous patterns on two input sets:-,.,_,é,ſ, quotes,:,=, blanks, newlines and,.For each input, each regex returned the same match list and
suboutput, andredact_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 ([.-]). Underre.Ithat class also containsİ ı ſ K.For the JWT and PEM rewrite, a second differential run compared the new code with 53eeb81 and
mainon 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:
--or a trailing-(-----BEGIN X- PRIVATE KEY-----); orPRIVATE KEYby 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 asX-Y.Tests
New file
tests/test_redaction_assignments.py:'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.=orBearer, glued to-or to a dashed run, and a run that starts witheyJ-. JWTs glued to_, an ASCII letter oréstay unredacted.PRIVATE KEY, a laterPRIVATE KEYpair, 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.main. They now take 0–80 ms.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.tomlallowlists 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
_boundedtruncation inplugin_component_risk.pyshould 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