Stage 7 — final stage of the realism migration. Canary plants are
now scheduled by the same realism planner that handles inert content,
keeping the orchestrator as the single decision point and avoiding
duplicate diurnal / persona / rate-limit logic in the canary
subsystem.
New surface:
- decnet/canary/cultivator.py: cultivate(plan, repo) builds a
CanaryContext, calls the right generator (canary_aws_creds ->
aws_creds, canary_mysql_dump -> mysql_dump, …), persists the
canary_tokens row before plant so the canary worker can attribute
callbacks even on plant-time previews. Resolves canary placements
to credible operator paths (~/.aws/credentials, ~/.ssh/id_rsa,
/var/backups/db_backup.sql).
- realism/planner.py adds 8 canary content_classes uniformly weighted
inside a 3% probability gate. Hard-capped: each tick at most one
canary; create branch falls through to inert otherwise.
- scheduler.pick_file dispatches canary content_class to the
cultivator; FileAction grows an optional content_bytes field so
binary canary artifacts (DOCX/PDF/honeydoc) survive the wire
intact instead of being utf-8 round-tripped.
- SSHDriver._run_file uses content_bytes when set, falls back to
encoding the str content otherwise.
Stealth (per feedback_stealth.md): cultivator does not introduce
any DECNET literal; the underlying generators are already
stealth-clean and the test suite asserts the contract holds.
Tests cover round-tripping every canary class through the cultivator,
verifying placement-path conventions, persona-login normalisation
("John Smith" -> /home/johnsmith/.aws/credentials), and the
no-DECNET-leak invariant.
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""Realism-driven canary cultivation.
|
|
|
|
Stage 7 of the realism migration: the orchestrator's planner picks a
|
|
canary content_class ~3% of file ticks; the cultivator turns that into
|
|
a CanaryArtifact + persisted CanaryToken row.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from decnet.canary.cultivator import cultivate
|
|
from decnet.realism.taxonomy import ContentClass, Plan
|
|
from decnet.web.db.sqlite.repository import SQLiteRepository
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def repo(tmp_path):
|
|
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
|
await r.initialize()
|
|
yield r
|
|
await r.engine.dispose()
|
|
|
|
|
|
def _plan(cls: ContentClass, persona: str = "admin") -> Plan:
|
|
return Plan(
|
|
decky_uuid="d1",
|
|
decky_name="alpha",
|
|
persona=persona,
|
|
content_class=cls,
|
|
action="create",
|
|
target_path="",
|
|
mtime=datetime(2026, 4, 27, 11, 30, tzinfo=timezone.utc),
|
|
body_hint=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cultivate_records_canary_token_row(repo, monkeypatch):
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
|
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
|
|
|
artifact = await cultivate(
|
|
_plan(ContentClass.CANARY_GIT_CONFIG), repo,
|
|
)
|
|
assert artifact.path == "/home/admin/.git/config"
|
|
assert artifact.content
|
|
# Token row landed and the slug round-trips through the slug index.
|
|
rows = await repo.list_canary_tokens(decky_name="alpha")
|
|
assert len(rows) == 1
|
|
assert rows[0]["generator"] == "git_config"
|
|
assert rows[0]["placement_path"] == "/home/admin/.git/config"
|
|
assert rows[0]["callback_token"] in artifact.content.decode("utf-8")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cultivate_persists_path_for_each_class(repo, monkeypatch):
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
|
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
|
|
|
classes_and_paths = {
|
|
ContentClass.CANARY_AWS_CREDS: "/home/admin/.aws/credentials",
|
|
ContentClass.CANARY_ENV_FILE: "/home/admin/app/.env",
|
|
ContentClass.CANARY_GIT_CONFIG: "/home/admin/.git/config",
|
|
ContentClass.CANARY_SSH_KEY: "/home/admin/.ssh/id_rsa",
|
|
ContentClass.CANARY_HONEYDOC: "/home/admin/Documents/notes.html",
|
|
ContentClass.CANARY_MYSQL_DUMP: "/var/backups/db_backup.sql",
|
|
}
|
|
for cls, expected in classes_and_paths.items():
|
|
artifact = await cultivate(_plan(cls), repo)
|
|
assert artifact.path == expected, (
|
|
f"{cls.value!r} planted at {artifact.path!r}, want {expected!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cultivate_rejects_non_canary_class(repo):
|
|
with pytest.raises(ValueError, match="non-canary"):
|
|
await cultivate(_plan(ContentClass.NOTE), repo)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cultivate_persona_login_normalisation(repo, monkeypatch):
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
|
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
|
artifact = await cultivate(
|
|
_plan(ContentClass.CANARY_AWS_CREDS, persona="John Smith"), repo,
|
|
)
|
|
# Spaces collapsed to lowercase login, same convention as the
|
|
# realism namer's _home() function.
|
|
assert artifact.path == "/home/johnsmith/.aws/credentials"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cultivate_artifact_does_not_leak_decnet_string(repo, monkeypatch):
|
|
"""Stealth contract (per feedback_stealth.md): a planted canary's
|
|
bytes must never carry the DECNET literal — that would tell an
|
|
attacker the file is a honeypot trap."""
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
|
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
|
for cls in (
|
|
ContentClass.CANARY_AWS_CREDS,
|
|
ContentClass.CANARY_GIT_CONFIG,
|
|
ContentClass.CANARY_ENV_FILE,
|
|
ContentClass.CANARY_SSH_KEY,
|
|
):
|
|
artifact = await cultivate(_plan(cls), repo)
|
|
body = artifact.content.decode("utf-8", errors="replace")
|
|
assert "decnet" not in body.lower(), (
|
|
f"{cls.value!r} body leaked 'decnet': "
|
|
f"{body[:120]!r}"
|
|
)
|