merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/canary/__init__.py
Normal file
0
tests/canary/__init__.py
Normal file
88
tests/canary/conftest.py
Normal file
88
tests/canary/conftest.py
Normal 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
24
tests/canary/test_cli.py
Normal 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
|
||||
145
tests/canary/test_cultivator.py
Normal file
145
tests/canary/test_cultivator.py
Normal 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}"
|
||||
)
|
||||
80
tests/canary/test_deploy_hook.py
Normal file
80
tests/canary/test_deploy_hook.py
Normal 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
|
||||
88
tests/canary/test_factory.py
Normal file
88
tests/canary/test_factory.py
Normal 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
|
||||
188
tests/canary/test_generators.py
Normal file
188
tests/canary/test_generators.py
Normal 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"
|
||||
173
tests/canary/test_instrumenters.py
Normal file
173
tests/canary/test_instrumenters.py
Normal 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")
|
||||
85
tests/canary/test_models.py
Normal file
85
tests/canary/test_models.py
Normal 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"
|
||||
68
tests/canary/test_paths.py
Normal file
68
tests/canary/test_paths.py
Normal 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)
|
||||
247
tests/canary/test_planter.py
Normal file
247
tests/canary/test_planter.py
Normal 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"]
|
||||
179
tests/canary/test_repository.py
Normal file
179
tests/canary/test_repository.py
Normal 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
|
||||
52
tests/canary/test_storage.py
Normal file
52
tests/canary/test_storage.py
Normal 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")
|
||||
44
tests/canary/test_systemd_unit.py
Normal file
44
tests/canary/test_systemd_unit.py
Normal 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
|
||||
42
tests/canary/test_topics.py
Normal file
42
tests/canary/test_topics.py
Normal 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)
|
||||
119
tests/canary/test_worker_dns.py
Normal file
119
tests/canary/test_worker_dns.py
Normal 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)
|
||||
124
tests/canary/test_worker_http.py
Normal file
124
tests/canary/test_worker_http.py
Normal 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
|
||||
Reference in New Issue
Block a user