주기 회기 진단과 로그인 폭 보강
This commit is contained in:
parent
946661926b
commit
f97e7fadac
10 changed files with 216 additions and 17 deletions
|
|
@ -103,6 +103,17 @@ CRITICAL_UNTRACKED_SUFFIXES = frozenset(
|
|||
)
|
||||
PROJECT_RE = re.compile(r"^vignette-periodic-[0-9a-f]{8}-[a-z0-9-]{6,32}$")
|
||||
HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SENSITIVE_ENV_KEY_RE = re.compile(
|
||||
r"(?:PASSWORD|SECRET|TOKEN|API_KEY)$|^ENGINE_GATEWAY_SHARED_SECRET$"
|
||||
)
|
||||
DIAGNOSTIC_TAIL_CHARS = 12_000
|
||||
EMAIL_RE = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b")
|
||||
UUID_RE = re.compile(
|
||||
r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b"
|
||||
)
|
||||
SENSITIVE_QUERY_RE = re.compile(
|
||||
r"(?i)([?&](?:code|email|token|secret|session|state)=)[^&\s]+"
|
||||
)
|
||||
|
||||
|
||||
class GateError(RuntimeError):
|
||||
|
|
@ -362,6 +373,27 @@ def _safe_error(exc: BaseException) -> str:
|
|||
return str(exc).replace(str(REPO_ROOT), "<repo>")[-1600:]
|
||||
|
||||
|
||||
def _sanitize_diagnostic_text(text: str, environ: Mapping[str, str]) -> str:
|
||||
sanitized = text.replace(str(REPO_ROOT), "<repo>")
|
||||
sensitive_values = sorted(
|
||||
{
|
||||
value
|
||||
for key, value in environ.items()
|
||||
if value and SENSITIVE_ENV_KEY_RE.search(key)
|
||||
},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
for value in sensitive_values:
|
||||
sanitized = sanitized.replace(value, "<redacted>")
|
||||
sanitized = EMAIL_RE.sub("<redacted-email>", sanitized)
|
||||
sanitized = UUID_RE.sub("<redacted-uuid>", sanitized)
|
||||
sanitized = SENSITIVE_QUERY_RE.sub(r"\1<redacted>", sanitized)
|
||||
if any(value in sanitized for value in sensitive_values):
|
||||
raise GateError("failure diagnostics retained a runtime secret")
|
||||
return sanitized[-DIAGNOSTIC_TAIL_CHARS:]
|
||||
|
||||
|
||||
def assert_safe_value(label: str, value: str) -> None:
|
||||
normalized = value.strip().lower().replace("\\", "/")
|
||||
if any(marker.lower().replace("\\", "/") in normalized for marker in FORBIDDEN_MARKERS):
|
||||
|
|
@ -1056,6 +1088,46 @@ class PeriodicRunner:
|
|||
proof["errors"].append(_safe_error(exc))
|
||||
return proof
|
||||
|
||||
def _capture_failure_diagnostics(
|
||||
self, stack_env: Mapping[str, str]
|
||||
) -> dict[str, Any]:
|
||||
if not self.state.stack_attempted:
|
||||
return {"captured": False, "reason": "stack_not_attempted"}
|
||||
diagnostics: dict[str, Any] = {
|
||||
"captured": True,
|
||||
"secret_value_emitted": False,
|
||||
"commands": {},
|
||||
}
|
||||
commands = (
|
||||
(
|
||||
"compose_ps",
|
||||
self._compose_argv("ps", "--format", "json"),
|
||||
),
|
||||
(
|
||||
"compose_logs",
|
||||
self._compose_argv(
|
||||
"logs",
|
||||
"--no-color",
|
||||
"--timestamps",
|
||||
"--tail",
|
||||
"240",
|
||||
"db",
|
||||
"api",
|
||||
),
|
||||
),
|
||||
)
|
||||
for stage, argv in commands:
|
||||
result = self._run(stage, argv, timeout=90, check=False)
|
||||
combined = "\n".join(part for part in (result.stdout, result.stderr) if part)
|
||||
sanitized = _sanitize_diagnostic_text(combined, stack_env)
|
||||
diagnostics["commands"][stage] = {
|
||||
"exit_code": result.returncode,
|
||||
"duration_seconds": round(result.duration_seconds, 3),
|
||||
"raw_sha256": hashlib.sha256(combined.encode("utf-8")).hexdigest(),
|
||||
"sanitized_tail": sanitized,
|
||||
}
|
||||
return diagnostics
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_green(cleanup: Mapping[str, Any]) -> bool:
|
||||
return (
|
||||
|
|
@ -1116,6 +1188,7 @@ class PeriodicRunner:
|
|||
status = "FAILED"
|
||||
error = ""
|
||||
cleanup: dict[str, Any] = {}
|
||||
stack_env: dict[str, str] = {}
|
||||
try:
|
||||
self.state.source = self.source_identity(require_clean=True)
|
||||
self.state.runtime = build_runtime_identity(self.state.source)
|
||||
|
|
@ -1329,6 +1402,16 @@ class PeriodicRunner:
|
|||
status = "GREEN"
|
||||
except BaseException as exc:
|
||||
error = _safe_error(exc)
|
||||
if self.state.stack_attempted:
|
||||
try:
|
||||
self.state.proof["failure_diagnostics"] = (
|
||||
self._capture_failure_diagnostics(stack_env)
|
||||
)
|
||||
except BaseException as diagnostic_exc:
|
||||
self.state.proof["failure_diagnostics"] = {
|
||||
"captured": False,
|
||||
"reason": _safe_error(diagnostic_exc),
|
||||
}
|
||||
finally:
|
||||
cleanup = self._cleanup()
|
||||
if not self._cleanup_green(cleanup):
|
||||
|
|
|
|||
|
|
@ -183,6 +183,75 @@ def config(receipt_path: Path) -> runner.RunnerConfig:
|
|||
|
||||
|
||||
class SafetyContractTests(unittest.TestCase):
|
||||
def test_failure_diagnostics_redact_known_secrets_and_precede_cleanup(self) -> None:
|
||||
class DiagnosticFake(FakeController):
|
||||
def run(self, stage, argv, **kwargs):
|
||||
result = super().run(stage, argv, **kwargs)
|
||||
if stage == "compose_ps":
|
||||
return runner.CommandResult(
|
||||
0,
|
||||
'[{"Service":"db","Health":"unhealthy"}]',
|
||||
"",
|
||||
0.02,
|
||||
)
|
||||
if stage == "compose_logs":
|
||||
return runner.CommandResult(
|
||||
0,
|
||||
"db init failed with owner-secret-value for learner@hs.ac.kr",
|
||||
(
|
||||
"api waiting for engine-secret-value at "
|
||||
"123e4567-e89b-42d3-a456-426614174000?token=visible"
|
||||
),
|
||||
0.03,
|
||||
)
|
||||
return result
|
||||
|
||||
fake = DiagnosticFake()
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
|
||||
periodic.state.runtime = runner.RuntimeIdentity(
|
||||
"20260812T070000-abcdef12",
|
||||
"vignette-periodic-11111111-abcdef12",
|
||||
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
|
||||
18080,
|
||||
18443,
|
||||
15439,
|
||||
19199,
|
||||
)
|
||||
periodic.state.docker_context = "desktop-linux"
|
||||
periodic.state.env_file = Path(raw) / "stack.env"
|
||||
periodic.state.override_file = Path(raw) / "override.yml"
|
||||
periodic.state.stack_attempted = True
|
||||
proof = periodic._capture_failure_diagnostics(
|
||||
{
|
||||
"POSTGRES_PASSWORD": "owner-secret-value",
|
||||
"ENGINE_GATEWAY_SHARED_SECRET": "engine-secret-value",
|
||||
"POSTGRES_DB": "vignette_periodic",
|
||||
}
|
||||
)
|
||||
periodic._cleanup()
|
||||
|
||||
rendered = json.dumps(proof, ensure_ascii=False)
|
||||
self.assertTrue(proof["captured"])
|
||||
self.assertFalse(proof["secret_value_emitted"])
|
||||
self.assertNotIn("owner-secret-value", rendered)
|
||||
self.assertNotIn("engine-secret-value", rendered)
|
||||
self.assertNotIn("learner@hs.ac.kr", rendered)
|
||||
self.assertNotIn("123e4567-e89b-42d3-a456-426614174000", rendered)
|
||||
self.assertNotIn("token=visible", rendered)
|
||||
self.assertIn("<redacted>", rendered)
|
||||
self.assertIn("<redacted-email>", rendered)
|
||||
self.assertIn("<redacted-uuid>", rendered)
|
||||
stages = [stage for stage, _ in fake.commands]
|
||||
self.assertLess(stages.index("compose_logs"), stages.index("compose_down"))
|
||||
|
||||
def test_failure_diagnostic_tail_is_bounded(self) -> None:
|
||||
sanitized = runner._sanitize_diagnostic_text(
|
||||
"x" * (runner.DIAGNOSTIC_TAIL_CHARS + 50),
|
||||
{"SESSION_SECRET": "not-present-secret"},
|
||||
)
|
||||
self.assertEqual(len(sanitized), runner.DIAGNOSTIC_TAIL_CHARS)
|
||||
|
||||
def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None:
|
||||
for port in sorted(runner.FORBIDDEN_PORTS):
|
||||
with self.subTest(port=port):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue