merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

0
tests/canary/__init__.py Normal file
View File

88
tests/canary/conftest.py Normal file
View File

@@ -0,0 +1,88 @@
"""Shared fixtures for canary tests — minimal DOCX/XLSX/HTML/PDF fixtures.
We synthesise the OOXML zips inline rather than checking real binary
fixtures into the repo. Keeps the test surface portable and the diff
reviewable; the smallest valid DOCX is ~12 files but Word/LibreOffice
both accept a stripped-down skeleton with just ``[Content_Types].xml``,
``_rels/.rels``, ``word/document.xml``, and ``word/_rels/document.xml.rels``.
"""
from __future__ import annotations
import io
import zipfile
import pytest
_DOCX_CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'</Types>'
)
_DOCX_PACKAGE_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
'</Relationships>'
)
_DOCX_DOCUMENT = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:body><w:p><w:r><w:t>Existing content.</w:t></w:r></w:p></w:body>'
'</w:document>'
)
_DOCX_DOCUMENT_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'</Relationships>'
)
@pytest.fixture
def minimal_docx() -> bytes:
"""Return a tiny but structurally valid DOCX as bytes."""
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", _DOCX_CONTENT_TYPES)
zf.writestr("_rels/.rels", _DOCX_PACKAGE_RELS)
zf.writestr("word/document.xml", _DOCX_DOCUMENT)
zf.writestr("word/_rels/document.xml.rels", _DOCX_DOCUMENT_RELS)
return out.getvalue()
_XLSX_CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Override PartName="/xl/workbook.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'</Types>'
)
_XLSX_WORKBOOK_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'</Relationships>'
)
@pytest.fixture
def minimal_xlsx() -> bytes:
"""Return a tiny but structurally valid XLSX as bytes."""
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", _XLSX_CONTENT_TYPES)
zf.writestr("_rels/.rels", _DOCX_PACKAGE_RELS.replace("word/document.xml", "xl/workbook.xml"))
zf.writestr("xl/workbook.xml", '<workbook/>')
zf.writestr("xl/_rels/workbook.xml.rels", _XLSX_WORKBOOK_RELS)
return out.getvalue()

24
tests/canary/test_cli.py Normal file
View File

@@ -0,0 +1,24 @@
"""Smoke coverage for the ``decnet canary`` CLI subcommand.
We don't run the worker (it would block on HTTP/DNS sockets) — we
just confirm the command is registered and not master-gated, so an
agent host can run ``decnet canary`` without the gate hiding it.
"""
from __future__ import annotations
from typer.testing import CliRunner
from decnet.cli import app
from decnet.cli.gating import MASTER_ONLY_COMMANDS
def test_canary_command_registered() -> None:
runner = CliRunner()
result = runner.invoke(app, ["canary", "--help"])
assert result.exit_code == 0
assert "Run the canary HTTP + DNS callback receiver" in result.output
def test_canary_is_not_master_only() -> None:
# Agents must be able to run their own canary worker.
assert "canary" not in MASTER_ONLY_COMMANDS

View File

