"""Credential redaction for text that leaves the process that produced it. Kept free of Flask so the display service can redact what it publishes (see src/error_aggregator.py) as well as the web interface what it returns. """ import re # Credentials that turn up inside exception text. A requests error quotes the # URL it failed on, and plugins that authenticate by query string put their key # there, so echoing an exception verbatim can hand out an API key. Redact the # value, keep the parameter name -- knowing *which* credential was involved is # part of the diagnosis. _REDACT_CREDENTIAL = re.compile( r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|' r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)', re.IGNORECASE, ) # `Authorization: `. The scheme name is kept because it # says which kind of credential failed; the credential goes. Any scheme # matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and # whatever a plugin's API invents next are all credentials, and a list would # silently leak the ones nobody thought of. Not covered by the generic pattern # above, whose value part stops at whitespace and so would keep the credential # once a space follows the scheme. # # The opening quote and the whitespace after it are one optional unit. Written # `\s*["\']?\s*`, a whitespace run with no quote in it could be split between # the two `\s*` in every possible way, and a header with no credential after # it tried them all: quadratic, 8s for 20k spaces. _REDACT_AUTH_HEADER = re.compile( r'((?:proxy-)?authorization["\']?\s*[=:]\s*(?:["\']\s*)?' r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept r'([^\s,"\'<>}]+)', # the credential, redacted re.IGNORECASE, ) # Credentials embedded in a URL: https://user:password@host. requests quotes # the full URL in its exceptions, so this is a realistic leak. The username is # kept -- it identifies which account failed without being the secret. # # A match may only start where a run of scheme characters starts. Unanchored, # `[a-z][a-z0-9+.-]*://` was tried from every letter of a long run (a hex # digest, an ID, a blob of response body), each attempt reading to the end of # the run: quadratic, 1.6s for 20k characters, all of it holding the GIL. # Leading digits and `+.-` sit inside group 1 so the substitution puts them # back; the scheme proper still has to start with a letter. _REDACT_URL_USERINFO = re.compile( r'((? str: """Replace credentials in ``text`` with ````; keep everything else, including line breaks, so a stack trace stays readable.""" text = text or '' # Order matters: the URL and header forms are more specific than the # generic key=value pattern, which would otherwise chew the scheme. text = _REDACT_URL_USERINFO.sub(r'\1\3', text) text = _REDACT_AUTH_HEADER.sub(r'\1', text) return _REDACT_CREDENTIAL.sub(r'\1', text)