285 lines
10 KiB
Python
285 lines
10 KiB
Python
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ops import verify_gcp_leoclean_service_environment as verifier
|
|
|
|
|
|
def environment_block(environment: Mapping[str, str]) -> bytes:
|
|
return b"\0".join(f"{key}={value}".encode() for key, value in environment.items()) + b"\0"
|
|
|
|
|
|
def test_all_expected_fields_pass_and_receipt_contains_no_values() -> None:
|
|
environment = verifier.expected_environment()
|
|
environment.update({"HOME": "/home/teleo", "LANG": "C.UTF-8"})
|
|
|
|
parsed = verifier.parse_environment(environment_block(environment))
|
|
validated = verifier.validate_environment(parsed)
|
|
receipt = {
|
|
"runtime_environment": "scoped",
|
|
"status": "pass",
|
|
"validated_fields": list(validated),
|
|
}
|
|
rendered = verifier.canonical_json(receipt)
|
|
|
|
assert validated == tuple(sorted(verifier.expected_environment()))
|
|
assert json.loads(rendered) == receipt
|
|
assert rendered == json.dumps(receipt, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + "\n"
|
|
for value in verifier.expected_environment().values():
|
|
assert value not in rendered
|
|
|
|
|
|
@pytest.mark.parametrize("field", sorted(verifier.expected_environment()))
|
|
def test_each_expected_field_mismatch_fails_closed(field: str) -> None:
|
|
environment = verifier.expected_environment()
|
|
environment[field] = "wrong-value-that-is-not-a-secret"
|
|
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
|
|
assert caught.value.code == "environment_mismatch"
|
|
assert caught.value.fields == (field,)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "unsafe_value", "expected_code"),
|
|
[
|
|
("TELEO_CLOUDSQL_USER", "postgres", "environment_mismatch"),
|
|
(
|
|
"TELEO_CLOUDSQL_PASSWORD_SECRET",
|
|
verifier.ADMINISTRATOR_PASSWORD_SECRET,
|
|
"administrator_secret_reference",
|
|
),
|
|
],
|
|
)
|
|
def test_later_dropin_effective_override_fails_without_outputting_value(
|
|
field: str,
|
|
unsafe_value: str,
|
|
expected_code: str,
|
|
) -> None:
|
|
environment = verifier.expected_environment()
|
|
environment[field] = unsafe_value
|
|
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
|
|
rendered = verifier.canonical_json(verifier.failure_receipt(caught.value))
|
|
assert caught.value.code == expected_code
|
|
assert unsafe_value not in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "safe_label"),
|
|
[
|
|
("PGPASSWORD", "PG*"),
|
|
("pgservice", "PG*"),
|
|
("CLOUDSDK_CORE_PROJECT", "CLOUDSDK_*"),
|
|
("CLOUDSDK_AUTH_ACCESS_TOKEN", "CLOUDSDK_*"),
|
|
("cloudsdk_auth_refresh_token", "CLOUDSDK_*"),
|
|
("GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_APPLICATION_CREDENTIALS"),
|
|
("LD_PRELOAD", "LD_*"),
|
|
("LD_LIBRARY_PATH", "LD_*"),
|
|
("ld_audit", "LD_*"),
|
|
("BASH_ENV", "BASH_ENV"),
|
|
("ENV", "ENV"),
|
|
("BASH_FUNC_injected%%", "BASH_FUNC_*"),
|
|
("PYTHONHOME", "PYTHON*"),
|
|
("PYTHONPATH", "PYTHON*"),
|
|
("PYTHONHTTPSVERIFY", "PYTHON*"),
|
|
("pythonstartup", "PYTHON*"),
|
|
("HTTP_PROXY", "HTTP_PROXY"),
|
|
("https_proxy", "HTTPS_PROXY"),
|
|
("ALL_PROXY", "ALL_PROXY"),
|
|
("no_proxy", "NO_PROXY"),
|
|
("SSL_CERT_FILE", "SSL_CERT_FILE"),
|
|
("ssl_cert_dir", "SSL_CERT_DIR"),
|
|
("REQUESTS_CA_BUNDLE", "REQUESTS_CA_BUNDLE"),
|
|
("curl_ca_bundle", "CURL_CA_BUNDLE"),
|
|
("SSLKEYLOGFILE", "SSLKEYLOGFILE"),
|
|
("TELEO_CLOUDSQL_CREDENTIAL_MODE", "TELEO_CLOUDSQL_CREDENTIAL_MODE"),
|
|
("TELEO_KB_CLAIM_BASE_URL", "TELEO_KB_CLAIM_BASE_URL"),
|
|
("TELEO_KB_READ_ATTEMPTS", "TELEO_KB_READ_ATTEMPTS"),
|
|
("TELEO_MEMORY_INCLUDE_EXCERPTS", "TELEO_MEMORY_INCLUDE_EXCERPTS"),
|
|
("TELEO_MEMORY_REDACT", "TELEO_MEMORY_REDACT"),
|
|
],
|
|
)
|
|
def test_environment_file_broader_credentials_and_forbidden_fields_fail_closed(
|
|
field: str,
|
|
safe_label: str,
|
|
) -> None:
|
|
environment = verifier.expected_environment()
|
|
injected_value = "sensitive-injected-value"
|
|
environment[field] = injected_value
|
|
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
|
|
rendered = verifier.canonical_json(verifier.failure_receipt(caught.value))
|
|
assert caught.value.code == "forbidden_environment_fields"
|
|
assert caught.value.fields == (safe_label,)
|
|
assert injected_value not in rendered
|
|
|
|
|
|
def test_arbitrary_forbidden_key_suffix_is_not_an_output_channel() -> None:
|
|
environment = verifier.expected_environment()
|
|
environment["PG_SECRET_MARKER"] = "not-output"
|
|
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
|
|
rendered = verifier.canonical_json(verifier.failure_receipt(caught.value))
|
|
assert "PG_SECRET_MARKER" not in rendered
|
|
assert "PG*" in rendered
|
|
|
|
|
|
def test_pythonunbuffered_is_the_only_allowed_python_runtime_tuning() -> None:
|
|
environment = verifier.expected_environment()
|
|
environment["PYTHONUNBUFFERED"] = "1"
|
|
|
|
assert verifier.validate_environment(environment) == tuple(sorted(verifier.expected_environment()))
|
|
|
|
|
|
def test_bash_env_executes_before_a_script_but_effective_verifier_rejects_it(tmp_path: Path) -> None:
|
|
marker = "BASH_ENV_EXECUTED"
|
|
payload = tmp_path / "bash-env.sh"
|
|
payload.write_text(f"printf '{marker}\\n'\n", encoding="utf-8")
|
|
completed = subprocess.run(
|
|
["/bin/bash", "-c", ":"],
|
|
env={"BASH_ENV": str(payload), "PATH": verifier.RUNTIME_PATH},
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert completed.returncode == 0
|
|
assert completed.stdout.strip() == marker
|
|
|
|
environment = verifier.expected_environment()
|
|
environment["BASH_ENV"] = str(payload)
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
assert caught.value.fields == ("BASH_ENV",)
|
|
|
|
|
|
def test_admin_secret_reference_in_any_value_fails_without_field_or_value_leak() -> None:
|
|
environment = verifier.expected_environment()
|
|
environment["UNRELATED"] = f"prefix/{verifier.ADMINISTRATOR_PASSWORD_SECRET}/suffix"
|
|
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_environment(environment)
|
|
|
|
rendered = verifier.canonical_json(verifier.failure_receipt(caught.value))
|
|
assert caught.value.code == "administrator_secret_reference"
|
|
assert verifier.ADMINISTRATOR_PASSWORD_SECRET not in rendered
|
|
assert "UNRELATED" not in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
b"A=1",
|
|
b"A=1\0\0",
|
|
b"A=1\0BROKEN\0",
|
|
b"=value\0",
|
|
b"A=1\0A=2\0",
|
|
b"A=\xff\0",
|
|
b"",
|
|
],
|
|
)
|
|
def test_malformed_nul_environment_is_rejected(raw: bytes) -> None:
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.parse_environment(raw)
|
|
|
|
assert caught.value.code == "environment_malformed"
|
|
|
|
|
|
def test_parser_preserves_equals_inside_values() -> None:
|
|
assert verifier.parse_environment(b"A=one=two\0B=\0") == {"A": "one=two", "B": ""}
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -1, 1.5, "0", "01", "+1", "-3", "not-a-pid", True])
|
|
def test_pid_must_be_a_positive_integer(value: object) -> None:
|
|
with pytest.raises(verifier.VerificationError) as caught:
|
|
verifier.validate_pid(value) # type: ignore[arg-type]
|
|
|
|
assert caught.value.code == "invalid_pid"
|
|
|
|
|
|
def test_cli_errors_are_canonical_sanitized_json_and_exit_one(capsys: pytest.CaptureFixture[str]) -> None:
|
|
secret_input = "not-a-pid-secret-marker"
|
|
|
|
returncode = verifier.main(["--pid", secret_input])
|
|
|
|
output = capsys.readouterr()
|
|
assert returncode == 1
|
|
assert output.err == ""
|
|
assert json.loads(output.out) == {"error": {"code": "invalid_pid"}, "status": "fail"}
|
|
assert secret_input not in output.out
|
|
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="requires Linux /proc/PID/environ")
|
|
def test_cli_reads_real_process_environment_and_detects_effective_conflict(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
expected = verifier.expected_environment()
|
|
good_process = subprocess.Popen(
|
|
["/bin/sleep", "30"],
|
|
env=expected,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
try:
|
|
assert verifier.main(["--pid", str(good_process.pid)]) == 0
|
|
good_output = capsys.readouterr()
|
|
assert good_output.err == ""
|
|
assert json.loads(good_output.out)["status"] == "pass"
|
|
finally:
|
|
good_process.terminate()
|
|
good_process.wait(timeout=5)
|
|
|
|
conflicting = dict(expected)
|
|
conflicting["TELEO_CLOUDSQL_USER"] = "postgres"
|
|
conflicting["GOOGLE_APPLICATION_CREDENTIALS"] = "/tmp/broader-credential.json"
|
|
bad_process = subprocess.Popen(
|
|
["/bin/sleep", "30"],
|
|
env=conflicting,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
try:
|
|
assert verifier.main(["--pid", str(bad_process.pid)]) == 1
|
|
bad_output = capsys.readouterr()
|
|
receipt = json.loads(bad_output.out)
|
|
assert bad_output.err == ""
|
|
assert receipt == {
|
|
"error": {"code": "forbidden_environment_fields", "fields": ["GOOGLE_APPLICATION_CREDENTIALS"]},
|
|
"status": "fail",
|
|
}
|
|
assert "postgres" not in bad_output.out
|
|
assert "/tmp/broader-credential.json" not in bad_output.out
|
|
finally:
|
|
bad_process.terminate()
|
|
bad_process.wait(timeout=5)
|
|
|
|
|
|
def test_unexpected_read_error_is_generic_and_does_not_echo_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
sensitive_marker = "sensitive-proc-read-detail"
|
|
|
|
def fail_read(_self: object) -> bytes:
|
|
raise OSError(sensitive_marker)
|
|
|
|
monkeypatch.setattr(verifier.Path, "read_bytes", fail_read)
|
|
|
|
assert verifier.main(["--pid", str(os.getpid())]) == 1
|
|
output = capsys.readouterr()
|
|
assert json.loads(output.out) == {"error": {"code": "internal_error"}, "status": "fail"}
|
|
assert sensitive_marker not in output.out
|