@@ -0,0 +1,145 @@
"""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}"
)
@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}"
)

View File

@@ -0,0 +1,80 @@
"""Smoke coverage for the deploy-time canary baseline seed.
The deployer hook calls ``decnet.canary.planter.seed_baseline`` for
every running decky. Two properties matter:
* a baseline seed runs, producing one token row per configured
generator; and
* failures in seed_baseline must never abort the surrounding
deploy flow (resilience principle).
We don't drive the full ``deploy()`` here — that pulls in docker,
network helpers, etc. Instead we exercise ``seed_baseline``
directly with the planter's docker-exec patched, then assert the
hook's wiring via static inspection.
"""
from __future__ import annotations
import asyncio
from typing import AsyncIterator
from unittest.mock import patch
import pytest
import pytest_asyncio
from decnet.canary import planter
from decnet.web.db.sqlite.repository import SQLiteRepository
import decnet.web.db.models # noqa: F401
class _FakeProc:
def __init__(self, rc: int = 0, stderr: bytes = b"") -> None:
self.returncode = rc
self._stderr = stderr
async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]:
return b"", self._stderr
def _patch(rc: int = 0, stderr: bytes = b""):
async def _fake(*argv, **kw): # noqa: ANN001
return _FakeProc(rc, stderr)
return patch.object(asyncio, "create_subprocess_exec", _fake)
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
r = SQLiteRepository(str(tmp_path / "h.db"))
await r.initialize()
yield r
@pytest.mark.asyncio
async def test_baseline_creates_tokens_per_decky(
repo: SQLiteRepository, monkeypatch
) -> None:
monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds")
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
with _patch(rc=0):
await planter.seed_baseline("web1", repo)
await planter.seed_baseline("web2", repo)
web1 = await repo.list_canary_tokens(decky_name="web1")
web2 = await repo.list_canary_tokens(decky_name="web2")
assert len(web1) == 3 and len(web2) == 3
assert {t["generator"] for t in web1} == {"git_config", "env_file", "aws_creds"}
def test_deploy_hook_is_wired_into_deployer() -> None:
"""Static check: deployer's _mirror_fleet_to_db calls seed_baseline.
We grep the source rather than driving the full deploy() because
that pulls in docker + networking helpers and we don't want a
second test environment for this one assertion.
"""
import inspect
from decnet.engine import deployer
source = inspect.getsource(deployer)
assert "seed_baseline" in source, "deployer must call canary.planter.seed_baseline"
# And the call must be wrapped in try/except so a failure doesn't
# abort the deploy.
assert "canary baseline seed failed (best-effort)" in source

View File

@@ -0,0 +1,88 @@
"""Coverage for the generator/instrumenter factory + MIME dispatch.
The concrete generators and instrumenters land in subsequent commits;
this file only tests the dispatch surface — it must reject unknown
names with ``ValueError`` and pick the right instrumenter for known
MIME types (with passthrough as the fallback for binary blobs we
can't safely mutate).
"""
from __future__ import annotations
import pytest
from decnet.canary.factory import (
KNOWN_GENERATORS,
KNOWN_INSTRUMENTERS,
pick_instrumenter_for_mime,
)
@pytest.mark.parametrize(
"mime, expected",
[
("application/pdf", "pdf"),
("application/PDF", "pdf"), # case-insensitive
("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"),
("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
("text/html", "html"),
("application/xhtml+xml", "html"),
("text/plain", "plain"),
("text/x-yaml", "plain"),
("application/json", "plain"),
("application/yaml", "plain"),
("application/toml", "plain"),
("image/png", "image"),
("image/jpeg", "image"),
("image/gif", "image"),
],
)
def test_mime_dispatch_known(mime: str, expected: str) -> None:
assert pick_instrumenter_for_mime(mime) == expected
@pytest.mark.parametrize(
"mime",
[
"",
"application/octet-stream",
"application/x-tar",
"application/zip", # bare zip — DOCX/XLSX dispatch by alias, not raw zip
"video/mp4",
"audio/mpeg",
],
)
def test_mime_dispatch_falls_back_to_passthrough(mime: str) -> None:
assert pick_instrumenter_for_mime(mime) == "passthrough"
def test_known_lists_are_stable() -> None:
# If anyone adds/removes from the dispatch tables, the test
# surfaces it. Keeps the schema-of-record in one place.
assert KNOWN_GENERATORS == (
"git_config", "env_file", "ssh_key", "aws_creds",
"honeydoc", "honeydoc_docx", "honeydoc_pdf", "mysql_dump",
)
assert KNOWN_INSTRUMENTERS == (
"docx", "xlsx", "pdf", "html", "image", "plain", "passthrough",
)
def test_unknown_generator_raises() -> None:
from decnet.canary.factory import get_generator
with pytest.raises(ValueError, match="Unknown canary generator"):
get_generator("bogus")
def test_unknown_instrumenter_raises() -> None:
from decnet.canary.factory import get_instrumenter
with pytest.raises(ValueError, match="Unknown canary instrumenter"):
get_instrumenter("bogus")
def test_base_artifact_dataclass_defaults() -> None:
from decnet.canary import CanaryArtifact
a = CanaryArtifact(path="/x", content=b"y")
assert a.mode == 0o600
assert a.mtime_offset == 0
assert a.notes == []
assert a.generator is None and a.instrumenter is None

View File

@@ -0,0 +1,188 @@
"""Coverage for the synthesised-artifact generators.
Each generator MUST be deterministic for a given ``CanaryContext`` —
the planter relies on that idempotency to re-seed without storing
the rendered bytes. We assert byte-for-byte stability across two
calls with the same inputs as well as the obvious "slug appears in
the artifact" property.
"""
from __future__ import annotations
import re
import pytest
from decnet.canary import CanaryContext, get_generator
from decnet.canary.factory import KNOWN_GENERATORS
def _ctx(**kw) -> CanaryContext:
defaults = dict(
callback_token="abcDEF123-test",
http_base="https://canary.example.test",
dns_zone="canary.example.test",
persona="linux",
)
defaults.update(kw)
return CanaryContext(**defaults)
@pytest.mark.parametrize("name", KNOWN_GENERATORS)
def test_generator_is_deterministic(name: str) -> None:
g = get_generator(name)
a = g.generate(_ctx())
b = g.generate(_ctx())
assert a.content == b.content, f"{name} not deterministic"
assert a.generator == name
assert a.instrumenter is None
assert a.mode in (0o600, 0o644)
@pytest.mark.parametrize("name", ["git_config", "env_file", "honeydoc"])
def test_callback_url_embedded(name: str) -> None:
g = get_generator(name)
art = g.generate(_ctx(callback_token="slug-XYZ"))
body = art.content.decode("utf-8")
assert "slug-XYZ" in body, f"{name} did not embed slug"
assert "https://canary.example.test" in body
def test_aws_creds_passive_does_not_embed_url() -> None:
# AWS creds are passive — there's no realistic field to hide a URL
# in. Asserting the absence prevents a regression where a future
# change tries to slip the slug into a comment and breaks realism.
g = get_generator("aws_creds")
art = g.generate(_ctx(callback_token="slug-XYZ"))
body = art.content.decode("utf-8")
assert "https://" not in body
assert "slug-XYZ" not in body
# Access key matches the AKIA[A-Z0-9]{16} shape.
assert re.search(r"AKIA[A-Z0-9]{16}", body)
def test_aws_creds_changes_with_slug() -> None:
g = get_generator("aws_creds")
a = g.generate(_ctx(callback_token="slug-A"))
b = g.generate(_ctx(callback_token="slug-B"))
assert a.content != b.content
def test_ssh_key_uses_dns_zone_when_available() -> None:
g = get_generator("ssh_key")
art = g.generate(_ctx(callback_token="slugZ", dns_zone="canary.test"))
assert b"slugZ.canary.test" in art.content
def test_ssh_key_falls_back_to_http_host_without_dns() -> None:
g = get_generator("ssh_key")
art = g.generate(_ctx(
http_base="https://example.test", dns_zone="",
))
assert b"example.test" in art.content
def test_honeydoc_html_is_valid_ish_html() -> None:
g = get_generator("honeydoc")
art = g.generate(_ctx())
body = art.content.decode("utf-8")
assert "<!DOCTYPE html>" in body
assert "<img" in body
assert "width=\"1\" height=\"1\"" in body
def test_honeydoc_docx_produces_valid_zip_with_callback() -> None:
import io
import zipfile
g = get_generator("honeydoc_docx")
art = g.generate(_ctx(callback_token="slugDX"))
assert art.content[:4] == b"PK\x03\x04" # zip magic
with zipfile.ZipFile(io.BytesIO(art.content), "r") as zf:
names = set(zf.namelist())
assert {"[Content_Types].xml", "_rels/.rels", "word/document.xml",
"word/_rels/document.xml.rels"} <= names
rels = zf.read("word/_rels/document.xml.rels").decode()
assert "https://canary.example.test/c/slugDX" in rels
assert "TargetMode=\"External\"" in rels
doc = zf.read("word/document.xml").decode()
assert "Q3 Operations Review" in doc
assert "<w:drawing>" in doc
def test_honeydoc_pdf_produces_valid_pdf_with_openaction() -> None:
pikepdf = pytest.importorskip("pikepdf")
g = get_generator("honeydoc_pdf")
art = g.generate(_ctx(callback_token="slugPDF"))
assert art.content[:5] == b"%PDF-"
# Re-open and confirm OpenAction URI round-trips.
import io
with pikepdf.open(io.BytesIO(art.content)) as pdf:
action = pdf.Root["/OpenAction"]
assert str(action["/S"]) == "/URI"
assert str(action["/URI"]) == "https://canary.example.test/c/slugPDF"
def test_git_config_remote_url_shape() -> None:
g = get_generator("git_config")
art = g.generate(_ctx(callback_token="slug42"))
body = art.content.decode("utf-8")
assert "[remote \"origin\"]" in body
assert "https://canary.example.test/c/slug42/repo.git" in body
def test_env_file_carries_two_callback_fields() -> None:
g = get_generator("env_file")
art = g.generate(_ctx(callback_token="slugEnv"))
body = art.content.decode("utf-8")
assert "API_BASE_URL=https://canary.example.test/c/slugEnv" in body
assert "WEBHOOK_NOTIFY_URL=https://canary.example.test/c/slugEnv/webhook" in body
def test_mysql_dump_requires_dns_zone() -> None:
g = get_generator("mysql_dump")
with pytest.raises(ValueError, match="dns_zone"):
g.generate(_ctx(dns_zone=""))
def test_mysql_dump_payload_round_trips_through_base64() -> None:
import base64 as _b64
g = get_generator("mysql_dump")
art = g.generate(_ctx(callback_token="slugSQL", dns_zone="canary.test"))
body = art.content.decode("utf-8")
# Slug must NOT appear in plaintext — the camouflage is base64.
assert "slugSQL" not in body.replace("\n", " ").split("SET @b = '")[0]
# Locate the base64 blob and decode it; the inner SQL must reference
# the slug-bearing replica host, smuggle @@hostname/@@lc_time_names
# into SOURCE_USER, and target port 3306.
m = re.search(r"SET @b = '([A-Za-z0-9+/=]+)';", body)
assert m, "expected base64 payload assignment"
inner = _b64.b64decode(m.group(1)).decode("utf-8")
assert "slugSQL.canary.test" in inner
assert "SOURCE_PORT=3306" in inner
assert "@@hostname" in inner
assert "@@lc_time_names" in inner
assert "CHANGE REPLICATION SOURCE TO" in inner
def test_mysql_dump_executes_and_starts_replica() -> None:
g = get_generator("mysql_dump")
art = g.generate(_ctx(callback_token="slugSQL2", dns_zone="canary.test"))
body = art.content.decode("utf-8")
# The PREPARE/EXECUTE/START REPLICA chain is what makes the import
# actually phone home; missing any of these silently breaks the trip.
assert "PREPARE stmt1 FROM @s2;" in body
assert "EXECUTE stmt1;" in body
assert "PREPARE stmt2 FROM @bb;" in body
assert "EXECUTE stmt2;" in body
assert "START REPLICA;" in body
# Realism: header + trailer markers that mysqldump emits.
assert body.startswith("-- MySQL dump")
assert "-- Dump completed" in body
def test_artifacts_carry_notes() -> None:
# Notes drive the API ``preview`` endpoint so operators can sanity-
# check what we did before the file lands. Empty notes would mean
# the operator is staring at opaque bytes.
for name in KNOWN_GENERATORS:
art = get_generator(name).generate(_ctx())
assert art.notes, f"{name} produced no notes"

View File

@@ -0,0 +1,173 @@
"""Coverage for the operator-upload instrumenters.
Each instrumenter is round-tripped against a small, real-shaped
fixture. We assert:
* the callback URL ends up somewhere in the mutated bytes;
* the output still parses (zip stays a valid zip; HTML stays
reasonable);
* the rejection paths surface :class:`InstrumenterRejectedError`
with a useful message.
"""
from __future__ import annotations
import io
import zipfile
import pytest
from decnet.canary import CanaryContext, get_instrumenter
from decnet.canary.base import InstrumenterRejectedError
def _ctx(slug: str = "slug-abc") -> CanaryContext:
return CanaryContext(
callback_token=slug,
http_base="https://canary.example.test",
dns_zone="canary.example.test",
persona="linux",
)
# ----------------------- passthrough ------------------------------------
def test_passthrough_preserves_bytes() -> None:
ins = get_instrumenter("passthrough")
out = ins.instrument(b"\x00\x01\x02bin", _ctx(), target_path="/tmp/x.bin")
assert out.content == b"\x00\x01\x02bin"
assert out.path == "/tmp/x.bin"
assert out.instrumenter == "passthrough"
# ----------------------- plain ------------------------------------------
def test_plain_substitutes_url_placeholder() -> None:
ins = get_instrumenter("plain")
blob = b"api: {{CANARY_URL}}\nhost: {{CANARY_HOST}}\n"
out = ins.instrument(blob, _ctx("slugXYZ"), target_path="/etc/x.yaml")
assert b"https://canary.example.test/c/slugXYZ" in out.content
assert b"slugXYZ.canary.example.test" in out.content
assert b"{{CANARY_URL}}" not in out.content
def test_plain_appends_when_no_placeholder() -> None:
ins = get_instrumenter("plain")
out = ins.instrument(b"key=value\n", _ctx("s1"), target_path="/etc/x.env")
assert b"https://canary.example.test/c/s1" in out.content
# Original content survives.
assert out.content.startswith(b"key=value\n")
@pytest.mark.parametrize(
"head, expect_prefix",
[
(b"[default]\nfoo=1\n", b"; "),
(b"// js code\nconst x = 1;\n", b"// "),
(b"#!/bin/bash\necho hi\n", b"# "),
],
)
def test_plain_picks_comment_prefix(head: bytes, expect_prefix: bytes) -> None:
ins = get_instrumenter("plain")
out = ins.instrument(head, _ctx(), target_path="/etc/x")
# The appended comment line uses the matching prefix.
appended = out.content[len(head):]
assert appended.lstrip(b"\n").startswith(expect_prefix)
# ----------------------- html -------------------------------------------
def test_html_injects_pixel_before_body_close() -> None:
ins = get_instrumenter("html")
blob = b"<html><body><h1>hi</h1></body></html>"
out = ins.instrument(blob, _ctx("slugH"), target_path="/srv/x.html")
assert b"https://canary.example.test/c/slugH" in out.content
# Pixel sits before </body>, not after.
body_close = out.content.index(b"</body>")
pixel_pos = out.content.index(b"<img ")
assert pixel_pos < body_close
# Original markup survives intact.
assert b"<h1>hi</h1>" in out.content
def test_html_appends_pixel_when_body_missing() -> None:
ins = get_instrumenter("html")
out = ins.instrument(b"<p>no body</p>", _ctx(), target_path="/srv/x.html")
assert out.content.endswith(b">\n") or out.content.endswith(b'>\n')
assert b"<img" in out.content
# ----------------------- docx -------------------------------------------
def test_docx_injects_external_image_relationship(minimal_docx: bytes) -> None:
ins = get_instrumenter("docx")
out = ins.instrument(minimal_docx, _ctx("slugD"), target_path="/x/r.docx")
# Output is still a valid zip we can re-open.
with zipfile.ZipFile(io.BytesIO(out.content), "r") as zf:
rels = zf.read("word/_rels/document.xml.rels").decode()
doc = zf.read("word/document.xml").decode()
assert "https://canary.example.test/c/slugD" in rels
assert "TargetMode=\"External\"" in rels
assert "image" in rels
# Drawing is embedded in the document body, before </w:body>.
assert "<w:drawing>" in doc
assert doc.index("<w:drawing>") < doc.index("</w:body>")
def test_docx_rejects_non_zip() -> None:
ins = get_instrumenter("docx")
with pytest.raises(InstrumenterRejectedError, match="not a valid DOCX"):
ins.instrument(b"not a docx at all", _ctx(), target_path="/x")
def test_docx_rejects_zip_missing_members() -> None:
ins = get_instrumenter("docx")
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as zf:
zf.writestr("readme.txt", "hello")
with pytest.raises(InstrumenterRejectedError, match="missing expected member"):
ins.instrument(out.getvalue(), _ctx(), target_path="/x")
# ----------------------- xlsx -------------------------------------------
def test_xlsx_injects_relationship(minimal_xlsx: bytes) -> None:
ins = get_instrumenter("xlsx")
out = ins.instrument(minimal_xlsx, _ctx("slugX"), target_path="/x/r.xlsx")
with zipfile.ZipFile(io.BytesIO(out.content), "r") as zf:
rels = zf.read("xl/_rels/workbook.xml.rels").decode()
assert "https://canary.example.test/c/slugX" in rels
assert "TargetMode=\"External\"" in rels
def test_xlsx_rejects_zip_without_workbook_rels() -> None:
ins = get_instrumenter("xlsx")
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as zf:
zf.writestr("readme.txt", "hello")
with pytest.raises(InstrumenterRejectedError, match="no workbook relationships"):
ins.instrument(out.getvalue(), _ctx(), target_path="/x")
# ----------------------- pdf / image (optional dep) ---------------------
def test_pdf_rejects_when_pikepdf_missing() -> None:
pytest.importorskip # noqa: B018 — fence below
try:
import pikepdf # noqa: F401
except ImportError:
ins = get_instrumenter("pdf")
with pytest.raises(InstrumenterRejectedError, match="pikepdf"):
ins.instrument(b"%PDF-1.4\n", _ctx(), target_path="/x.pdf")
else:
pytest.skip("pikepdf is installed; skipping the missing-dep guard")
def test_image_rejects_when_pillow_missing() -> None:
try:
import PIL # noqa: F401
except ImportError:
ins = get_instrumenter("image")
with pytest.raises(InstrumenterRejectedError, match="Pillow"):
ins.instrument(b"\x89PNG\r\n", _ctx(), target_path="/x.png")
else:
pytest.skip("Pillow is installed; skipping the missing-dep guard")

View File

@@ -0,0 +1,85 @@
"""Smoke coverage for the Pydantic request/response shapes + helpers.
The tables themselves are exercised end-to-end in
:mod:`tests.canary.test_repository`; this module only covers the
helpers and request validation that don't go through the DB —
``CanaryTrigger.headers()`` JSON decoding, the
``CanaryTokenCreateRequest`` body shape, and the dump-roundtrip on
the response models.
"""
from __future__ import annotations
import pytest
from decnet.web.db.models import (
CanaryBlobResponse,
CanaryTokenCreateRequest,
CanaryTokenResponse,
CanaryTrigger,
CanaryTriggerResponse,
)
def test_create_request_minimal() -> None:
r = CanaryTokenCreateRequest(
decky_name="web1",
kind="http",
placement_path="/home/admin/.env",
generator="env_file",
)
assert r.blob_uuid is None
assert r.persona_path_hint is None
def test_create_request_kind_is_constrained() -> None:
with pytest.raises(ValueError):
CanaryTokenCreateRequest(
decky_name="web1", kind="bogus", # type: ignore[arg-type]
placement_path="/x", generator="aws_creds",
)
def test_trigger_headers_decode_valid_json() -> None:
t = CanaryTrigger(
token_uuid="t",
src_ip="1.2.3.4",
raw_headers='{"user-agent":"curl"}',
)
assert t.headers() == {"user-agent": "curl"}
@pytest.mark.parametrize("raw", ["", "not json", "[1,2,3]", "null"])
def test_trigger_headers_falls_back_to_empty(raw: str) -> None:
t = CanaryTrigger(token_uuid="t", src_ip="1.2.3.4", raw_headers=raw)
assert t.headers() == {}
def test_response_models_round_trip() -> None:
# Canonical shapes — proves the field set + types match what the
# router will hand back. Strings everywhere because the DB layer
# uses str UUIDs (project convention).
blob = CanaryBlobResponse(
uuid="b1", sha256="0" * 64, filename="x.docx",
content_type="application/octet-stream", size_bytes=1,
uploaded_by="u1", uploaded_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
token_count=2,
)
assert blob.token_count == 2
tok = CanaryTokenResponse(
uuid="t1", kind="http", decky_name="web1",
blob_uuid=None, instrumenter=None, generator="aws_creds",
placement_path="/a", callback_token="s",
placed_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
last_triggered_at=None, trigger_count=0,
created_by="u1", state="planted", last_error=None,
)
assert tok.kind == "http"
trig = CanaryTriggerResponse(
uuid="x", token_uuid="t1",
occurred_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
src_ip="1.2.3.4", user_agent=None, request_path=None,
dns_qname=None, headers={}, attacker_id=None,
)
assert trig.src_ip == "1.2.3.4"

View File

@@ -0,0 +1,68 @@
"""Coverage for the persona-aware path resolver + placement validator."""
from __future__ import annotations
import pytest
from decnet.canary.paths import (
DEFAULT_LINUX_USER,
DEFAULT_WINDOWS_USER,
default_path_for,
default_user,
normalize_placement,
)
def test_default_user_dispatch() -> None:
assert default_user("linux") == DEFAULT_LINUX_USER
assert default_user("windows") == DEFAULT_WINDOWS_USER
# Unknown personas fall through to Linux — better to plant than fail.
assert default_user("aix") == DEFAULT_LINUX_USER
@pytest.mark.parametrize(
"generator, persona, expected_substr",
[
("aws_creds", "linux", "/home/admin/.aws/credentials"),
("aws_creds", "windows", "/home/Administrator/.aws/credentials"),
("env_file", "linux", "/home/admin/.env"),
("env_file", "windows", "/home/Administrator/Desktop/prod.env"),
("git_config", "linux", "/home/admin/.git/config"),
("ssh_key", "linux", "/home/admin/.ssh/id_rsa"),
("honeydoc", "linux", "/home/admin/Documents/quarterly_report.html"),
("honeydoc_docx", "linux", "/home/admin/Documents/quarterly_report.docx"),
("honeydoc_pdf", "linux", "/home/admin/Documents/quarterly_report.pdf"),
],
)
def test_default_path_for_known_generators(
generator: str, persona: str, expected_substr: str,
) -> None:
assert default_path_for(generator, persona) == expected_substr
def test_default_path_for_unknown_generator_falls_through() -> None:
# Unknown generator — defensive /tmp drop. The API rejects unknowns
# upstream, but the resolver shouldn't crash if one slips through.
assert default_path_for("bogus") == "/tmp/bogus.canary"
def test_normalize_placement_accepts_clean_paths() -> None:
assert normalize_placement("/home/admin/.env") == "/home/admin/.env"
assert normalize_placement("/var/lib/x") == "/var/lib/x"
@pytest.mark.parametrize(
"bad",
[
"",
"relative/path",
"./still-relative",
"/path/with\x00nul",
"/path/with\nnewline",
"/path/with\rcr",
"/path/../escape",
"/trailing/..",
],
)
def test_normalize_placement_rejects_bad(bad: str) -> None:
with pytest.raises(ValueError):
normalize_placement(bad)

View File

@@ -0,0 +1,247 @@
"""Coverage for the canary planter (docker exec wrapper).
We don't actually invoke docker — :func:`asyncio.create_subprocess_exec`
is patched to record argv and return canned ``(rc, stdout, stderr)``
triples. That lets us assert:
* the docker argv has the right shape (container = ``<decky>-ssh``,
``sh -c <script>``);
* the script base64-decodes the artifact bytes losslessly;
* mtime is backdated by the right offset;
* state transitions hit the repo on success/failure;
* the bus event publishes on success.
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
from typing import AsyncIterator
from unittest.mock import patch
import pytest
import pytest_asyncio
from decnet.bus import topics
from decnet.bus.fake import FakeBus
from decnet.canary import CanaryArtifact
from decnet.canary import planter
from decnet.web.db.sqlite.repository import SQLiteRepository
import decnet.web.db.models # noqa: F401
class _FakeProc:
def __init__(self, rc: int, stdout: bytes = b"", stderr: bytes = b"") -> None:
self.returncode = rc
self._stdout = stdout
self._stderr = stderr
async def communicate(self) -> tuple[bytes, bytes]:
return self._stdout, self._stderr
def kill(self) -> None: # pragma: no cover — never reached in non-timeout tests
pass
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
captured: list[list[str]] = []
stdin_seen: list[bytes | None] = []
async def _fake(*argv, **kw):
captured.append(list(argv))
# Capture whatever bytes the planter would stream over stdin —
# the new contract pipes the base64 payload here instead of
# interpolating it into the sh script.
proc = _FakeProc(rc, b"", stderr)
orig = proc.communicate
async def communicate(input=None):
stdin_seen.append(input)
return await orig()
proc.communicate = communicate # type: ignore[assignment]
return proc
return patch.object(asyncio, "create_subprocess_exec", _fake), captured, stdin_seen
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
r = SQLiteRepository(str(tmp_path / "p.db"))
await r.initialize()
yield r
@pytest_asyncio.fixture
async def fake_bus() -> AsyncIterator[FakeBus]:
bus = FakeBus()
await bus.connect()
yield bus
await bus.close()
# ---------------- argv shape + base64 round-trip --------------------------
@pytest.mark.asyncio
async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus: FakeBus, tmp_path) -> None:
art = CanaryArtifact(
path="/home/admin/.aws/credentials",
content=b"\x00binary\xffpayload",
mode=0o600,
mtime_offset=-86400,
generator="aws_creds",
)
# Persist a token row so the state-update path has something to flip.
await repo.create_canary_token({
"uuid": "tok-1", "kind": "http", "decky_name": "web1",
"generator": "aws_creds", "placement_path": art.path,
"callback_token": "slug", "secret_seed": "s", "created_by": "u1",
})
patcher, captured, stdin_seen = _patch_subprocess(rc=0)
with patcher:
ok, err = await planter.plant(
"web1", art, token_uuid="tok-1", repo=repo, bus=fake_bus,
)
assert ok is True and err is None
assert len(captured) == 1
argv = captured[0]
# docker exec -i <container> sh -c <script>
assert argv[:4] == ["docker", "exec", "-i", "web1-ssh"]
assert argv[4:6] == ["sh", "-c"]
script = argv[6]
# The base64 payload is streamed via stdin, NOT interpolated into
# the script (would blow past ARG_MAX for any non-trivial blob).
assert stdin_seen[0] == base64.b64encode(art.content)
assert "base64 -d > /home/admin/.aws/credentials" in script
assert base64.b64encode(art.content).decode() not in script
# touch -d @<mtime> with negative offset → an int strictly less than now.
m = re.search(r"touch -d @(\d+) ", script)
assert m and int(m.group(1)) > 0
# State transitioned to planted.
row = await repo.get_canary_token("tok-1")
assert row["state"] == "planted" and row["last_error"] is None
@pytest.mark.asyncio
async def test_plant_records_failure_when_docker_returns_nonzero(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
await repo.create_canary_token({
"uuid": "tok-2", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug2", "secret_seed": "s", "created_by": "u1",
})
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
patcher, _argvs, _stdin = _patch_subprocess(rc=125, stderr=b"container not running")
with patcher:
ok, err = await planter.plant(
"web1", art, token_uuid="tok-2", repo=repo, bus=fake_bus,
)
assert ok is False
assert err and "not running" in err
row = await repo.get_canary_token("tok-2")
assert row["state"] == "failed" and row["last_error"]
@pytest.mark.asyncio
async def test_plant_rejects_empty_path(repo: SQLiteRepository) -> None:
await repo.create_canary_token({
"uuid": "tok-3", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug3", "secret_seed": "s", "created_by": "u1",
})
art = CanaryArtifact(path="", content=b"y")
ok, err = await planter.plant("web1", art, token_uuid="tok-3", repo=repo)
assert ok is False and err is not None
row = await repo.get_canary_token("tok-3")
assert row["state"] == "failed"
@pytest.mark.asyncio
async def test_plant_publishes_placed_event(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
await repo.create_canary_token({
"uuid": "tok-4", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug4", "secret_seed": "s", "created_by": "u1",
})
sub = fake_bus.subscribe("canary.>")
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
patcher, _argvs, _stdin = _patch_subprocess(rc=0)
with patcher:
await planter.plant(
"web1", art, token_uuid="tok-4", repo=repo, bus=fake_bus,
)
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
assert event.topic == topics.canary("tok-4", topics.CANARY_PLACED)
assert event.payload["decky_name"] == "web1"
assert event.payload["generator"] == "env_file"
# ---------------- revoke --------------------------------------------------
@pytest.mark.asyncio
async def test_revoke_unlinks_and_publishes(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
await repo.create_canary_token({
"uuid": "tok-r", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/etc/x.env",
"callback_token": "slugR", "secret_seed": "s", "created_by": "u1",
})
sub = fake_bus.subscribe("canary.>")
patcher, captured, _stdin = _patch_subprocess(rc=0)
with patcher:
ok, err = await planter.revoke(
"web1", "/etc/x.env",
token_uuid="tok-r", repo=repo, bus=fake_bus,
)
assert ok and not err
assert "rm -f /etc/x.env" in captured[0][5]
row = await repo.get_canary_token("tok-r")
assert row["state"] == "revoked"
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
assert event.topic == topics.canary("tok-r", topics.CANARY_REVOKED)
# ---------------- seed_baseline ------------------------------------------
@pytest.mark.asyncio
async def test_seed_baseline_creates_one_token_per_generator(
repo: SQLiteRepository, fake_bus: FakeBus, monkeypatch
) -> None:
monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds")
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
patcher, captured, _stdin = _patch_subprocess(rc=0)
with patcher:
rows = await planter.seed_baseline("web1", repo, bus=fake_bus)
assert {r["generator"] for r in rows} == {"git_config", "env_file", "aws_creds"}
# One docker exec per generator.
assert len(captured) == 3
# aws_creds ends up as kind=aws_passive; the other two are http.
by_gen = {r["generator"]: r for r in rows}
assert by_gen["aws_creds"]["kind"] == "aws_passive"
assert by_gen["env_file"]["kind"] == "http"
persisted = await repo.list_canary_tokens(decky_name="web1")
assert len(persisted) == 3
@pytest.mark.asyncio
async def test_seed_baseline_skips_unknown_generator(repo: SQLiteRepository, monkeypatch) -> None:
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file,bogus")
patcher, _argvs, _stdin = _patch_subprocess(rc=0)
with patcher:
rows = await planter.seed_baseline("web1", repo)
assert {r["generator"] for r in rows} == {"env_file"}
@pytest.mark.asyncio
async def test_seed_baseline_marks_failed_when_docker_errors(
repo: SQLiteRepository, monkeypatch
) -> None:
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file")
patcher, _argvs, _stdin = _patch_subprocess(rc=125, stderr=b"container down")
with patcher:
rows = await planter.seed_baseline("web1", repo)
assert len(rows) == 1
persisted = await repo.list_canary_tokens(decky_name="web1")
assert persisted[0]["state"] == "failed"
assert persisted[0]["last_error"]

View File

@@ -0,0 +1,179 @@
"""Repository CRUD coverage for canary blobs / tokens / triggers.
Same harness as the rest of :mod:`tests.db` — spin up a SQLite-backed
:class:`SQLiteRepository` against a tempfile, exercise the public
methods, assert observable state.
We deliberately don't go through the API; that gets its own test
module once the router lands. This file proves the repository layer
in isolation: dedup, refcount-aware delete, slug lookup, atomic
trigger record + counter bump, attribution.
"""
from __future__ import annotations
import hashlib
from typing import AsyncIterator
import pytest
import pytest_asyncio
from decnet.web.db.sqlite.repository import SQLiteRepository
import decnet.web.db.models # noqa: F401 — registers tables on import
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
r = SQLiteRepository(str(tmp_path / "canary.db"))
await r.initialize()
yield r
async def _make_blob(repo: SQLiteRepository, content: bytes, *, by: str = "u1") -> dict:
return await repo.upsert_canary_blob({
"sha256": hashlib.sha256(content).hexdigest(),
"filename": "report.docx",
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size_bytes": len(content),
"uploaded_by": by,
})
@pytest.mark.asyncio
async def test_upsert_blob_dedupes_by_sha256(repo: SQLiteRepository) -> None:
a = await _make_blob(repo, b"same bytes", by="u1")
b = await _make_blob(repo, b"same bytes", by="u2")
assert a["uuid"] == b["uuid"], "second upload must return the canonical row"
# Different bytes → different blob.
c = await _make_blob(repo, b"different bytes", by="u1")
assert c["uuid"] != a["uuid"]
@pytest.mark.asyncio
async def test_upsert_blob_requires_sha256(repo: SQLiteRepository) -> None:
with pytest.raises(ValueError):
await repo.upsert_canary_blob({"filename": "x", "content_type": "x", "size_bytes": 0, "uploaded_by": "u"})
@pytest.mark.asyncio
async def test_get_blob_by_sha256(repo: SQLiteRepository) -> None:
blob = await _make_blob(repo, b"x")
found = await repo.get_canary_blob_by_sha256(blob["sha256"])
assert found is not None and found["uuid"] == blob["uuid"]
assert await repo.get_canary_blob_by_sha256("0" * 64) is None
@pytest.mark.asyncio
async def test_list_blobs_carries_token_count(repo: SQLiteRepository) -> None:
blob = await _make_blob(repo, b"x")
listed = await repo.list_canary_blobs()
assert len(listed) == 1 and listed[0]["token_count"] == 0
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "blob_uuid": blob["uuid"],
"instrumenter": "docx", "placement_path": "/tmp/x.docx",
"callback_token": "slug-1", "secret_seed": "s", "created_by": "u1",
})
listed = await repo.list_canary_blobs()
assert listed[0]["token_count"] == 1
@pytest.mark.asyncio
async def test_delete_blob_refuses_while_referenced(repo: SQLiteRepository) -> None:
blob = await _make_blob(repo, b"x")
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "blob_uuid": blob["uuid"],
"instrumenter": "docx", "placement_path": "/tmp/x.docx",
"callback_token": "slug-r", "secret_seed": "s", "created_by": "u1",
})
assert await repo.delete_canary_blob(blob["uuid"]) is False
# Even after revoke, the row still references the blob — operator
# must explicitly clean tokens before they can prune the blob.
tok = await repo.get_canary_token_by_slug("slug-r")
await repo.update_canary_token_state(tok["uuid"], "revoked")
assert await repo.delete_canary_blob(blob["uuid"]) is False
@pytest.mark.asyncio
async def test_delete_blob_returns_false_for_missing(repo: SQLiteRepository) -> None:
assert await repo.delete_canary_blob("00000000-0000-0000-0000-000000000000") is False
@pytest.mark.asyncio
async def test_token_slug_lookup(repo: SQLiteRepository) -> None:
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
"placement_path": "/home/admin/.aws/credentials",
"callback_token": "slug-aws", "secret_seed": "s", "created_by": "u1",
})
found = await repo.get_canary_token_by_slug("slug-aws")
assert found is not None and found["decky_name"] == "web1"
assert await repo.get_canary_token_by_slug("nonexistent") is None
@pytest.mark.asyncio
async def test_list_tokens_filters(repo: SQLiteRepository) -> None:
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
"placement_path": "/a", "callback_token": "s1",
"secret_seed": "s", "created_by": "u1",
})
await repo.create_canary_token({
"kind": "dns", "decky_name": "web2", "generator": "aws_creds",
"placement_path": "/b", "callback_token": "s2",
"secret_seed": "s", "created_by": "u1",
})
assert len(await repo.list_canary_tokens()) == 2
assert len(await repo.list_canary_tokens(decky_name="web1")) == 1
assert len(await repo.list_canary_tokens(kind="dns")) == 1
assert len(await repo.list_canary_tokens(state="revoked")) == 0
@pytest.mark.asyncio
async def test_record_trigger_bumps_counters_atomically(repo: SQLiteRepository) -> None:
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
"placement_path": "/a", "callback_token": "slug-c",
"secret_seed": "s", "created_by": "u1",
})
tok = await repo.get_canary_token_by_slug("slug-c")
assert tok["trigger_count"] == 0 and tok["last_triggered_at"] is None
trig_id = await repo.record_canary_trigger({
"token_uuid": tok["uuid"], "src_ip": "1.2.3.4",
"request_path": "/c/slug-c", "user_agent": "curl/8.0",
"raw_headers": {"user-agent": "curl/8.0"},
})
assert trig_id
tok2 = await repo.get_canary_token_by_slug("slug-c")
assert tok2["trigger_count"] == 1
assert tok2["last_triggered_at"] is not None
# raw_headers stored as JSON text and decodes via the model helper.
triggers = await repo.list_canary_triggers(tok["uuid"])
assert len(triggers) == 1
assert triggers[0]["src_ip"] == "1.2.3.4"
@pytest.mark.asyncio
async def test_attribute_trigger_sets_attacker(repo: SQLiteRepository) -> None:
await repo.create_canary_token({
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
"placement_path": "/a", "callback_token": "slug-at",
"secret_seed": "s", "created_by": "u1",
})
tok = await repo.get_canary_token_by_slug("slug-at")
trig_id = await repo.record_canary_trigger({
"token_uuid": tok["uuid"], "src_ip": "9.9.9.9",
})
assert await repo.attribute_canary_trigger(trig_id, "attacker-uuid-123") is True
assert await repo.attribute_canary_trigger("missing-trig", "x") is False
triggers = await repo.list_canary_triggers(tok["uuid"])
assert triggers[0]["attacker_id"] == "attacker-uuid-123"
@pytest.mark.asyncio
async def test_get_token_returns_none_for_missing(repo: SQLiteRepository) -> None:
assert await repo.get_canary_token("00000000-0000-0000-0000-000000000000") is None
assert await repo.get_canary_blob("00000000-0000-0000-0000-000000000000") is None
@pytest.mark.asyncio
async def test_update_state_returns_false_for_missing(repo: SQLiteRepository) -> None:
assert await repo.update_canary_token_state("missing", "revoked") is False

