174 lines
8 KiB
Python
174 lines
8 KiB
Python
|
|
"""Credential delivery and evidence checks; synthetic tokens are never login proof."""
|
||
|
|
import base64
|
||
|
|
import importlib.util
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
import unittest
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
SCRIPT = ROOT / "scripts/playwright_consumer.py"
|
||
|
|
spec = importlib.util.spec_from_file_location("playwright_consumer", SCRIPT)
|
||
|
|
consumer = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(consumer)
|
||
|
|
|
||
|
|
|
||
|
|
class ConsumerTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get("PLAYWRIGHT_TEST_TMPDIR", "/tmp"))
|
||
|
|
self.addCleanup(self.temp.cleanup)
|
||
|
|
self.directory = Path(self.temp.name)
|
||
|
|
self.token = self.directory / "token.json"
|
||
|
|
self.config = self.directory / "kubeconfig.json"
|
||
|
|
self.claims = dict(iss=consumer.ISSUER, aud=consumer.AUDIENCE,
|
||
|
|
sub="synthetic", principal_type="human", scope=consumer.SCOPE,
|
||
|
|
iat=int(time.time()), exp=int(time.time()) + 900)
|
||
|
|
self.cluster = consumer.configuration(str(self.token))["clusters"][0]["cluster"]
|
||
|
|
|
||
|
|
def write_token(self):
|
||
|
|
part = base64.urlsafe_b64encode(json.dumps(self.claims).encode()).decode().rstrip("=")
|
||
|
|
value = "synthetic." + part + ".not-a-real-signature"
|
||
|
|
self.token.write_text(json.dumps(dict(access_token=value, token_type="Bearer")))
|
||
|
|
self.token.chmod(0o600)
|
||
|
|
return value
|
||
|
|
|
||
|
|
def invoke(self, cluster=None):
|
||
|
|
env = dict(os.environ, KUBERNETES_EXEC_INFO=json.dumps({
|
||
|
|
"apiVersion": consumer.API, "kind": "ExecCredential",
|
||
|
|
"spec": {"cluster": self.cluster if cluster is None else cluster},
|
||
|
|
}))
|
||
|
|
return subprocess.run([sys.executable, str(SCRIPT), "credential", "--token-file",
|
||
|
|
str(self.token)], env=env, capture_output=True, text=True)
|
||
|
|
|
||
|
|
def test_exec_delivers_only_expected_credential_and_expiry(self):
|
||
|
|
token = self.write_token()
|
||
|
|
result = self.invoke()
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
value = json.loads(result.stdout)
|
||
|
|
self.assertEqual(value["status"]["token"], token)
|
||
|
|
self.assertTrue(value["status"]["expirationTimestamp"].endswith("Z"))
|
||
|
|
self.assertEqual(result.stderr, "")
|
||
|
|
|
||
|
|
def test_registered_coding_agent_supported(self):
|
||
|
|
self.claims.update(principal_type="service", roles=["coding-agent"])
|
||
|
|
self.write_token()
|
||
|
|
self.assertEqual(self.invoke().returncode, 0)
|
||
|
|
|
||
|
|
def test_wrong_destination_refused_before_token_read(self):
|
||
|
|
variants = [dict(server="https://example.org"), {"proxy-url": "http://example.org"},
|
||
|
|
{"insecure-skip-tls-verify": True}, {"tls-server-name": "other"},
|
||
|
|
{"certificate-authority-data": "ZmFrZQ=="}]
|
||
|
|
for change in variants:
|
||
|
|
with self.subTest(change=change), patch.object(consumer, "read_token") as read:
|
||
|
|
info = dict(apiVersion=consumer.API, kind="ExecCredential",
|
||
|
|
spec={"cluster": dict(self.cluster, **change)})
|
||
|
|
with patch.dict(os.environ, KUBERNETES_EXEC_INFO=json.dumps(info)):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
consumer.credential(str(self.token))
|
||
|
|
read.assert_not_called()
|
||
|
|
|
||
|
|
def test_wrong_claims_fail_without_leaking_token(self):
|
||
|
|
original = self.claims.copy()
|
||
|
|
variants = [{"iss": "https://other.example"}, {"aud": "openbao"},
|
||
|
|
{"aud": [consumer.AUDIENCE, "openbao"]}, {"scope": "openid"},
|
||
|
|
{"principal_type": "service", "roles": []}, {"sub": ""},
|
||
|
|
{"exp": int(time.time()) - 5}, {"exp": True},
|
||
|
|
{"iat": int(time.time()) + 100}, {"exp": int(time.time()) + 7200}]
|
||
|
|
for change in variants:
|
||
|
|
with self.subTest(change=change):
|
||
|
|
self.claims = dict(original, **change)
|
||
|
|
token = self.write_token()
|
||
|
|
result = self.invoke()
|
||
|
|
self.assertNotEqual(result.returncode, 0)
|
||
|
|
self.assertEqual(result.stdout, "")
|
||
|
|
self.assertNotIn(token, result.stderr)
|
||
|
|
|
||
|
|
def test_file_permissions_symlinks_and_hardlinks_refused(self):
|
||
|
|
self.write_token()
|
||
|
|
self.token.chmod(0o644)
|
||
|
|
self.assertNotEqual(self.invoke().returncode, 0)
|
||
|
|
self.token.chmod(0o600)
|
||
|
|
other = self.directory / "other.json"
|
||
|
|
os.link(self.token, other)
|
||
|
|
self.assertNotEqual(self.invoke().returncode, 0)
|
||
|
|
self.token.unlink()
|
||
|
|
self.token.symlink_to(other)
|
||
|
|
self.assertNotEqual(self.invoke().returncode, 0)
|
||
|
|
|
||
|
|
def test_nonprivate_parent_and_git_worktree_refused(self):
|
||
|
|
self.directory.chmod(0o755)
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
consumer.configuration(str(self.token))
|
||
|
|
self.directory.chmod(0o700)
|
||
|
|
(self.directory / ".git").write_text("gitdir: synthetic")
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
consumer.configuration(str(self.token))
|
||
|
|
|
||
|
|
def test_setup_no_credentials_no_overwrite_and_kubectl_parses(self):
|
||
|
|
consumer.setup(str(self.token), str(self.config))
|
||
|
|
config = json.loads(self.config.read_text())
|
||
|
|
self.assertEqual(self.config.stat().st_mode & 0o777, 0o600)
|
||
|
|
self.assertEqual(set(config["users"][0]["user"]), {"exec"})
|
||
|
|
with self.assertRaises(FileExistsError):
|
||
|
|
consumer.setup(str(self.token), str(self.config))
|
||
|
|
result = subprocess.run(["kubectl", "--kubeconfig", str(self.config),
|
||
|
|
"config", "view", "-o", "json"], capture_output=True, text=True)
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
self.assertEqual(json.loads(result.stdout)["current-context"], "playwright")
|
||
|
|
|
||
|
|
def test_verify_rejects_impersonation_configuration_without_api_call(self):
|
||
|
|
config = consumer.configuration(str(self.token))
|
||
|
|
config["users"][0]["user"]["as"] = "keycape:human:fake"
|
||
|
|
self.config.write_text(json.dumps(config))
|
||
|
|
with patch.object(consumer.subprocess, "run") as run:
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
consumer.verify(str(self.config))
|
||
|
|
run.assert_not_called()
|
||
|
|
|
||
|
|
def test_verify_requires_real_identity_and_all_permission_results(self):
|
||
|
|
self.config.write_text(json.dumps(consumer.configuration(str(self.token))))
|
||
|
|
calls = []
|
||
|
|
|
||
|
|
def api(argv, **kwargs):
|
||
|
|
calls.append(argv)
|
||
|
|
body = json.loads(kwargs["input"])
|
||
|
|
if body["kind"] == "SelfSubjectReview":
|
||
|
|
data = {"status": {"userInfo": {"username": "keycape:human:synthetic",
|
||
|
|
"groups": ["system:authenticated", "playwright-consumers"]}}}
|
||
|
|
else:
|
||
|
|
data = {"status": {"allowed": len(calls) <= 4}}
|
||
|
|
return subprocess.CompletedProcess(argv, 0, json.dumps(data), "")
|
||
|
|
|
||
|
|
with patch.object(consumer.subprocess, "run", side_effect=api):
|
||
|
|
receipt = consumer.verify(str(self.config))
|
||
|
|
self.assertEqual(len(receipt["checks_passed"]), 9)
|
||
|
|
self.assertFalse(receipt["impersonated"])
|
||
|
|
self.assertTrue(all("--kubeconfig" in argv and "--as" not in argv for argv in calls))
|
||
|
|
|
||
|
|
with patch.object(consumer.subprocess, "run", return_value=subprocess.CompletedProcess(
|
||
|
|
[], 0, json.dumps({"status": {"userInfo": {"username": "system:admin",
|
||
|
|
"groups": ["system:masters"]}}}), "")):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
consumer.verify(str(self.config))
|
||
|
|
|
||
|
|
calls.clear()
|
||
|
|
|
||
|
|
def excessive_permission(argv, **kwargs):
|
||
|
|
result = api(argv, **kwargs)
|
||
|
|
if len(calls) > 1:
|
||
|
|
result.stdout = json.dumps({"status": {"allowed": True}})
|
||
|
|
return result
|
||
|
|
|
||
|
|
with patch.object(consumer.subprocess, "run", side_effect=excessive_permission):
|
||
|
|
with self.assertRaisesRegex(ValueError, "secrets-denied"):
|
||
|
|
consumer.verify(str(self.config))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|