193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
"""Contracts for the handoff harness credential screen and output bounding."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_handoff_support import load_handoff_module
|
|
|
|
redaction = load_handoff_module("hermes_handoff_redaction")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"token: ghp_ABCDEFGHIJKLMNOPQRSTUV1234",
|
|
"Authorization: Bearer abcdefghijklmnop",
|
|
"cookie: session=abcdefghijklmnop",
|
|
"password=hunter2000secret",
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----",
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI",
|
|
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0.SflKxwRJ",
|
|
"https://user:supersecretvalue@scm.example.dev/x",
|
|
"vault token hvs.CAESIJabcdefghijklmnop",
|
|
"aws AKIAIOSFODNN7EXAMPLE",
|
|
],
|
|
)
|
|
def test_credential_shapes_are_screened(text: str) -> None:
|
|
assert redaction.contains_credential_shape(text)
|
|
assert redaction.REDACTED in redaction.redact(text)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7",
|
|
"ab346f55509d584e457fe26cf90be3078f7a375c",
|
|
'"automountServiceAccountToken": false',
|
|
'"key": "kubernetes.io/arch"',
|
|
"namespace: hermes",
|
|
"replicas: 3",
|
|
"checked_at: 2026-08-17T09:17:07.224400+00:00",
|
|
],
|
|
)
|
|
def test_ordinary_evidence_survives_screening(text: str) -> None:
|
|
assert redaction.redact(text) == text
|
|
assert not redaction.contains_credential_shape(text)
|
|
|
|
|
|
def test_screened_json_is_still_parsable_json() -> None:
|
|
"""A screen that corrupts structure would break every downstream evaluator."""
|
|
payload = {
|
|
"spec": {"automountServiceAccountToken": True, "key": "kubernetes.io/arch"},
|
|
"data": {"password": "a-long-enough-secret-value"},
|
|
}
|
|
screened = redaction.redact(json.dumps(payload, indent=2))
|
|
reparsed = json.loads(screened)
|
|
|
|
assert reparsed["spec"]["automountServiceAccountToken"] is True
|
|
assert reparsed["spec"]["key"] == "kubernetes.io/arch"
|
|
assert reparsed["data"]["password"] == redaction.REDACTED
|
|
|
|
|
|
def test_high_entropy_runs_are_screened_but_hex_digests_are_not() -> None:
|
|
assert (
|
|
redaction.redact("Zm9vYmFyYmF6cXV4-Quux_1234567890ABCDEFghij")
|
|
!= redaction.REDACTED
|
|
)
|
|
long_mixed = "aB3" + "x9Y2z_" * 10
|
|
assert redaction.redact(long_mixed) == redaction.REDACTED
|
|
assert redaction.redact("f" * 64) == "f" * 64
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "sensitive"),
|
|
[
|
|
("ANTHROPIC_API_KEY", True),
|
|
("gitea_token", True),
|
|
("clientSecret", True),
|
|
("Set-Cookie", True),
|
|
("automountServiceAccountToken", True),
|
|
("key", False),
|
|
("namespace", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_key_sensitivity_classification(key: str, sensitive: bool) -> None:
|
|
assert redaction.is_sensitive_key(key) is sensitive
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "possible"),
|
|
[
|
|
("true", False),
|
|
("False", False),
|
|
("3", False),
|
|
("12.5", False),
|
|
("short", False),
|
|
("", False),
|
|
("a-long-enough-secret", True),
|
|
('"another-long-secret"', True),
|
|
("1234567890123", False),
|
|
("-12.5e9", False),
|
|
],
|
|
)
|
|
def test_value_shape_gates_screening(value: str, possible: bool) -> None:
|
|
assert redaction.could_hold_secret(value) is possible
|
|
|
|
|
|
def test_bound_truncates_to_a_byte_budget_and_reports_it() -> None:
|
|
text, truncated = redaction.bound("x" * 100, max_bytes=10)
|
|
assert truncated
|
|
assert len(text.encode()) <= 10
|
|
assert text == redaction.TRUNCATION_NOTE[:10]
|
|
|
|
text, truncated = redaction.bound("short", max_bytes=64)
|
|
assert (text, truncated) == ("short", False)
|
|
|
|
|
|
def test_bound_with_no_budget_keeps_nothing() -> None:
|
|
assert redaction.bound("content", max_bytes=0) == ("", True)
|
|
assert redaction.bound("", max_bytes=0) == ("", False)
|
|
|
|
|
|
def test_bound_never_splits_a_multibyte_character() -> None:
|
|
text, truncated = redaction.bound("é" * 10, max_bytes=5)
|
|
assert truncated
|
|
assert len(text.encode()) <= 5
|
|
|
|
|
|
def test_safe_text_screens_before_it_bounds() -> None:
|
|
"""A boundary must never preserve a useful credential prefix."""
|
|
payload = "password=" + "a" * 200
|
|
text, truncated = redaction.safe_text(payload, max_bytes=20)
|
|
assert not truncated
|
|
assert redaction.REDACTED in text
|
|
|
|
|
|
def test_no_byte_budget_exposes_a_credential_prefix() -> None:
|
|
payload = "password=A9secret-prefix-that-must-never-survive"
|
|
for budget in range(1, 64):
|
|
screened, _ = redaction.safe_text(payload, max_bytes=budget)
|
|
assert "A9" not in screened
|
|
assert len(screened.encode()) <= budget
|
|
|
|
|
|
def test_scrub_walks_nested_structures_and_keeps_useful_types() -> None:
|
|
scrubbed = redaction.scrub(
|
|
{
|
|
"token": "a-long-enough-secret",
|
|
"automountServiceAccountToken": False,
|
|
"items": [{"password": "another-long-secret"}, 3, None],
|
|
"note": ("tuple", "values"),
|
|
}
|
|
)
|
|
|
|
assert scrubbed["token"] == redaction.REDACTED
|
|
assert scrubbed["automountServiceAccountToken"] is False
|
|
assert scrubbed["items"][0]["password"] == redaction.REDACTED
|
|
assert scrubbed["items"][1] == 3
|
|
assert scrubbed["items"][2] is None
|
|
assert scrubbed["note"] == ["tuple", "values"]
|
|
|
|
|
|
def test_scrub_screens_short_numeric_nested_values_and_mapping_keys() -> None:
|
|
scrubbed = redaction.scrub(
|
|
{
|
|
"token": 1234,
|
|
"credentials": {"x": "y"},
|
|
"ghp_ABCDEF1234567890": "mapping-key",
|
|
"private_key": "x",
|
|
}
|
|
)
|
|
assert scrubbed["token"] == redaction.REDACTED
|
|
assert scrubbed["credentials"] == redaction.REDACTED
|
|
assert scrubbed["private_key"] == redaction.REDACTED
|
|
assert not any("ghp_" in key for key in scrubbed)
|
|
|
|
|
|
def test_unterminated_private_key_blocks_are_screened() -> None:
|
|
assert "PRIVATE KEY" not in redaction.redact("-----BEGIN PRIVATE KEY-----\nabc")
|
|
|
|
|
|
def test_screening_is_idempotent() -> None:
|
|
once = redaction.redact("api_key=AKIAIOSFODNN7EXAMPLE")
|
|
assert redaction.redact(once) == once
|
|
assert redaction.redact("") == ""
|
|
|
|
|
|
def test_normalise_key_strips_separators() -> None:
|
|
assert redaction.normalise_key("Client-Secret_v2") == "clientsecretv2"
|