View File

@@ -0,0 +1,52 @@
"""Coverage for the on-disk blob store."""
from __future__ import annotations
import hashlib
from decnet.canary import storage
def test_write_blob_is_idempotent(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
sha1, p1, sz1 = storage.write_blob(b"hello canary")
sha2, p2, sz2 = storage.write_blob(b"hello canary")
assert sha1 == sha2 == hashlib.sha256(b"hello canary").hexdigest()
assert p1 == p2
assert sz1 == sz2 == len(b"hello canary")
# Two-level fan-out: ab/cd/abcd...
assert p1.parent.parent.parent == tmp_path
assert p1.parent.name == sha1[2:4]
assert p1.parent.parent.name == sha1[:2]
def test_read_blob_returns_bytes(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
sha, _, _ = storage.write_blob(b"some payload")
assert storage.read_blob(sha) == b"some payload"
def test_unlink_blob_returns_false_for_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
sha = "0" * 64
assert storage.unlink_blob(sha) is False
def test_unlink_blob_removes_file(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
sha, path, _ = storage.write_blob(b"to be removed")
assert path.exists()
assert storage.unlink_blob(sha) is True
assert not path.exists()
# Second unlink is a no-op rather than a crash.
assert storage.unlink_blob(sha) is False
def test_blob_dir_honors_env(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path / "alt"))
assert storage.blob_dir() == tmp_path / "alt"
def test_short_sha_rejected() -> None:
import pytest
with pytest.raises(ValueError):
storage._path_for("abc")

View File

@@ -0,0 +1,44 @@
"""Sanity check on the decnet-canary.service unit + decnet.target.
Tests are deliberately static (no rendering, no systemd) — they just
confirm the unit file exists, references the canary CLI command, is
included in the master target, and follows the same security
hardening posture as decnet-webhook.service.
"""
from __future__ import annotations
from pathlib import Path
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
def test_canary_unit_exists() -> None:
assert (DEPLOY / "decnet-canary.service.j2").exists()
def test_canary_unit_runs_decnet_canary() -> None:
body = (DEPLOY / "decnet-canary.service.j2").read_text()
assert "{{ venv_dir }}/bin/decnet canary" in body
assert "After=" in body and "decnet-bus.service" in body
def test_canary_unit_has_security_hardening() -> None:
"""Canary handles attacker traffic — must mirror webhook's hardening."""
body = (DEPLOY / "decnet-canary.service.j2").read_text()
for required in (
"NoNewPrivileges=yes",
"ProtectSystem=full",
"ProtectHome=read-only",
"PrivateTmp=yes",
"ProtectKernelTunables=yes",
"ProtectKernelModules=yes",
"ProtectControlGroups=yes",
"RestrictSUIDSGID=yes",
"LockPersonality=yes",
):
assert required in body, f"missing hardening directive: {required}"
def test_canary_listed_in_master_target() -> None:
body = (DEPLOY / "decnet.target").read_text()
assert "decnet-canary.service" in body

View File

@@ -0,0 +1,42 @@
"""Coverage for the canary bus-topic builder + constants.
The builder shares :func:`_reject_tokens` with every other family in
:mod:`decnet.bus.topics`, so we only need to exercise the canary
surface: the three leaf constants and that bogus segments are
rejected. Anything more would duplicate :mod:`tests.bus.test_topics`.
"""
from __future__ import annotations
import pytest
from decnet.bus import topics
def test_canary_constants_are_distinct() -> None:
assert topics.CANARY == "canary"
assert topics.CANARY_PLACED == "placed"
assert topics.CANARY_TRIGGERED == "triggered"
assert topics.CANARY_REVOKED == "revoked"
assert len({
topics.CANARY_PLACED,
topics.CANARY_TRIGGERED,
topics.CANARY_REVOKED,
}) == 3
def test_canary_builder_round_trip() -> None:
assert topics.canary("abc-123", topics.CANARY_TRIGGERED) == "canary.abc-123.triggered"
assert topics.canary("xyz", topics.CANARY_PLACED) == "canary.xyz.placed"
assert topics.canary("xyz", topics.CANARY_REVOKED) == "canary.xyz.revoked"
@pytest.mark.parametrize("bogus_id", ["", "with.dot", "with*wildcard", "with>chevron", "with space"])
def test_canary_builder_rejects_bad_token_id(bogus_id: str) -> None:
with pytest.raises(ValueError):
topics.canary(bogus_id, topics.CANARY_TRIGGERED)
@pytest.mark.parametrize("bogus_event", ["", "x.y", "*", ">"])
def test_canary_builder_rejects_bad_event(bogus_event: str) -> None:
with pytest.raises(ValueError):
topics.canary("good_id", bogus_event)

View File

@@ -0,0 +1,119 @@
"""DNS surface coverage for the canary worker.
We don't open a real UDP socket — instead we drive
:class:`CanaryDNSProtocol` directly with synthesised packets and
inspect the bytes it returns via a fake transport. Faster than a
real listener, and avoids needing privileged ports in the test
runner.
"""
from __future__ import annotations
import asyncio
import struct
from typing import AsyncIterator
import pytest
import pytest_asyncio
from decnet.canary.dns_server import (
CanaryDNSProtocol,
_encode_name,
parse_query,
)
def _build_query(qname: str, txid: int = 0xCAFE, qtype: int = 1) -> bytes:
header = struct.pack("!HHHHHH", txid, 0x0100, 1, 0, 0, 0) # RD bit set
return header + _encode_name(qname) + struct.pack("!HH", qtype, 1)
class _FakeTransport:
def __init__(self) -> None:
self.sent: list[tuple[bytes, tuple]] = []
def sendto(self, data: bytes, addr: tuple) -> None:
self.sent.append((data, addr))
@pytest_asyncio.fixture
async def proto_and_hits():
hits: list[tuple[str, str, str]] = []
async def hook(slug: str, query, src_ip: str) -> None: # type: ignore[no-untyped-def]
hits.append((slug, query.qname, src_ip))
proto = CanaryDNSProtocol("canary.example.test", hook, answer_ip="192.0.2.1")
transport = _FakeTransport()
proto.connection_made(transport)
yield proto, transport, hits
@pytest.mark.asyncio
async def test_known_slug_returns_answer_and_fires_hook(proto_and_hits) -> None:
proto, transport, hits = proto_and_hits
pkt = _build_query("slug42.canary.example.test")
proto.datagram_received(pkt, ("203.0.113.7", 12345))
# Allow the create_task hook to settle.
await asyncio.sleep(0)
await asyncio.sleep(0)
assert hits == [("slug42", "slug42.canary.example.test", "203.0.113.7")]
assert len(transport.sent) == 1
response = transport.sent[0][0]
# Header: ANCOUNT == 1, RCODE == 0 in lower 4 bits of flags[1].
_txid, flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", response[:12])
assert (flags & 0x0F) == 0 # NOERROR
assert an == 1
@pytest.mark.asyncio
async def test_unknown_slug_returns_nxdomain(proto_and_hits) -> None:
proto, transport, hits = proto_and_hits
pkt = _build_query("not-our-zone.example.com")
proto.datagram_received(pkt, ("203.0.113.7", 12345))
await asyncio.sleep(0)
assert hits == []
assert len(transport.sent) == 1
response = transport.sent[0][0]
_txid, flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", response[:12])
assert (flags & 0x0F) == 3 # NXDOMAIN
assert an == 0
@pytest.mark.asyncio
async def test_multi_label_subdomain_is_ignored(proto_and_hits) -> None:
"""Slug must be exactly one label. ``foo.bar.canary.example.test``
is an attacker probing a sub-resource we don't model — NXDOMAIN."""
proto, transport, hits = proto_and_hits
pkt = _build_query("foo.bar.canary.example.test")
proto.datagram_received(pkt, ("203.0.113.7", 12345))
await asyncio.sleep(0)
assert hits == []
@pytest.mark.asyncio
async def test_malformed_packet_is_dropped_silently(proto_and_hits) -> None:
proto, transport, hits = proto_and_hits
proto.datagram_received(b"\x00\x01\x02", ("203.0.113.7", 12345))
await asyncio.sleep(0)
assert hits == []
assert transport.sent == []
def test_parse_query_round_trip() -> None:
pkt = _build_query("abc.def.canary.example.test", txid=0x1234, qtype=1)
q = parse_query(pkt)
assert q.txid == 0x1234
assert q.qname == "abc.def.canary.example.test"
assert q.qtype == 1
assert q.qclass == 1
def test_parse_query_handles_pointer_loop() -> None:
"""Malicious packet with a pointer loop must raise, not hang."""
# Header (12) + name with a self-pointer at offset 12.
header = struct.pack("!HHHHHH", 0, 0x0100, 1, 0, 0, 0)
name = struct.pack("!H", 0xC00C) # pointer back to offset 12
qtype_qclass = struct.pack("!HH", 1, 1)
packet = header + name + qtype_qclass
with pytest.raises(ValueError, match="pointer loop"):
parse_query(packet)

View File

@@ -0,0 +1,124 @@
"""HTTP surface coverage for the canary worker.
We exercise the FastAPI app via Starlette's TestClient so the test
doesn't need a real socket. Asserts:
* ``GET /c/{slug}`` for a known slug returns 200 + image/gif, persists
a trigger row, bumps the token's counters, and publishes
``canary.<token_id>.triggered`` on the bus.
* ``GET /c/{slug}`` for an unknown slug returns the same 200 (stealth)
but persists nothing.
* The Server header is rewritten to a generic value (``nginx``).
* Bare root returns 404.
* X-Forwarded-For is honored.
"""
from __future__ import annotations
import asyncio
from typing import AsyncIterator
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from decnet.bus import topics
from decnet.bus.fake import FakeBus
from decnet.canary.worker import _build_app
from decnet.web.db.sqlite.repository import SQLiteRepository
import decnet.web.db.models # noqa: F401
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
r = SQLiteRepository(str(tmp_path / "w.db"))
await r.initialize()
yield r
@pytest_asyncio.fixture
async def bus() -> AsyncIterator[FakeBus]:
b = FakeBus()
await b.connect()
yield b
await b.close()
@pytest.mark.asyncio
async def test_known_slug_records_trigger_and_publishes(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
await repo.create_canary_token({
"uuid": "tok-w1", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug-W1", "secret_seed": "s", "created_by": "u1",
})
sub = bus.subscribe("canary.>")
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/slug-W1", headers={"User-Agent": "curl/8.0"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/gif")
assert resp.headers.get("server") == "nginx"
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
assert event.topic == topics.canary("tok-w1", topics.CANARY_TRIGGERED)
assert event.payload["src_ip"]
assert event.payload["user_agent"] == "curl/8.0"
triggers = await repo.list_canary_triggers("tok-w1")
assert len(triggers) == 1
assert triggers[0]["request_path"] == "/c/slug-W1"
tok = await repo.get_canary_token("tok-w1")
assert tok["trigger_count"] == 1
@pytest.mark.asyncio
async def test_unknown_slug_returns_same_response_but_persists_nothing(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/unknown-slug")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/gif")
# No tokens, no triggers, no nothing.
assert await repo.list_canary_tokens() == []
@pytest.mark.asyncio
async def test_root_returns_404(repo: SQLiteRepository, bus: FakeBus) -> None:
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_xff_is_honored(repo: SQLiteRepository, bus: FakeBus) -> None:
await repo.create_canary_token({
"uuid": "tok-xff", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug-xff", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get("/c/slug-xff", headers={"X-Forwarded-For": "9.9.9.9, 10.0.0.1"})
triggers = await repo.list_canary_triggers("tok-xff")
assert triggers[0]["src_ip"] == "9.9.9.9"
@pytest.mark.asyncio
async def test_no_decnet_strings_in_response(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Stealth posture: nothing in the HTTP surface mentions DECNET."""
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/anything")
body = resp.content.lower()
for v in resp.headers.values():
assert b"decnet" not in v.lower().encode()
assert b"decnet" not in body
# Docs / openapi / redoc are disabled.
assert client.get("/docs").status_code == 404
assert client.get("/openapi.json").status_code == 404
assert client.get("/redoc").status_code == 404