@@ -29,8 +29,65 @@ def __init__(self, field: str, kind: str) -> None:
2929# safe to block without a caller-supplied label. Assignment detection below catches
3030# generic credentials (including private deployment tokens) only when the field name
3131# explicitly says that it is a credential.
32- _PATTERNS : tuple [tuple [str , re .Pattern [str ]], ...] = (
33- ("private key" , re .compile (r"-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----" , re .I )),
32+ _PEM_HEADER = "-----begin private key-----"
33+ _PEM_HEADER_ALT = "-----begin "
34+
35+
36+ def _contains_pem_header (value : str ) -> bool :
37+ """O(N) PEM-header detection without regex backtracking.
38+
39+ Equivalent to ``re.search(r"-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----", re.I)``
40+ but implemented as a case-normalized anchor scan: polynomial-ReDoS analysis
41+ of the optional ``[A-Z0-9]+`` group is moot when no regex engine is involved.
42+ Recognizes the canonical header and ``-----BEGIN X PRIVATE KEY-----`` variants
43+ (RSA/EC/OPENSSH/ENCRYPTED/…).
44+ """
45+ lowered = value .casefold ()
46+ start = 0
47+ while True :
48+ index = lowered .find (_PEM_HEADER , start )
49+ if index != - 1 :
50+ return True
51+ anchor = lowered .find (_PEM_HEADER_ALT , start )
52+ if anchor == - 1 :
53+ return False
54+ tail = lowered [anchor + len (_PEM_HEADER_ALT ):]
55+ for token in ("rsa " , "ec " , "dsa " , "openssh " , "encrypted " , "pgp " ):
56+ if tail .startswith (token ) and tail [len (token ):].startswith (
57+ "private key-----" ):
58+ return True
59+ start = anchor + len (_PEM_HEADER_ALT )
60+
61+
62+ class _PEMHeaderPattern :
63+ """Minimal stand-in for the removed PEM regex: a linear-time ``search`` and ``sub``."""
64+
65+ @staticmethod
66+ def search (value : str ) -> _PEMMatch | None :
67+ return _PEMMatch (True ) if _contains_pem_header (value ) else None
68+
69+ @staticmethod
70+ def sub (repl : str , string : str , count : int = 0 ) -> str :
71+ return _redact_pem (string )
72+
73+
74+ class _PEMMatch :
75+ """Truthiness-compatible stand-in for a regex match object."""
76+
77+ __slots__ = ("_ok" ,)
78+
79+ def __init__ (self , ok : bool ) -> None :
80+ self ._ok = ok
81+
82+ def __bool__ (self ) -> bool :
83+ return self ._ok
84+
85+ def group (self , * args : object ) -> str :
86+ return _PEM_HEADER
87+
88+
89+ _PATTERNS : tuple [tuple [str , Any ], ...] = (
90+ ("private key" , _PEMHeaderPattern ()),
3491 ("AWS access key" , re .compile (r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b" )),
3592 ("GitHub token" , re .compile (r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b" )),
3693 ("GitLab token" , re .compile (r"\bglpat-[A-Za-z0-9_-]{20,}\b" )),
@@ -191,7 +248,11 @@ def redact_secrets(text: str) -> str:
191248 if not isinstance (text , str ) or not text :
192249 return text
193250 safe = _redact_pem (text )
251+ # PEM blocks are handled by the linear-time ``_redact_pem`` above; the
252+ # detection entry in ``_PATTERNS`` is find-based and has no ``sub``.
194253 for _kind , pattern in _PATTERNS :
254+ if isinstance (pattern , _PEMHeaderPattern ):
255+ continue
195256 safe = pattern .sub (_REDACTED , safe )
196257 safe = _DSN .sub (_REDACTED , safe )
197258 safe = _ASSIGNMENT .sub (_REDACTED , safe )
0 commit comments