Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ harness-check: ## Validate the Bub replay harness and committed scenarios.
@uv run ruff check e2e/bub
@uv run ruff format --check e2e/bub
@uv run ty check --project e2e/bub --python e2e/bub/.venv e2e/bub/src integrations/bub/src
@uv run --project e2e/bub python -m pytest e2e/bub/tests
@uv run --project e2e/bub powercontext-e2e --help >/dev/null

.PHONY: harness-acceptance
Expand Down
3 changes: 2 additions & 1 deletion e2e/bub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ POWERCONTEXT_E2E_DATABASE=oceanbase make harness-compose-acceptance
```

`make harness-compose-live` uses the provider variables above. Evidence is written below `.powercontext-e2e/bub/`;
set `POWERCONTEXT_E2E_OUTPUT` to keep it elsewhere. `make harness-compose-down` removes containers and database volumes.
set `POWERCONTEXT_E2E_OUTPUT` to keep it elsewhere. Compose containers, networks, and volumes are removed after both
successful and failed runs. `make harness-compose-down` remains available as an idempotent manual cleanup.
6 changes: 6 additions & 0 deletions e2e/bub/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies = [
"pyyaml>=6,<7",
]

[dependency-groups]
dev = ["pytest>=9.0.2"]

[project.scripts]
powercontext-e2e = "powercontext_e2e.__main__:main"

Expand All @@ -38,5 +41,8 @@ line-length = 120
select = ["A", "B", "C4", "C90", "E", "F", "I", "PGH", "RUF", "S", "SIM", "T10", "TRY", "UP", "W", "YTT"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]

[tool.ruff.format]
preview = true
22 changes: 20 additions & 2 deletions e2e/bub/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ if [ -z "${GITHUB_SHA:-}" ]; then
export GITHUB_SHA
fi

cleanup() {
status=$?
trap - EXIT INT TERM
set +e

docker compose $compose_files down --volumes --remove-orphans
cleanup_status=$?
if [ "$cleanup_status" -ne 0 ]; then
echo "Compose cleanup failed with exit code $cleanup_status" >&2
if [ "$status" -eq 0 ]; then
status=$cleanup_status
fi
fi
exit "$status"
}

trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

docker compose $compose_files build powercontext harness
docker compose $compose_files up --detach --wait powercontext

Expand All @@ -81,5 +101,3 @@ else
scenario=${POWERCONTEXT_E2E_SCENARIO:-e2e/bub/scenarios/project-database-decision.yaml}
docker compose $compose_files run --rm harness live "$scenario" --output /evidence
fi

docker compose $compose_files down --volumes --remove-orphans
190 changes: 190 additions & 0 deletions e2e/bub/src/powercontext_e2e/redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Credential redaction at the replay evidence boundary."""

from __future__ import annotations

import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qsl, quote, unquote, urlsplit

REDACTED = "[REDACTED]"

_SENSITIVE_ENVIRONMENT_NAMES = frozenset({
"api_key",
"authorization",
"credentials",
"database_url",
"mysql_pwd",
"password",
"pgpassword",
"private_key",
"secret",
"token",
})
_SENSITIVE_ENVIRONMENT_SUFFIXES = (
"_access_key_id",
"_api_key",
"_authorization",
"_client_secret",
"_credentials",
"_database_url",
"_dsn",
"_password",
"_private_key",
"_secret",
"_secret_access_key",
"_secret_key",
"_token",
)
_SENSITIVE_FIELD_NAMES = frozenset({
"access_token",
"api_key",
"auth_token",
"authorization",
"client_secret",
"connection_string",
"credentials",
"database_url",
"dsn",
"password",
"private_key",
"proxy_authorization",
"refresh_token",
"secret",
"secret_key",
"token",
})
_SENSITIVE_FIELD_SUFFIXES = (
"_access_token",
"_api_key",
"_auth_token",
"_authorization",
"_client_secret",
"_connection_string",
"_credentials",
"_database_url",
"_dsn",
"_password",
"_private_key",
"_refresh_token",
"_secret",
"_secret_key",
)
_DATABASE_URL = re.compile(
r"(?i)\b(?:cockroachdb|mariadb|mssql|mysql|oceanbase|oracle|postgres|postgresql|sqlite)"
r"(?:\+[a-z0-9_.-]+)?://[^\s\"'`<>{}\[\](),;\\]+"
)
_URL_PASSWORD = re.compile(r"(?i)(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@/\s\"'`<>]+(@)")
_URL_QUERY_CREDENTIAL = re.compile(
r"(?i)([?&](?:access[_-]?token|api[_-]?key|auth[_-]?token|client[_-]?secret|password|refresh[_-]?token|secret|token)=)"
r"[^&#\s\"'`]+"
)
_CREDENTIAL_ASSIGNMENT = re.compile(
r"(?i)(\b(?:access[_-]?token|api[_-]?key|auth[_-]?token|authorization|client[_-]?secret|password|secret)"
r"\b\s*[:=]\s*)(?:(?:basic|bearer)\s+)?[^\s,;&\"'`]+"
)
_CREDENTIAL_OPTION = re.compile(
r"(?i)(--(?:access-token|api-key|auth-token|client-secret|password|token)\s+)"
r"[^\s,;\"'`]+"
)
_BEARER_CREDENTIAL = re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{16,}")
_PROVIDER_CREDENTIAL = re.compile(r"(?<![a-zA-Z0-9_-])sk-[a-zA-Z0-9_-]{8,}(?![a-zA-Z0-9_-])")


@dataclass(frozen=True)
class EvidenceRedactor:
"""Remove configured and structurally recognizable credentials from evidence."""

secrets: tuple[str, ...]

