Skip to content

Commit 24182a7

Browse files
committed
fix: cover remaining evidence credential channels
1 parent b573438 commit 24182a7

2 files changed

Lines changed: 134 additions & 12 deletions

File tree

modelaudit/scanners/_evidence_redaction.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@
188188
rf"\A(?:{SENSITIVE_CONTAINER_KEY})\Z",
189189
re.IGNORECASE,
190190
)
191+
SENSITIVE_OPTION_KEY_RE: Final[re.Pattern[str]] = re.compile(
192+
r"\A(?:(?:[a-z0-9]+[-_.])*(?:access[-_]?key(?:[-_]?id)?|access[-_]?token|api[-_]?key|apikey|"
193+
r"auth[-_]?token|client[-_]?secret|credential|password|passwd|private[-_]?key|pwd|"
194+
r"refresh[-_]?token|sas|secret|secret[-_]?key|signature|sig|token)|authorization)\Z",
195+
re.IGNORECASE,
196+
)
191197
PYTHON_COMPOUND_ASSIGNMENT_OPERATORS: Final[tuple[str, ...]] = (
192198
"**=",
193199
"//=",
@@ -714,6 +720,15 @@ def _argument_keyword_and_value(
714720
return None, significant
715721

716722

723+
def _is_sensitive_literal_key(key: object) -> bool:
724+
if isinstance(key, bytes):
725+
try:
726+
key = key.decode()
727+
except UnicodeDecodeError:
728+
return False
729+
return isinstance(key, str) and SENSITIVE_CONTAINER_KEY_RE.fullmatch(key) is not None
730+
731+
717732
def _literal_sensitive_key(tokens: list[tokenize.TokenInfo]) -> bool:
718733
significant = _significant_tokens(tokens)
719734
if len(significant) != 1 or significant[0].type != tokenize.STRING:
@@ -722,12 +737,35 @@ def _literal_sensitive_key(tokens: list[tokenize.TokenInfo]) -> bool:
722737
key = ast.literal_eval(significant[0].string)
723738
except (SyntaxError, ValueError):
724739
return False
725-
if isinstance(key, bytes):
726-
try:
727-
key = key.decode()
728-
except UnicodeDecodeError:
729-
return False
730-
return isinstance(key, str) and SENSITIVE_CONTAINER_KEY_RE.fullmatch(key) is not None
740+
return _is_sensitive_literal_key(key)
741+
742+
743+
def _target_contains_sensitive_literal_key(target: str) -> bool:
744+
try:
745+
expression = ast.parse(target.strip(), mode="eval").body
746+
except (SyntaxError, ValueError):
747+
return False
748+
if isinstance(expression, ast.Constant):
749+
return _is_sensitive_literal_key(expression.value)
750+
for node in ast.walk(expression):
751+
if (
752+
isinstance(node, ast.Subscript)
753+
and isinstance(node.slice, ast.Constant)
754+
and _is_sensitive_literal_key(node.slice.value)
755+
):
756+
return True
757+
return False
758+
759+
760+
def _literal_sensitive_option(tokens: list[tokenize.TokenInfo]) -> bool:
761+
significant = _significant_tokens(tokens)
762+
if len(significant) != 1 or significant[0].type != tokenize.STRING:
763+
return False
764+
try:
765+
option = ast.literal_eval(significant[0].string)
766+
except (SyntaxError, ValueError):
767+
return False
768+
return isinstance(option, str) and SENSITIVE_OPTION_KEY_RE.fullmatch(option.lstrip("-")) is not None
731769

732770

733771
def _comparison_target_start(
@@ -789,9 +827,9 @@ def _redact_sensitive_keyed_calls(text: str) -> str:
789827
}
790828
replacements: list[tuple[int, int]] = []
791829
for index, token in enumerate(tokens):
792-
argument_spec = keyed_call_arguments.get(token.string) if token.type == tokenize.NAME else None
793-
if argument_spec is None:
830+
if token.type != tokenize.NAME:
794831
continue
832+
argument_spec = keyed_call_arguments.get(token.string)
795833
open_paren_index = index + 1
796834
while open_paren_index < len(tokens) and tokens[open_paren_index].type in {tokenize.NL, tokenize.COMMENT}:
797835
open_paren_index += 1
@@ -807,6 +845,33 @@ def _redact_sensitive_keyed_calls(text: str) -> str:
807845
_argument_keyword_and_value(tokens[argument_start:argument_end])
808846
for argument_start, argument_end in argument_ranges
809847
]
848+
for keyword, argument_value_tokens in arguments:
849+
if keyword == "auth" and argument_value_tokens:
850+
replacements.append(
851+
(
852+
_position_offset(offsets, argument_value_tokens[0].start, len(text)),
853+
_position_offset(offsets, argument_value_tokens[-1].end, len(text)),
854+
)
855+
)
856+
857+
if token.string in {"add_argument", "option"}:
858+
default_tokens = next(
859+
(argument_value_tokens for keyword, argument_value_tokens in arguments if keyword == "default"),
860+
None,
861+
)
862+
positional_arguments = [
863+
argument_value_tokens for keyword, argument_value_tokens in arguments if keyword is None
864+
]
865+
if default_tokens and any(_literal_sensitive_option(value_tokens) for value_tokens in positional_arguments):
866+
replacements.append(
867+
(
868+
_position_offset(offsets, default_tokens[0].start, len(text)),
869+
_position_offset(offsets, default_tokens[-1].end, len(text)),
870+
)
871+
)
872+
873+
if argument_spec is None:
874+
continue
810875
key_index, value_index, key_keywords, value_keywords = argument_spec
811876
key_tokens = _keyed_call_argument(arguments, key_index, key_keywords)
812877
value_tokens = _keyed_call_argument(arguments, value_index, value_keywords)
@@ -954,11 +1019,16 @@ def _redact_python_expression_assignments(text: str) -> str:
9541019
target_start = _position_offset(offsets, tokens[target_start_index].start, text_length)
9551020
target_end = _position_offset(offsets, token.start, text_length)
9561021
target = text[target_start:target_end]
1022+
source_target_is_sensitive = SENSITIVE_ASSIGNMENT_TARGET_RE.search(target) is not None
1023+
literal_target_is_sensitive = _target_contains_sensitive_literal_key(target)
9571024
if token.string == ":":
958-
if QUOTED_SENSITIVE_MAPPING_KEY_RE.search(target) is None:
1025+
source_target_is_sensitive = QUOTED_SENSITIVE_MAPPING_KEY_RE.search(target) is not None
1026+
if not source_target_is_sensitive and not literal_target_is_sensitive:
9591027
continue
960-
elif SENSITIVE_ASSIGNMENT_TARGET_RE.search(target) is None and not (
961-
"," in target and _contains_sensitive_unpacking_target(target)
1028+
elif (
1029+
not source_target_is_sensitive
1030+
and not literal_target_is_sensitive
1031+
and not ("," in target and _contains_sensitive_unpacking_target(target))
9621032
):
9631033
continue
9641034

@@ -984,7 +1054,10 @@ def _redact_python_expression_assignments(text: str) -> str:
9841054
)
9851055
is_annotated_target = ANNOTATED_SENSITIVE_ASSIGNMENT_TARGET_RE.search(target) is not None
9861056
requires_token_redaction = (
987-
token.string == ":=" or "," in target or PREFIXED_QUOTED_SENSITIVE_MAPPING_KEY_RE.search(target) is not None
1057+
token.string == ":="
1058+
or "," in target
1059+
or PREFIXED_QUOTED_SENSITIVE_MAPPING_KEY_RE.search(target) is not None
1060+
or (literal_target_is_sensitive and not source_target_is_sensitive)
9881061
)
9891062
if (
9901063
is_simple_value

tests/scanners/test_evidence_redaction.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,55 @@ def test_unparseable_sensitive_comparison_and_keyed_calls_fail_closed() -> None:
611611
assert 'eval("1 + 1")' in redacted
612612

613613

614+
def test_redacts_http_auth_and_sensitive_option_defaults() -> None:
615+
"""Auth arguments and credential option defaults must not retain secrets."""
616+
text = (
617+
'requests.get(url, auth=("user", "BASICAUTHSECRET1234567890")); '
618+
'parser.add_argument("-k", "--api-key", default="ARGPARSESECRET1234567890"); '
619+
'click.option("--client-secret", default=build("CLICKSECRET1234567890")); '
620+
'requests.get(url, auth_timeout="visible"); '
621+
'parser.add_argument("--region", default="us-east-1"); '
622+
'parser.add_argument("--api-key-count", default=3); '
623+
'click.option("--token-cache", default="enabled"); eval("1 + 1")'
624+
)
625+
626+
redacted = redact_evidence_string(text, max_chars=None)
627+
628+
assert "BASICAUTHSECRET1234567890" not in redacted
629+
assert "ARGPARSESECRET1234567890" not in redacted
630+
assert "CLICKSECRET1234567890" not in redacted
631+
assert "auth=<redacted>" in redacted
632+
assert 'parser.add_argument("-k", "--api-key", default=<redacted>)' in redacted
633+
assert 'click.option("--client-secret", default=<redacted>)' in redacted
634+
assert 'auth_timeout="visible"' in redacted
635+
assert 'parser.add_argument("--region", default="us-east-1")' in redacted
636+
assert 'parser.add_argument("--api-key-count", default=3)' in redacted
637+
assert 'click.option("--token-cache", default="enabled")' in redacted
638+
assert 'eval("1 + 1")' in redacted
639+
640+
641+
def test_redacts_parsed_python_literal_sensitive_keys() -> None:
642+
"""Escaped and triple-quoted keys should be classified by parsed value."""
643+
text = (
644+
'os.environ["""API_KEY"""] = "TRIPLEKEYSECRET1234567890"\n'
645+
'payload[("client_" "secret")] = "CONCATKEYSECRET1234567890"\n'
646+
'payload = {"api_\\x6bey": "ESCAPEDKEYSECRET1234567890", '
647+
'"regi\\x6fn": "visible"}\n'
648+
'safe: "api_key" = "visible"'
649+
)
650+
651+
redacted = redact_evidence_string(text, max_chars=None)
652+
653+
assert "TRIPLEKEYSECRET1234567890" not in redacted
654+
assert "CONCATKEYSECRET1234567890" not in redacted
655+
assert "ESCAPEDKEYSECRET1234567890" not in redacted
656+
assert 'os.environ["""API_KEY"""] = <redacted>' in redacted
657+
assert 'payload[("client_" "secret")] = <redacted>' in redacted
658+
assert '"api_\\x6bey": <redacted>' in redacted
659+
assert '"regi\\x6fn": "visible"' in redacted
660+
assert 'safe: "api_key" = "visible"' in redacted
661+
662+
614663
def test_redaction_bounds_expression_analysis_to_output_lookahead() -> None:
615664
"""Very large evidence strings should still redact secrets near the visible prefix."""
616665
secret = "BOUNDEDSECRET1234567890"

0 commit comments

Comments
 (0)