"""Engine unit tests. Run: python3 tests/unit/test_engine.py""" import os import sys import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) _TMP = tempfile.mkdtemp(prefix="ctxguard-test-") os.environ["CTXGUARD_HOME"] = _TMP from ctxguard import detect, policy as policy_mod, substitute from ctxguard.store import Store, base26 SLUG = "test-project" def engine(entities=()): store = Store(SLUG) pol = policy_mod.Policy() for value, type_, match, hint in entities: store.add(value, type_, match=match, hint=hint, prefix=pol.alias_prefix) return substitute.Engine(store, pol), store class TestSecrets(unittest.TestCase): def test_vendor_keys_are_replaced_by_markers(self): eng, _ = engine() cases = [ "AKIA1234567890ABCDEF", "ghp_Rf2Lp9Zt4Wm1Nb8Kd3Vy6Qs5Hj7Xc0Ae1Bg9", "sk-ant-api03-Rf2Lp9Zt4Wm1Nb8Kd3Vy6Qs5Hj7Xc0", "glpat-Rf2Lp9Zt4Wm1Nb8Kd3Vy", "xoxb-123456789012-Rf2Lp9Zt4Wm1", ] for secret in cases: with self.subTest(secret=secret[:8]): out = eng.redact(f"key = {secret} end") self.assertNotIn(secret, out.text) self.assertIn("", ): with self.subTest(line=line): self.assertEqual(eng.redact(line).secret_count, 0) def test_dev_password_assignment_is_not_a_secret(self): eng, _ = engine() self.assertEqual(eng.redact("POSTGRES_PASSWORD: service").secret_count, 0) def test_high_entropy_assignment_is_a_secret(self): eng, _ = engine() out = eng.redact('api_key = "Xq7Rf2Lp9Zt4Wm1Nb8Kd3Vy6"') self.assertEqual(out.secret_count, 1) def test_own_markers_are_not_detected_as_secrets(self): """Regression: the word SECRET inside `` matched the keyword rule, so the leak metric counted the engine's own redactions and could never reach zero.""" eng, _ = engine() marked = "SERVICE_API_KEY: " self.assertEqual(eng.redact(marked).secret_count, 0) self.assertEqual(eng.redact(marked).text, marked) def test_rule_names_in_prose_are_not_secrets(self): """A marker quoted with the hash elided -- `` -- does not match MARKER_RE, so the rule name inside read as a credential.""" eng, _ = engine() for text in ("a handful of `` markers", "rules fired: secret:aws-access-key"): with self.subTest(text=text): self.assertEqual(eng.redact(text).secret_count, 0) def test_our_own_report_format_is_not_self_detected(self): """`secret:generic-assignment=85` read as keyword-colon-value, so piping a report through the shell filter redacted its own labels.""" from ctxguard.cli import _render eng, _ = engine() report = _render({"secret:generic-assignment": 85, "secret:aws-access-key": 3}) self.assertEqual(eng.redact(report).secret_count, 0) self.assertEqual(eng.redact(report).text, report) def test_redacting_twice_is_stable(self): eng, _ = engine() once = eng.redact("k = AKIA2X7QP4LMZ9KDVR3T").text twice = eng.redact(once).text self.assertEqual(once, twice) def test_secrets_are_irreversible(self): eng, _ = engine() secret = "AKIA1234567890ABCDEF" redacted = eng.redact(f"k={secret}").text restored, _ = eng.rehydrate(redacted) self.assertNotIn(secret, restored) self.assertTrue(eng.has_secret_marker(restored)) class TestEntities(unittest.TestCase): def setUp(self): self.eng, self.store = engine([ ("Acme Corp", "company", "exact", "retail customer"), ("Иван Петров", "person", "stem", "backend developer"), ("db.acme.internal", "host", "exact", "prod database"), ]) def test_company_alias(self): out = self.eng.redact("Acme Corp asked for a report") self.assertIn("CTXG_COMPANY_A", out.text) self.assertNotIn("Acme", out.text) def test_homoglyph_evasion_is_caught(self): out = self.eng.redact("Aсme Corp") # Cyrillic 'с' self.assertIn("CTXG_COMPANY_A", out.text) def test_separator_variants(self): for variant in ("acme-corp", "AcmeCorp", "ACME_CORP"): with self.subTest(variant=variant): self.assertIn("CTXG_COMPANY_A", self.eng.redact(variant).text) def test_russian_declensions(self): for form in ("Иван Петров", "Ивана Петрова", "Ивану Петрову", "Иваном Петровым"): with self.subTest(form=form): out = self.eng.redact(f"Задачу закрыл {form}.") self.assertIn("CTXG_PERSON_A", out.text) self.assertNotIn("Петров", out.text) def test_longest_match_wins(self): self.store.add("Acme", "company", prefix="CTXG_") eng = substitute.Engine(self.store, policy_mod.Policy()) out = eng.redact("Acme Corp") self.assertIn("CTXG_COMPANY_A", out.text) self.assertNotIn("CTXG_COMPANY_B", out.text) def test_roundtrip(self): original = "Иван Петров из Acme Corp правит db.acme.internal" redacted = self.eng.redact(original).text restored, n = self.eng.rehydrate(redacted) self.assertEqual(restored, original) self.assertEqual(n, 3) def test_glossary_never_leaks_values(self): blob = repr(self.store.glossary()) for forbidden in ("Acme", "Петров", "db.acme.internal"): self.assertNotIn(forbidden, blob) def test_hint_repeating_the_value_is_rejected(self): with self.assertRaises(ValueError): self.store.add("Globex", "company", hint="Globex is a customer") class TestIdentMode(unittest.TestCase): """Regression from a live run: the real vendor name leaked through code identifiers, where boundary-anchored patterns cannot match.""" def setUp(self): self.eng, self.store = engine([("Contoso", "company", "ident", "address vendor")]) def test_matches_inside_identifiers(self): for text in ("ContosoClient", "struct ContosoConfig {", "CONTOSO_API_KEY=x", "src/clients/contoso.rs", "use crate::contoso::Client;"): with self.subTest(text=text): out = self.eng.redact(text).text self.assertNotIn("adata", out, f"real name survived in {out!r}") def test_exact_mode_would_have_missed_these(self): store = Store(SLUG) store.add("Contoso", "company", match="exact", prefix="CTXG_") eng = substitute.Engine(store, policy_mod.Policy()) self.assertIn("ContosoClient", eng.redact("ContosoClient").text) def test_case_survives_the_round_trip(self): for original in ("ContosoClient", "CONTOSO_API_KEY", "contoso.rs", "Contoso and CONTOSO and contoso"): with self.subTest(original=original): red = self.eng.redact(original).text restored, n = self.eng.rehydrate(red) self.assertEqual(restored, original) self.assertGreater(n, 0) def test_cli_exposes_the_mode(self): """The engine supported ident before the CLI did, so it was unreachable.""" from ctxguard.cli import build_parser args = build_parser().parse_args( ["entity", "add", "Contoso", "--type", "company", "--match", "ident"]) self.assertEqual(args.match, "ident") def test_short_values_are_refused(self): with self.assertRaises(ValueError): self.store.add("ACL", "project", match="ident") class TestPII(unittest.TestCase): def test_email_is_reversible(self): eng, _ = engine() original = "write to ivan.petrov@acme.ru please" red = eng.redact(original) self.assertNotIn("ivan.petrov@acme.ru", red.text) self.assertIn("CTXG_EMAIL_", red.text) restored, n = eng.rehydrate(red.text) self.assertEqual(restored, original) self.assertEqual(n, 1) def test_same_email_same_alias(self): eng, _ = engine() out = eng.redact("a@b.ru and a@b.ru").text aliases = {t for t in out.split() if t.startswith("CTXG_EMAIL_")} self.assertEqual(len(aliases), 1) def test_russian_phone(self): eng, _ = engine() for form in ("+7 916 123-45-67", "+79161234567", "8 (916) 123-45-67"): with self.subTest(form=form): self.assertIn("CTXG_PHONE_", eng.redact(f"тел {form}").text) def test_card_requires_luhn(self): eng, _ = engine() self.assertIn("CTXG_ID_", eng.redact("card 4111111111111111").text) self.assertNotIn("CTXG_ID_", eng.redact("id 4111111111111112").text) def test_card_requires_a_real_issuer_prefix(self): """Regression: Luhn alone passes 1 in 10 random digit runs, so lockfile checksums and base64 blobs were reported as payment cards.""" eng, _ = engine() # Luhn-valid but no issuer ever started with 9 or 1 self.assertNotIn("CTXG_ID_", eng.redact("n 9999999999999995").text) self.assertNotIn("CTXG_ID_", eng.redact("n 1234567890128").text) for card in ("4111111111111111", "5500005555555559", "371449635398431"): with self.subTest(card=card): self.assertIn("CTXG_ID_", eng.redact(f"card {card}").text) def test_digits_inside_a_hex_token_are_not_a_card(self): eng, _ = engine() self.assertNotIn("CTXG_ID_", eng.redact("sha=ab4111111111111111cd").text) def test_inn_needs_context_keyword(self): eng, _ = engine() self.assertIn("CTXG_ID_", eng.redact("ИНН 7712345678").text) self.assertNotIn("CTXG_ID_", eng.redact("count 7712345678").text) class TestStructural(unittest.TestCase): def setUp(self): self.pol = policy_mod.Policy() def test_env_file_denied(self): for path in ("/srv/app/.env", ".env", "/srv/app/.env.production", "/x/id_rsa"): with self.subTest(path=path): self.assertIsNotNone(detect.denied_path(path, self.pol)) def test_documented_templates_are_readable(self): """Regression from a live run: the .env refusal told the agent to read .env.example, and the next rule blocked that too. Contradictory guidance is how an agent learns to ignore guidance.""" for path in ("/srv/app/.env.example", ".env.sample", "/srv/app/.env.template"): with self.subTest(path=path): self.assertIsNone(detect.denied_path(path, self.pol)) def test_cat_env_example_is_allowed_but_cat_env_is_not(self): self.assertIsNone(detect.denied_command("cat .env.example", self.pol)) self.assertIsNone(detect.denied_command("cat .env.sample", self.pol)) self.assertIsNotNone(detect.denied_command("cat .env", self.pol)) self.assertIsNotNone(detect.denied_command("cat config/.env", self.pol)) def test_ordinary_source_allowed(self): for path in ("/srv/app/main.py", "README.md", "/srv/app/env.example.md"): with self.subTest(path=path): self.assertIsNone(detect.denied_path(path, self.pol)) def test_self_protection(self): for path in ( "/home/u/.claude/settings.json", "/home/u/.claude/ctx-guard/projects/x/entities.json", "/repo/plugins/ctxguard/scripts/ctxguard/detect.py", ): with self.subTest(path=path): self.assertIsNotNone(detect.self_protected(path, self.pol)) def test_credential_dumping_commands_denied(self): for cmd in ("env", "printenv", "gh auth token", "kubectl get secret app -o yaml", "cat .env", "aws configure get aws_secret_access_key", "op read op://a/b", "env | grep TOKEN", "printenv SERVICE_API_KEY", "sudo env", "ls; env", "cat config/.env"): with self.subTest(cmd=cmd): self.assertIsNotNone(detect.denied_command(cmd, self.pol)) def test_ordinary_commands_allowed(self): for cmd in ("ls -la", "git status", "npm test", "grep -r foo src/", "cat .env.example", "ls -la .env", "cp .env.example .env", "grep ROUTING src/config.rs"): with self.subTest(cmd=cmd): self.assertIsNone(detect.denied_command(cmd, self.pol)) class TestPolicyPersistence(unittest.TestCase): """Regression: `init` wrote a full snapshot of every rule list, so a later plugin upgrade shipped fixed rules the hook never read. The fix appeared not to work and only a direct comparison of file vs code showed why.""" def test_defaults_persist_as_a_diff(self): import json stored = json.loads(policy_mod.Policy().to_json()) self.assertEqual(stored, {"version": policy_mod.POLICY_VERSION}) def test_customisation_persists(self): import json pol = policy_mod.Policy() pol.mode = "observe" pol.allowlist = pol.allowlist + ["MY-OWN-EXAMPLE"] stored = json.loads(pol.to_json()) self.assertEqual(stored["mode"], "observe") self.assertIn("MY-OWN-EXAMPLE", stored["allowlist"]) self.assertNotIn("deny_command_patterns", stored) def test_stale_v1_rule_lists_are_discarded(self): stale = { "version": 1, "mode": "observe", "deny_command_patterns": [r"(?") self.assertNotIn("AKIA1234567890ABCDEF", repr(detect.withheld(f))) if __name__ == "__main__": unittest.main(verbosity=2)