@classmethod
def from_environment(cls) -> EvidenceRedactor:
secrets: set[str] = set()
for name, value in os.environ.items():
if value and _is_sensitive_environment_name(name):
secrets.update(_secret_variants(value))
return cls(tuple(sorted(secrets, key=lambda item: (-len(item), item))))

def redact(self, value: Any) -> Any:
"""Recursively redact JSON-compatible evidence without changing its normal shape."""
return self._redact(value, sensitive=False)

def redact_text(self, value: str) -> str:
"""Redact one final serialized evidence value."""
for secret in self.secrets:
if len(secret) >= 8:
value = value.replace(secret, REDACTED)
else:
value = re.sub(rf"(?<![a-zA-Z0-9]){re.escape(secret)}(?![a-zA-Z0-9])", REDACTED, value)
value = _DATABASE_URL.sub(REDACTED, value)
value = _URL_PASSWORD.sub(rf"\1{REDACTED}\2", value)
value = _URL_QUERY_CREDENTIAL.sub(rf"\1{REDACTED}", value)
value = _CREDENTIAL_ASSIGNMENT.sub(rf"\1{REDACTED}", value)
value = _CREDENTIAL_OPTION.sub(rf"\1{REDACTED}", value)
value = _PROVIDER_CREDENTIAL.sub(REDACTED, value)
return _BEARER_CREDENTIAL.sub(REDACTED, value)

def _redact(self, value: Any, *, sensitive: bool) -> Any:
if isinstance(value, str):
return REDACTED if sensitive and value else self.redact_text(value)
if isinstance(value, Mapping):
return {
self.redact_text(key) if isinstance(key, str) else key: self._redact(
item,
sensitive=sensitive or (isinstance(key, str) and _is_sensitive_field_name(key)),
)
for key, item in value.items()
}
if isinstance(value, list):
return [self._redact(item, sensitive=sensitive) for item in value]
if isinstance(value, tuple):
return tuple(self._redact(item, sensitive=sensitive) for item in value)
return value


def _is_sensitive_environment_name(name: str) -> bool:
normalized = _normalize_name(name)
return normalized in _SENSITIVE_ENVIRONMENT_NAMES or normalized.endswith(_SENSITIVE_ENVIRONMENT_SUFFIXES)


def _is_sensitive_field_name(name: str) -> bool:
normalized = _normalize_name(name)
return normalized in _SENSITIVE_FIELD_NAMES or normalized.endswith(_SENSITIVE_FIELD_SUFFIXES)


def _normalize_name(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", value.casefold()).strip("_")


def _secret_variants(value: str) -> set[str]:
variants = {value}
stripped = value.strip()
if stripped:
variants.add(stripped)
lowered = stripped.casefold()
for prefix in ("basic ", "bearer "):
if lowered.startswith(prefix):
variants.add(stripped[len(prefix) :])
variants.update(_url_secret_variants(stripped))
for item in tuple(variants):
if item:
variants.add(quote(item, safe=""))
return {item for item in variants if item}


def _url_secret_variants(value: str) -> set[str]:
try:
parsed = urlsplit(value)
except ValueError:
return set()
if not parsed.netloc:
return set()
variants = set()
if parsed.password:
variants.update((parsed.password, unquote(parsed.password)))
for name, item in parse_qsl(parsed.query, keep_blank_values=True):
if item and _is_sensitive_field_name(name):
variants.update((item, unquote(item)))
return variants
29 changes: 19 additions & 10 deletions e2e/bub/src/powercontext_e2e/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ScenarioSpec,
SessionObservation,
)
from .redaction import EvidenceRedactor

Mode = Literal["acceptance", "live", "offline-rescore"]
Report = EvaluationReport[ScenarioSpec, ReplayObservation, dict[str, str]]
Expand Down Expand Up @@ -391,15 +392,17 @@ def _commit() -> str:


def _redact(value: str) -> str:
secret = os.getenv("BUB_API_KEY")
return value.replace(secret, "[REDACTED]") if secret else value
return EvidenceRedactor.from_environment().redact_text(value)

Comment thread
thunguo marked this conversation as resolved.
Outdated

def write_artifacts(observation: ReplayObservation, report: Report, output_dir: Path) -> None:
redactor = EvidenceRedactor.from_environment()
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "replay.json").write_text(
observation.model_dump_json(by_alias=True, indent=2) + "\n",
encoding="utf-8",
replay_payload = json.loads(observation.model_dump_json(by_alias=True))
_write_evidence(
output_dir / "replay.json",
json.dumps(redactor.redact(replay_payload), indent=2, ensure_ascii=False) + "\n",
redactor,
Comment thread
thunguo marked this conversation as resolved.
Outdated
)

cases = [
Expand Down Expand Up @@ -428,17 +431,23 @@ def write_artifacts(observation: ReplayObservation, report: Report, output_dir:
"cases": cases,
"failures": [{"name": failure.name, "error": failure.error_message} for failure in report.failures],
}
(output_dir / "eval-report.json").write_text(
json.dumps(report_payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
_write_evidence(
output_dir / "eval-report.json",
json.dumps(redactor.redact(report_payload), indent=2, sort_keys=True, ensure_ascii=False) + "\n",
redactor,
)
(output_dir / "report.md").write_text(
_write_evidence(
output_dir / "report.md",
"# PowerContext session replay\n\n"
f"- Scenario: `{observation.scenario.id}`\n"
f"- Mode: `{observation.environment.mode}`\n"
f"- Database: `{observation.environment.database}`\n"
f"- Status: `{observation.status}`\n\n"
"## Evaluation\n\n"
f"```text\n{report.render(include_reasons=True)}\n```\n",
encoding="utf-8",
redactor,
)


def _write_evidence(path: Path, content: str, redactor: EvidenceRedactor) -> None:
path.write_text(redactor.redact_text(content), encoding="utf-8")
Loading