Skip to content
Open
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
4 changes: 4 additions & 0 deletions vulnhunter-agent/agent/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
_RAW_TOKEN_RE = re.compile(
r"(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|sk-ant-)[A-Za-z0-9_-]+"
)
_FORM_SECRET_RE = re.compile(
r"(?i)(client[_-]?secret\"?\s*[=:]\s*)[\"']?[^\s,}&\"']+[\"']?"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The exclusion class [^\s,}&\"']+ is meant to stop at a closing quote, but it also blocks the opening one — so any quoted value fails to match at all and passes through verbatim. Probing the regex directly:

ok    client_secret=SUPERSECRET&grant_type=x   -> client_secret=***&grant_type=x
ok    client_secret: SUPERSECRET               -> client_secret: ***
ok    CLIENT_SECRET=SUPERSECRET                -> CLIENT_SECRET=***
LEAK  {"client_secret": "SUPERSECRET"}         -> unchanged
LEAK  {"client_secret":"SUPERSECRET"}          -> unchanged
LEAK  client_secret = "SUPERSECRET"            -> unchanged   (TOML config form)
LEAK  client_secret='SUPERSECRET'              -> unchanged

This matters for the threat model in the PR description. RFC 6749 §5.2 specifies token-endpoint error responses as JSON, and agent/auth.py:134 interpolates response.text straight into AuthTokenError — so the most likely shape of the leak is exactly the shape not covered. The TOML form is live too: tests/test_audit.py:496 writes client_secret = "csecret" in a config fixture, and agent/audit.py:368 redacts every string in an audit record.

Suggested fix — consume the opening quote explicitly, and pick up the client-secret / clientSecret spellings that also leak today:

_FORM_SECRET_RE = re.compile(
    r"(?i)(client[_-]?secret\"?\s*[=:]\s*)[\"']?[^\s,}&\"']+[\"']?"
)

Please re-check idempotence after changing this. The current pattern happens to be idempotent because * isn't in the exclusion class, and that property is worth keeping — note that test_property_redact_is_idempotent (tests/test_url.py:164) only generates URL inputs, so it won't check this for you.



def redact(text: str) -> str:
Expand All @@ -42,6 +45,7 @@ def redact(text: str) -> str:
s = _BEARER_RE.sub(r"\1***", s)
s = _QUERY_TOKEN_RE.sub(r"\1***", s)
s = _RAW_TOKEN_RE.sub(r"\1***", s)
s = _FORM_SECRET_RE.sub(r"\1***", s)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pass ordering is fine — this runs after the token-prefix pass and the patterns don't overlap, so no interaction to worry about.

This new pass is the line with no test behind it. Please add a test_client_secret_redacted alongside the existing per-pass cases in tests/verify_012_audit_redaction.py, parametrized over the forms in my other comment (form-encoded, JSON with and without a space after the colon, TOML key = "value", single-quoted, uppercase), asserting the secret is absent and *** is present — plus an idempotence assertion.

# Residual risk (VULN-012, CWE-532): redaction is pattern-based over
# enumerated token formats; a novel/unknown secret format not in the pass
# list above would still pass through to the audit stream.
Expand Down
23 changes: 23 additions & 0 deletions vulnhunter-agent/tests/verify_012_audit_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
headers, access_token query params, and raw token prefixes.
"""

import pytest

from agent._url import redact


Expand Down Expand Up @@ -38,6 +40,27 @@ def test_raw_token_prefixes_redacted_prefix_preserved():
assert prefix in out, f"prefix {prefix} should be preserved for triage"


@pytest.mark.parametrize(
"text",
[
"client_secret=SUPERSECRET&grant_type=x",
"client_secret: SUPERSECRET",
"CLIENT_SECRET=SUPERSECRET",
'{"client_secret": "SUPERSECRET"}',
'{"client_secret":"SUPERSECRET"}',
'client_secret = "SUPERSECRET"',
"client_secret='SUPERSECRET'",
"client-secret=SUPERSECRET",
"clientSecret=SUPERSECRET",
],
)
def test_client_secret_redacted(text):
out = redact(text)
assert "SUPERSECRET" not in out
assert "***" in out
assert redact(out) == out # idempotent


def test_benign_text_unchanged():
assert redact("just a normal log line about issue #42") == (
"just a normal log line about issue #42"
Expand Down