Skip to content
Merged
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
45 changes: 44 additions & 1 deletion standup/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
_REDACTED = "[REDACTED]"
_PATTERNS = [
re.compile(
r"(?i)(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*"
r"(?i)(password|passwd|secret|token|api[_-]?key|access[_-]?key"
r"|secret[_-]?key|private[_-]?key|auth[_-]?token)\s*[=:]\s*"
r'(?:"[^"]*"|\'[^\']*\'|\S+)'
),
re.compile(
Expand All @@ -34,6 +35,43 @@
re.compile(r"\b\w[\w.-]+\.(?:local|internal|corp|lan)\b", re.IGNORECASE),
re.compile(r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*"),
]

# Type-tagged patterns for well-known secret formats that don't rely on a
# nearby trigger word (issue #2). Each is matched independently and replaced
# with a type-specific tag, e.g. "[REDACTED:GITHUB_TOKEN]", so the redaction
# reason stays visible without exposing the secret itself.
#
# The LLM key pattern's character class deliberately includes "-" and "_"
# (the base64url alphabet real provider keys use) with no upper length
# bound close to the prefix. An earlier draft used [A-Za-z0-9]{20,} with
# no "-"/"_", which stopped matching at the first hyphen in keys like
# "sk-ant-api03-..." and left the rest of a live key exposed in the
# "redacted" output.
_TAGGED_PATTERNS = [
(
"GITHUB_TOKEN",
re.compile(r"\bghp_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
),
(
"API_KEY",
re.compile(r"\bsk-(?:ant-api03-|proj-)?[A-Za-z0-9_-]{20,}\b"),
),
(
"AWS_KEY",
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
),
(
"SLACK_TOKEN",
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
),
(
"URI_CREDENTIALS",
# Matches the "user:password@" portion of a credentialed URI and
# redacts only that segment, leaving scheme/host/path intact so the
# rest of the commit message stays readable.
re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\s:@/]+:[^\s@/]+@"),
),
]
_ERROR_PATTERNS = [
re.compile(r"[A-Za-z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
re.compile(r"(?:/home/|/Users/)[^/\s]+(?:/[^\s]*)?"),
Expand Down Expand Up @@ -98,6 +136,11 @@ def redact_sensitive_patterns(text: str) -> str:
if matches:
match_count += matches
text = pattern.sub(_REDACTED, text)
for tag, pattern in _TAGGED_PATTERNS:
matches = len(pattern.findall(text))
if matches:
match_count += matches
text = pattern.sub(f"[REDACTED:{tag}]", text)
if text != original:
console.print(
"[yellow]⚠️ Sensitive patterns detected and redacted from commit messages.[/yellow]"
Expand Down
107 changes: 107 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,110 @@ def test_sanitize_error_message_handles_none():
def test_sanitize_error_message_handles_permission_error():
message = sanitize_error_message(PermissionError("secret path"))
assert "Permission was denied" in message


# ──────────────────────────────────────────────────────────────────────────────
# Issue #2: secret formats without a nearby trigger word
# ──────────────────────────────────────────────────────────────────────────────


def test_redact_github_classic_pat():
token = "ghp_" + "a" * 36
text = f"fix: rotate {token} after leak"
result = redact_sensitive_patterns(text)
assert token not in result
assert "[REDACTED:GITHUB_TOKEN]" in result


def test_redact_github_fine_grained_pat():
token = "github_pat_" + "A" * 22 + "_" + "B" * 59
text = f"fix: rotate {token}"
result = redact_sensitive_patterns(text)
assert token not in result
assert "[REDACTED:GITHUB_TOKEN]" in result


def test_redact_anthropic_key_full_key_not_just_prefix():
# Regression test: an earlier draft's char class excluded "-"/"_", so it
# only matched "sk-ant" and left the rest of the key (which uses the
# base64url alphabet, including "-" and "_") exposed after "redaction".
key = "sk-ant-api03-" + "aB3" * 20 + "-xY9zZ"
text = f"debug: leaked {key} in log"
result = redact_sensitive_patterns(text)
assert key not in result
# No fragment of the key body should survive redaction
assert "aB3aB3aB3" not in result
assert "[REDACTED:API_KEY]" in result


def test_redact_openai_proj_key():
key = "sk-proj-" + "a1B2c3D4-e5F6g7H8_i9J0k1L2m3N4o5"
text = f"chore: remove {key} from .env"
result = redact_sensitive_patterns(text)
assert key not in result
assert "[REDACTED:API_KEY]" in result


def test_redact_aws_access_key():
# Concatenated for the same reason as the Slack token above: this is
# AWS's own documented example key, but a contiguous literal still
# matches the AKIA[0-9A-Z]{16} shape closely enough to trip push
# protection.
fake_aws_key = "AKIA" + "IOSFODNN7EXAMPLE"
text = f"chore: rotate {fake_aws_key} for CI"
result = redact_sensitive_patterns(text)
assert fake_aws_key not in result
assert "[REDACTED:AWS_KEY]" in result


def test_redact_slack_bot_token():
# Built via concatenation, not a single literal: GitHub's push-protection
# secret scanner pattern-matches the raw source text, and a contiguous
# "xoxb-..." literal here is shaped enough like a real Slack token to
# trip it, even inside a test fixture. The runtime string (and therefore
# what the regex under test actually sees) is identical either way.
fake_slack_token = "xoxb-" + "1234567890-abcdefghijklmnop"
text = f"fix: revoke {fake_slack_token} after leak"
result = redact_sensitive_patterns(text)
assert fake_slack_token not in result
assert "[REDACTED:SLACK_TOKEN]" in result


def test_redact_uri_credentials():
text = "fix: rotate https://user:hunter2@db.internal/prod"
result = redact_sensitive_patterns(text)
assert "hunter2" not in result
assert "[REDACTED:URI_CREDENTIALS]" in result


def test_redact_compound_secret_key_variable():
text = "fix: rotate SECRET_KEY=abc123xyz in settings"
result = redact_sensitive_patterns(text)
assert "abc123xyz" not in result


def test_redact_compound_private_key_variable():
text = "chore: remove PRIVATE_KEY=abc123xyz from repo"
result = redact_sensitive_patterns(text)
assert "abc123xyz" not in result


def test_redact_compound_auth_token_variable():
text = "fix: rotate AUTH_TOKEN=abc123xyz after leak"
result = redact_sensitive_patterns(text)
assert "abc123xyz" not in result


def test_redact_does_not_flag_bare_key_word():
# Regression test: a bare "KEY" trigger word (considered and rejected
# during the issue #2 discussion) false-positives on ordinary commit
# messages that just happen to contain the word "key" before "=".
text = "feat: support KEY=VALUE parsing for .env files"
result = redact_sensitive_patterns(text)
assert result == text


def test_redact_does_not_flag_shard_key_column_name():
text = "chore: rename SHARD-KEY=tenant_id column"
result = redact_sensitive_patterns(text)
assert result == text
Loading