diff --git a/decnet/canary/cultivator.py b/decnet/canary/cultivator.py index 1cc145b1..8f7a222f 100644 --- a/decnet/canary/cultivator.py +++ b/decnet/canary/cultivator.py @@ -49,6 +49,22 @@ _CLASS_TO_GENERATOR: dict[ContentClass, str] = { } +# Generator → CanaryKind. The trip surface (HTTP slug callback / DNS +# resolution / passive bait) determines how the canary worker matches +# an attacker callback to this token. Aligned with +# :data:`decnet.web.db.models.canary.CanaryKind`. +_GENERATOR_TO_KIND: dict[str, str] = { + "aws_creds": "aws_passive", # no embedded callback; passive bait + "env_file": "http", + "git_config": "http", + "honeydoc": "http", + "honeydoc_docx": "http", + "honeydoc_pdf": "http", + "ssh_key": "dns", # trip is DNS resolution of host comment + "mysql_dump": "dns", # trip is DNS resolution of subdomain +} + + # Path conventions per generator. The realism planner doesn't know # about decoy-realistic credential locations (``~/.aws/credentials``, # ``~/.git/config``); we map them per-class here so the planted @@ -139,7 +155,7 @@ async def cultivate( # itself (improbable but possible — DOCX viewers can preview # autoplay-style). await repo.create_canary_token({ - "kind": "http", # MVP: all realism-cultivated tokens use HTTP + "kind": _GENERATOR_TO_KIND.get(gen_name, "http"), "decky_name": plan.decky_name, "instrumenter": None, "generator": gen_name, diff --git a/tests/canary/test_cultivator.py b/tests/canary/test_cultivator.py index cb8b99f1..b8efd744 100644 --- a/tests/canary/test_cultivator.py +++ b/tests/canary/test_cultivator.py @@ -112,3 +112,34 @@ async def test_cultivate_artifact_does_not_leak_decnet_string(repo, monkeypatch) f"{cls.value!r} body leaked 'decnet': " f"{body[:120]!r}" ) + + +@pytest.mark.asyncio +async def test_cultivate_records_kind_per_generator(repo, monkeypatch): + """The token row's ``kind`` reflects the trip surface of the + underlying generator: HTTP slug callback, DNS resolution, or + passive bait. The canary worker uses ``kind`` to route incoming + callbacks; a wrong kind means the trip won't attribute correctly.""" + monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test") + monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test") + cases = [ + (ContentClass.CANARY_AWS_CREDS, "aws_passive"), + (ContentClass.CANARY_ENV_FILE, "http"), + (ContentClass.CANARY_GIT_CONFIG, "http"), + (ContentClass.CANARY_HONEYDOC, "http"), + (ContentClass.CANARY_HONEYDOC_DOCX, "http"), + (ContentClass.CANARY_HONEYDOC_PDF, "http"), + (ContentClass.CANARY_SSH_KEY, "dns"), + (ContentClass.CANARY_MYSQL_DUMP, "dns"), + ] + for cls, expected_kind in cases: + await cultivate(_plan(cls, persona=f"p-{cls.value}"), repo) + rows = await repo.list_canary_tokens(decky_name="alpha") + by_gen = {r["generator"]: r["kind"] for r in rows} + for cls, expected_kind in cases: + from decnet.canary.cultivator import _CLASS_TO_GENERATOR + gen = _CLASS_TO_GENERATOR[cls] + assert by_gen[gen] == expected_kind, ( + f"{cls.value!r} → generator {gen!r} got kind={by_gen[gen]!r}, " + f"want {expected_kind!r}" + )