fix(security): redact xAI (Grok) API keys in logs

xAI is a first-class provider in hermes-agent with its own credential
pool entry (XAI_API_KEY / xai-oauth). API keys follow the format
xai-<60+ alphanumeric chars> and were absent from _PREFIX_PATTERNS in
agent/redact.py.

When a key appears raw in log output, tool results, or error messages,
it passed through completely unmasked. The ENV-assignment and Bearer
header patterns catch the most common cases, but a raw token in a
stack trace or debug print had no protection.

Verified before fix:
  redact_sensitive_text("using key xai-ABCD...rstu to call xAI", force=True)
  # "using key xai-ABCD...rstu to call xAI"  <- exposed

After fix:
  # "using key xai-AB...rstu to call xAI"    <- masked

Five unit tests added to TestXaiToken covering bare token masking,
env assignment, short-prefix false positive, company name false
positive, and visible prefix in masked output.
This commit is contained in:
flamiinngo 2026-05-17 09:14:27 +01:00 committed by Teknium
parent fae0fa4325
commit 5613dfea93
2 changed files with 27 additions and 0 deletions

View file

@ -103,6 +103,7 @@ _PREFIX_PATTERNS = [
r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name

View file

@ -511,3 +511,29 @@ class TestFormBodyRedaction:
text = "first=1\nsecond=2"
# Should pass through (still subject to other redactors)
assert "first=1" in redact_sensitive_text(text)
class TestXaiToken:
KEY = "xai-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu"
def test_bare_token_masked(self):
result = redact_sensitive_text(f"using key {self.KEY}", force=True)
assert self.KEY not in result
assert "xai-AB" in result
def test_env_assignment_masked(self):
result = redact_sensitive_text(f"XAI_API_KEY={self.KEY}", force=True)
assert self.KEY not in result
def test_too_short_not_masked(self):
short = "xai-tooshort"
result = redact_sensitive_text(f"text {short} here", force=True)
assert short in result
def test_company_name_not_masked(self):
result = redact_sensitive_text("xai is a company", force=True)
assert result == "xai is a company"
def test_prefix_visible_in_masked_output(self):
result = redact_sensitive_text(self.KEY, force=True)
assert result.startswith("xai-AB")