merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
37
decnet/canary/__init__.py
Normal file
37
decnet/canary/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Canary tokens — decoy artifacts planted in decky filesystems.
|
||||
|
||||
Public surface is exported here so callers can ``from decnet.canary
|
||||
import CanaryArtifact, get_generator, get_instrumenter`` without
|
||||
knowing the submodule layout. Concrete generators / instrumenters
|
||||
live under :mod:`decnet.canary.generators` and
|
||||
:mod:`decnet.canary.instrumenters` respectively; the factory keeps
|
||||
import-time cost down by deferring those imports until first use
|
||||
(same pattern as :mod:`decnet.intel.factory`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryGenerator,
|
||||
CanaryInstrumenter,
|
||||
)
|
||||
from decnet.canary.factory import (
|
||||
KNOWN_GENERATORS,
|
||||
KNOWN_INSTRUMENTERS,
|
||||
get_generator,
|
||||
get_instrumenter,
|
||||
pick_instrumenter_for_mime,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CanaryArtifact",
|
||||
"CanaryContext",
|
||||
"CanaryGenerator",
|
||||
"CanaryInstrumenter",
|
||||
"KNOWN_GENERATORS",
|
||||
"KNOWN_INSTRUMENTERS",
|
||||
"get_generator",
|
||||
"get_instrumenter",
|
||||
"pick_instrumenter_for_mime",
|
||||
]
|
||||
145
decnet/canary/base.py
Normal file
145
decnet/canary/base.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Canary generator / instrumenter ABCs and the artifact dataclass.
|
||||
|
||||
Two flavors of producer share the same return shape:
|
||||
|
||||
* :class:`CanaryGenerator` synthesises a fake artifact from scratch
|
||||
(e.g. a plausible ``~/.aws/credentials`` block, a ``.git/config``
|
||||
pointing at an attacker-bait remote URL). Operators don't supply
|
||||
any input.
|
||||
|
||||
* :class:`CanaryInstrumenter` mutates an operator-uploaded blob to
|
||||
embed the callback (HTTP slug + DNS host). The original blob bytes
|
||||
are passed in; the instrumenter returns the mutated version.
|
||||
|
||||
Both return a :class:`CanaryArtifact` — the planter doesn't care
|
||||
which path produced it. Same dataclass keeps the planter's
|
||||
docker-exec injector trivial.
|
||||
|
||||
ABCs intentionally do not include I/O — generators and instrumenters
|
||||
are pure functions of (slug, host, blob?). All filesystem work
|
||||
happens in :mod:`decnet.canary.planter` and :mod:`decnet.canary.storage`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanaryContext:
|
||||
"""Inputs every generator/instrumenter needs to embed a working callback.
|
||||
|
||||
``callback_token`` is the unique slug; it appears verbatim in HTTP
|
||||
URLs (``https://<host>/c/<callback_token>``) and as the leftmost
|
||||
DNS label (``<callback_token>.canary.<dns_zone>``) so a single
|
||||
slug resolves to a single :class:`CanaryToken` row regardless of
|
||||
which path the attacker tripped.
|
||||
|
||||
``http_base`` and ``dns_zone`` come from the canary worker's
|
||||
public-facing config (``DECNET_CANARY_HTTP_BASE``,
|
||||
``DECNET_CANARY_DNS_ZONE``). When DNS isn't deployed,
|
||||
``dns_zone`` is empty and instrumenters that only have a DNS
|
||||
surface (e.g. an artifact whose only realistic embed point is a
|
||||
hostname) raise.
|
||||
"""
|
||||
|
||||
callback_token: str
|
||||
http_base: str # e.g. "https://canary.example.test" — no trailing slash
|
||||
dns_zone: str = "" # e.g. "canary.example.test"; "" disables DNS embeds
|
||||
persona: str = "linux" # "linux" | "windows" — drives default username, path style
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanaryArtifact:
|
||||
"""Bytes-and-placement bundle produced by a generator/instrumenter."""
|
||||
|
||||
path: str
|
||||
"""Absolute path inside the target container."""
|
||||
|
||||
content: bytes
|
||||
"""Final bytes that hit the decky filesystem.
|
||||
|
||||
Always raw bytes — the planter base64-encodes for the wire so
|
||||
binary blobs (DOCX/PNG/PDF) survive ``docker exec sh -c`` safely.
|
||||
"""
|
||||
|
||||
mode: int = 0o600
|
||||
"""Unix file mode. Defaults to ``0600`` because most realistic
|
||||
canary placements (``~/.aws/credentials``, ``.env``, ``id_rsa``)
|
||||
are operator-only. Honeydocs in user docs folders should pass
|
||||
``0o644``.
|
||||
"""
|
||||
|
||||
mtime_offset: int = 0
|
||||
"""Seconds relative to *now* for the planted file's mtime.
|
||||
|
||||
Negative values backdate the file so it doesn't look like it
|
||||
appeared the moment the decky was deployed. ``-86400 * 90`` (90
|
||||
days ago) is a common choice for ``honeydoc`` artifacts; ``0``
|
||||
means "stamp it now," which is fine for ``aws_creds``-like files
|
||||
that would plausibly be touched recently.
|
||||
"""
|
||||
|
||||
instrumenter: Optional[str] = None
|
||||
"""Identifier of the instrumenter that produced this artifact (for
|
||||
upload-driven tokens). Mirrored into ``CanaryToken.instrumenter``.
|
||||
Mutually exclusive with :attr:`generator`.
|
||||
"""
|
||||
|
||||
generator: Optional[str] = None
|
||||
"""Identifier of the generator that produced this artifact (for
|
||||
synthesised tokens). Mirrored into ``CanaryToken.generator``.
|
||||
Mutually exclusive with :attr:`instrumenter`.
|
||||
"""
|
||||
|
||||
notes: list[str] = field(default_factory=list)
|
||||
"""Human-readable notes about the embedding (e.g. "DOCX: injected
|
||||
1×1 remote image at relsId rId99"). Surfaced in the API
|
||||
``preview`` response so the operator sees what we did before
|
||||
planting. Never leaked to the attacker-facing surface.
|
||||
"""
|
||||
|
||||
|
||||
class CanaryGenerator(ABC):
|
||||
"""Produces a fake artifact from scratch."""
|
||||
|
||||
name: str #: short tag — matches ``CanaryToken.generator``
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
"""Synthesise the artifact.
|
||||
|
||||
MUST NOT do I/O. MUST be deterministic for the same
|
||||
``(callback_token, http_base, dns_zone, persona)`` so re-seeding
|
||||
from :attr:`CanaryToken.secret_seed` produces byte-identical
|
||||
output and the planter is naturally idempotent.
|
||||
"""
|
||||
|
||||
|
||||
class CanaryInstrumenter(ABC):
|
||||
"""Mutates an operator-uploaded blob to embed a callback."""
|
||||
|
||||
name: str #: short tag — matches ``CanaryToken.instrumenter``
|
||||
|
||||
#: MIME prefixes this instrumenter handles. The factory uses these
|
||||
#: to dispatch by sniffed content-type. Sub-string match against
|
||||
#: the prefix list (e.g. ``("application/pdf",)`` or
|
||||
#: ``("text/",)``).
|
||||
mime_prefixes: tuple[str, ...] = ()
|
||||
|
||||
@abstractmethod
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
"""Return the mutated bytes with the callback embedded.
|
||||
|
||||
MUST raise :class:`InstrumenterRejectedError` when the blob
|
||||
can't be safely mutated (corrupt zip, encrypted PDF, etc.) so
|
||||
the API can surface a 400 with the specific reason rather than
|
||||
silently shipping the original bytes.
|
||||
"""
|
||||
|
||||
|
||||
class InstrumenterRejectedError(ValueError):
|
||||
"""Raised when an instrumenter can't safely mutate the input."""
|
||||
181
decnet/canary/cultivator.py
Normal file
181
decnet/canary/cultivator.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Realism contract adapter for canary generators.
|
||||
|
||||
Stage 7 of the realism migration. The orchestrator's planner picks a
|
||||
``canary_*`` :class:`~decnet.realism.taxonomy.ContentClass` 1–3% of
|
||||
the time on file ticks; this module turns that pick into a
|
||||
:class:`~decnet.canary.base.CanaryArtifact` (bytes the SSH driver
|
||||
plants) plus a persisted :class:`~decnet.web.db.models.CanaryToken`
|
||||
row so the canary worker recognises the slug when an attacker trips
|
||||
it.
|
||||
|
||||
What this is NOT: it doesn't pick *when* canaries fire — that's the
|
||||
realism planner's job. It doesn't decide *where* on the filesystem
|
||||
the canary lands beyond what realism naming + persona conventions
|
||||
already produce. It's a thin bytes-and-row factory bolted onto the
|
||||
realism contract.
|
||||
|
||||
Stealth (per ``feedback_stealth.md``): we never leak the
|
||||
``DECNET`` literal into anything that survives to the planted file.
|
||||
The underlying generators are already stealth-clean; this wrapper
|
||||
must not undo that.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets as _secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext
|
||||
from decnet.canary.factory import get_generator
|
||||
from decnet.logging import get_logger
|
||||
from decnet.realism.personas import login_for
|
||||
from decnet.realism.taxonomy import ContentClass, Plan
|
||||
|
||||
log = get_logger("canary.cultivator")
|
||||
|
||||
|
||||
# realism content_class → canary generator name. Mirrors
|
||||
# :data:`decnet.canary.factory.KNOWN_GENERATORS`.
|
||||
_CLASS_TO_GENERATOR: dict[ContentClass, str] = {
|
||||
ContentClass.CANARY_AWS_CREDS: "aws_creds",
|
||||
ContentClass.CANARY_ENV_FILE: "env_file",
|
||||
ContentClass.CANARY_GIT_CONFIG: "git_config",
|
||||
ContentClass.CANARY_SSH_KEY: "ssh_key",
|
||||
ContentClass.CANARY_HONEYDOC: "honeydoc",
|
||||
ContentClass.CANARY_HONEYDOC_DOCX: "honeydoc_docx",
|
||||
ContentClass.CANARY_HONEYDOC_PDF: "honeydoc_pdf",
|
||||
ContentClass.CANARY_MYSQL_DUMP: "mysql_dump",
|
||||
}
|
||||
|
||||
|
||||
# Generator → CanaryKind. The trip surface (HTTP slug callback / DNS
|
||||
# resolution / passive bait) determines how the canary worker matches
|
||||
# an attacker callback to this token. Aligned with
|
||||
# :data:`decnet.web.db.models.canary.CanaryKind`.
|
||||
_GENERATOR_TO_KIND: dict[str, str] = {
|
||||
"aws_creds": "aws_passive", # no embedded callback; passive bait
|
||||
"env_file": "http",
|
||||
"git_config": "http",
|
||||
"honeydoc": "http",
|
||||
"honeydoc_docx": "http",
|
||||
"honeydoc_pdf": "http",
|
||||
"ssh_key": "dns", # trip is DNS resolution of host comment
|
||||
"mysql_dump": "dns", # trip is DNS resolution of subdomain
|
||||
}
|
||||
|
||||
|
||||
# Path conventions per generator. The realism planner doesn't know
|
||||
# about decoy-realistic credential locations (``~/.aws/credentials``,
|
||||
# ``~/.git/config``); we map them per-class here so the planted
|
||||
# artifact lands somewhere an attacker would actually look.
|
||||
_DEFAULT_PATH: dict[ContentClass, str] = {
|
||||
ContentClass.CANARY_AWS_CREDS: "/home/{persona}/.aws/credentials",
|
||||
ContentClass.CANARY_ENV_FILE: "/home/{persona}/app/.env",
|
||||
ContentClass.CANARY_GIT_CONFIG: "/home/{persona}/.git/config",
|
||||
ContentClass.CANARY_SSH_KEY: "/home/{persona}/.ssh/id_rsa",
|
||||
ContentClass.CANARY_HONEYDOC: "/home/{persona}/Documents/notes.html",
|
||||
ContentClass.CANARY_HONEYDOC_DOCX: "/home/{persona}/Documents/Q3-Operations-Review.docx",
|
||||
ContentClass.CANARY_HONEYDOC_PDF: "/home/{persona}/Documents/Q3-Operations-Review.pdf",
|
||||
ContentClass.CANARY_MYSQL_DUMP: "/var/backups/db_backup.sql",
|
||||
}
|
||||
|
||||
|
||||
def _path_for(plan: Plan) -> str:
|
||||
"""Produce the canary placement path for *plan*.
|
||||
|
||||
The realism planner already filled in ``plan.target_path`` from
|
||||
the namer, but canary placements have stronger conventions
|
||||
(``~/.aws/credentials``, ``~/.ssh/id_rsa``) than the realism
|
||||
namer's vocabulary. When :data:`_DEFAULT_PATH` has an entry,
|
||||
that wins.
|
||||
"""
|
||||
template = _DEFAULT_PATH.get(plan.content_class)
|
||||
if template is None:
|
||||
return plan.target_path
|
||||
return template.format(persona=login_for(plan.persona))
|
||||
|
||||
|
||||
def _new_callback_token() -> str:
|
||||
"""16 url-safe bytes — same shape canary slug fields use elsewhere."""
|
||||
return _secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
async def cultivate(
|
||||
plan: Plan,
|
||||
repo: Any,
|
||||
*,
|
||||
http_base: Optional[str] = None,
|
||||
dns_zone: Optional[str] = None,
|
||||
created_by: str = "system",
|
||||
) -> CanaryArtifact:
|
||||
"""Realism-driven canary plant.
|
||||
|
||||
Build a :class:`CanaryContext`, ask the right generator for bytes,
|
||||
persist a ``canary_tokens`` row so the canary worker can attribute
|
||||
callbacks to this token, and return the artifact for the SSH
|
||||
driver to plant.
|
||||
|
||||
*http_base* and *dns_zone* default to ``DECNET_CANARY_HTTP_BASE``
|
||||
and ``DECNET_CANARY_DNS_ZONE`` env vars respectively — same
|
||||
pattern the canary worker uses. When both are empty, generators
|
||||
that need a callback host (``ssh_key`` DNS, ``mysql_dump``)
|
||||
raise; the planner's caller logs and falls back to a non-canary
|
||||
plan.
|
||||
"""
|
||||
if not plan.content_class.is_canary():
|
||||
raise ValueError(
|
||||
f"cultivate() called with non-canary content_class="
|
||||
f"{plan.content_class!r}"
|
||||
)
|
||||
gen_name = _CLASS_TO_GENERATOR.get(plan.content_class)
|
||||
if gen_name is None:
|
||||
raise KeyError(
|
||||
f"no canary generator mapped for content_class="
|
||||
f"{plan.content_class!r}"
|
||||
)
|
||||
|
||||
callback_token = _new_callback_token()
|
||||
ctx = CanaryContext(
|
||||
callback_token=callback_token,
|
||||
http_base=http_base or os.environ.get("DECNET_CANARY_HTTP_BASE", ""),
|
||||
dns_zone=dns_zone or os.environ.get("DECNET_CANARY_DNS_ZONE", ""),
|
||||
persona="linux", # all our deckies are POSIX in MVP
|
||||
)
|
||||
generator = get_generator(gen_name)
|
||||
artifact = generator.generate(ctx)
|
||||
|
||||
# The generator returns ``path=""`` (planter fills it normally).
|
||||
# We have a realism-derived path on hand; stuff it in for the SSH
|
||||
# driver's plant_file call AND the canary_tokens row.
|
||||
placement_path = _path_for(plan)
|
||||
|
||||
# Persist the token row before planting so the canary worker can
|
||||
# attribute a callback if the artifact trips during the plant
|
||||
# itself (improbable but possible — DOCX viewers can preview
|
||||
# autoplay-style).
|
||||
await repo.create_canary_token({
|
||||
"kind": _GENERATOR_TO_KIND.get(gen_name, "http"),
|
||||
"decky_name": plan.decky_name,
|
||||
"instrumenter": None,
|
||||
"generator": gen_name,
|
||||
"placement_path": placement_path,
|
||||
"callback_token": callback_token,
|
||||
"secret_seed": callback_token, # deterministic re-seed compatible
|
||||
"placed_at": datetime.now(timezone.utc),
|
||||
"created_by": created_by,
|
||||
"state": "planted",
|
||||
})
|
||||
|
||||
# Carry the placement_path on the artifact so the orchestrator's
|
||||
# plant_file call uses it. We don't mutate the generator's
|
||||
# original — copy with the new path.
|
||||
return CanaryArtifact(
|
||||
path=placement_path,
|
||||
content=artifact.content,
|
||||
mode=artifact.mode,
|
||||
mtime_offset=artifact.mtime_offset,
|
||||
instrumenter=artifact.instrumenter,
|
||||
generator=artifact.generator,
|
||||
notes=list(artifact.notes),
|
||||
)
|
||||
207
decnet/canary/dns_server.py
Normal file
207
decnet/canary/dns_server.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Minimal authoritative DNS server for canary tokens (stdlib only).
|
||||
|
||||
We don't need a full resolver — only enough to:
|
||||
|
||||
1. Decode an inbound query's qname.
|
||||
2. If the qname matches ``<slug>.<canary_zone>``, log the callback,
|
||||
publish ``canary.<token_id>.triggered`` on the bus, and return a
|
||||
plausible A record (any RFC-5737 reserved address would do; we
|
||||
use 192.0.2.1) so the attacker's resolver doesn't loop on
|
||||
NXDOMAIN.
|
||||
3. For unknown qnames return NXDOMAIN.
|
||||
|
||||
DNS-over-UDP wire format is well-trodden: 12-byte header + name
|
||||
labels + qtype + qclass. We implement just the bits we need.
|
||||
|
||||
This module deliberately avoids the ``dnslib`` PyPI package so the
|
||||
canary worker has no extra dependency surface. If we ever need
|
||||
EDNS0, DNSSEC, or other niceties we'll swap to dnslib then.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DNSQuery:
|
||||
"""Decoded query — only the bits the canary worker cares about."""
|
||||
|
||||
txid: int
|
||||
qname: str # lowercase, no trailing dot
|
||||
qtype: int
|
||||
qclass: int
|
||||
flags: int
|
||||
|
||||
|
||||
def _decode_name(buf: bytes, offset: int) -> Tuple[str, int]:
|
||||
"""Return ``(qname_lowercase_no_dot, bytes_consumed)``.
|
||||
|
||||
Supports compressed pointers (RFC 1035 §4.1.4). Doesn't recurse —
|
||||
we walk the pointer chain iteratively with a hop cap to avoid
|
||||
pointer-loop DoS.
|
||||
"""
|
||||
labels: list[str] = []
|
||||
pos = offset
|
||||
consumed = 0
|
||||
jumped = False
|
||||
hops = 0
|
||||
while True:
|
||||
if pos >= len(buf):
|
||||
raise ValueError("truncated DNS name")
|
||||
length = buf[pos]
|
||||
if length == 0:
|
||||
pos += 1
|
||||
if not jumped:
|
||||
consumed = pos - offset
|
||||
break
|
||||
if (length & 0xC0) == 0xC0:
|
||||
# Compression pointer.
|
||||
if pos + 1 >= len(buf):
|
||||
raise ValueError("truncated DNS pointer")
|
||||
ptr = ((length & 0x3F) << 8) | buf[pos + 1]
|
||||
if not jumped:
|
||||
consumed = (pos + 2) - offset
|
||||
pos = ptr
|
||||
jumped = True
|
||||
hops += 1
|
||||
if hops > 10:
|
||||
raise ValueError("DNS pointer loop")
|
||||
continue
|
||||
pos += 1
|
||||
if pos + length > len(buf):
|
||||
raise ValueError("truncated DNS label")
|
||||
labels.append(buf[pos:pos + length].decode("ascii", "replace"))
|
||||
pos += length
|
||||
return ".".join(labels).lower(), consumed
|
||||
|
||||
|
||||
def parse_query(packet: bytes) -> DNSQuery:
|
||||
"""Parse the (single) question of a DNS query packet."""
|
||||
if len(packet) < 12:
|
||||
raise ValueError("DNS packet too short")
|
||||
txid, flags, qdcount, _ancount, _nscount, _arcount = struct.unpack(
|
||||
"!HHHHHH", packet[:12]
|
||||
)
|
||||
if qdcount != 1:
|
||||
raise ValueError(f"expected 1 question, got {qdcount}")
|
||||
qname, consumed = _decode_name(packet, 12)
|
||||
pos = 12 + consumed
|
||||
if pos + 4 > len(packet):
|
||||
raise ValueError("truncated DNS qtype/qclass")
|
||||
qtype, qclass = struct.unpack("!HH", packet[pos:pos + 4])
|
||||
return DNSQuery(
|
||||
txid=txid, qname=qname, qtype=qtype, qclass=qclass, flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def _encode_name(name: str) -> bytes:
|
||||
out = bytearray()
|
||||
for label in name.split("."):
|
||||
if not label:
|
||||
continue
|
||||
b = label.encode("ascii", "replace")
|
||||
out.append(len(b))
|
||||
out.extend(b)
|
||||
out.append(0)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _build_response(
|
||||
query: DNSQuery,
|
||||
*,
|
||||
rcode: int = 0,
|
||||
answer_ip: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""Encode a DNS response packet.
|
||||
|
||||
*rcode* 0 = NOERROR, 3 = NXDOMAIN. When *answer_ip* is supplied
|
||||
and the query was for an A record we include exactly one answer
|
||||
(TTL 60, class IN).
|
||||
"""
|
||||
qd_count = 1
|
||||
an_count = 1 if (answer_ip and query.qtype == 1 and rcode == 0) else 0
|
||||
flags = 0x8400 | rcode # response + authoritative + RA bit clear + rcode
|
||||
header = struct.pack(
|
||||
"!HHHHHH", query.txid, flags, qd_count, an_count, 0, 0,
|
||||
)
|
||||
qname_bytes = _encode_name(query.qname)
|
||||
question = qname_bytes + struct.pack("!HH", query.qtype, query.qclass)
|
||||
|
||||
answer = b""
|
||||
if an_count:
|
||||
# Use a name pointer back to the question (offset 12).
|
||||
ptr = struct.pack("!H", 0xC000 | 12)
|
||||
rdata = bytes(int(o) for o in answer_ip.split("."))
|
||||
answer = ptr + struct.pack("!HHIH", 1, 1, 60, 4) + rdata
|
||||
|
||||
return header + question + answer
|
||||
|
||||
|
||||
# Hook signature: receives the matched slug + the query; returns
|
||||
# nothing. The worker uses it to persist a CanaryTrigger row and
|
||||
# publish the bus event.
|
||||
TriggerHook = Callable[[str, DNSQuery, str], Awaitable[None]]
|
||||
|
||||
|
||||
class CanaryDNSProtocol(asyncio.DatagramProtocol):
|
||||
"""asyncio UDP server endpoint for canary DNS callbacks.
|
||||
|
||||
Constructor takes the canary zone (``"canary.example.test"``) and
|
||||
a coroutine called when a query matches ``<slug>.<zone>``. The
|
||||
hook runs in the event loop's task; we don't block the receive
|
||||
path on it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
zone: str,
|
||||
hook: TriggerHook,
|
||||
*,
|
||||
answer_ip: str = "192.0.2.1",
|
||||
) -> None:
|
||||
# Normalise: lowercase, no leading/trailing dot.
|
||||
self._zone = zone.lower().strip(".")
|
||||
self._suffix = "." + self._zone if self._zone else ""
|
||||
self._hook = hook
|
||||
self._answer_ip = answer_ip
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
|
||||
def connection_made(self, transport) -> None: # type: ignore[override]
|
||||
self._transport = transport # type: ignore[assignment]
|
||||
|
||||
def datagram_received( # type: ignore[override]
|
||||
self, data: bytes, addr: Tuple[str, int],
|
||||
) -> None:
|
||||
try:
|
||||
query = parse_query(data)
|
||||
except ValueError:
|
||||
# Malformed query — drop silently. Returning a FORMERR
|
||||
# would tip off the attacker that *something* is listening
|
||||
# on this port; the stealth posture (feedback_stealth)
|
||||
# prefers radio silence on parse errors.
|
||||
return
|
||||
slug = self._slug_for(query.qname)
|
||||
if slug is None:
|
||||
# Unknown name — NXDOMAIN.
|
||||
self._send(addr, _build_response(query, rcode=3))
|
||||
return
|
||||
# Known name — answer with our sinkhole IP, then fire the hook.
|
||||
self._send(addr, _build_response(query, answer_ip=self._answer_ip))
|
||||
asyncio.create_task(self._hook(slug, query, addr[0]))
|
||||
|
||||
def _slug_for(self, qname: str) -> Optional[str]:
|
||||
if not self._zone or not qname.endswith(self._suffix):
|
||||
return None
|
||||
slug = qname[: -len(self._suffix)]
|
||||
# Single-label slug only; multi-label means the attacker is
|
||||
# querying a sub-resource we don't model.
|
||||
if not slug or "." in slug:
|
||||
return None
|
||||
return slug
|
||||
|
||||
def _send(self, addr: Tuple[str, int], packet: bytes) -> None:
|
||||
if self._transport is not None:
|
||||
self._transport.sendto(packet, addr)
|
||||
141
decnet/canary/factory.py
Normal file
141
decnet/canary/factory.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Generator and instrumenter factories.
|
||||
|
||||
Same lazy-import pattern as :mod:`decnet.intel.factory` — concrete
|
||||
implementations stay un-imported until first use so importing
|
||||
:mod:`decnet.canary` from a CLI subcommand doesn't drag in
|
||||
``pikepdf`` / ``python-docx`` / ``Pillow`` for callers that only
|
||||
need the model layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from decnet.canary.base import CanaryGenerator, CanaryInstrumenter
|
||||
|
||||
KNOWN_GENERATORS: Tuple[str, ...] = (
|
||||
"git_config",
|
||||
"env_file",
|
||||
"ssh_key",
|
||||
"aws_creds",
|
||||
"honeydoc",
|
||||
"honeydoc_docx",
|
||||
"honeydoc_pdf",
|
||||
"mysql_dump",
|
||||
)
|
||||
|
||||
KNOWN_INSTRUMENTERS: Tuple[str, ...] = (
|
||||
"docx",
|
||||
"xlsx",
|
||||
"pdf",
|
||||
"html",
|
||||
"image",
|
||||
"plain",
|
||||
"passthrough",
|
||||
)
|
||||
|
||||
|
||||
def get_generator(name: str) -> CanaryGenerator:
|
||||
"""Return the generator registered under ``name``.
|
||||
|
||||
Raises :class:`ValueError` for unknown names so a typo in the API
|
||||
request surfaces as a 400 rather than silently producing nothing.
|
||||
"""
|
||||
if name == "git_config":
|
||||
from decnet.canary.generators.git_config import GitConfigGenerator
|
||||
return GitConfigGenerator()
|
||||
if name == "env_file":
|
||||
from decnet.canary.generators.env_file import EnvFileGenerator
|
||||
return EnvFileGenerator()
|
||||
if name == "ssh_key":
|
||||
from decnet.canary.generators.ssh_key import SSHKeyGenerator
|
||||
return SSHKeyGenerator()
|
||||
if name == "aws_creds":
|
||||
from decnet.canary.generators.aws_creds import AWSCredsGenerator
|
||||
return AWSCredsGenerator()
|
||||
if name == "honeydoc":
|
||||
from decnet.canary.generators.honeydoc import HoneydocGenerator
|
||||
return HoneydocGenerator()
|
||||
if name == "honeydoc_docx":
|
||||
from decnet.canary.generators.honeydoc_docx import HoneydocDocxGenerator
|
||||
return HoneydocDocxGenerator()
|
||||
if name == "honeydoc_pdf":
|
||||
from decnet.canary.generators.honeydoc_pdf import HoneydocPdfGenerator
|
||||
return HoneydocPdfGenerator()
|
||||
if name == "mysql_dump":
|
||||
from decnet.canary.generators.mysql_dump import MySQLDumpGenerator
|
||||
return MySQLDumpGenerator()
|
||||
raise ValueError(
|
||||
f"Unknown canary generator: {name!r}. Known: {KNOWN_GENERATORS}"
|
||||
)
|
||||
|
||||
|
||||
def get_instrumenter(name: str) -> CanaryInstrumenter:
|
||||
"""Return the instrumenter registered under ``name``."""
|
||||
if name == "docx":
|
||||
from decnet.canary.instrumenters.docx import DocxInstrumenter
|
||||
return DocxInstrumenter()
|
||||
if name == "xlsx":
|
||||
from decnet.canary.instrumenters.xlsx import XlsxInstrumenter
|
||||
return XlsxInstrumenter()
|
||||
if name == "pdf":
|
||||
from decnet.canary.instrumenters.pdf import PdfInstrumenter
|
||||
return PdfInstrumenter()
|
||||
if name == "html":
|
||||
from decnet.canary.instrumenters.html import HtmlInstrumenter
|
||||
return HtmlInstrumenter()
|
||||
if name == "image":
|
||||
from decnet.canary.instrumenters.image import ImageInstrumenter
|
||||
return ImageInstrumenter()
|
||||
if name == "plain":
|
||||
from decnet.canary.instrumenters.plain import PlainInstrumenter
|
||||
return PlainInstrumenter()
|
||||
if name == "passthrough":
|
||||
from decnet.canary.instrumenters.passthrough import PassthroughInstrumenter
|
||||
return PassthroughInstrumenter()
|
||||
raise ValueError(
|
||||
f"Unknown canary instrumenter: {name!r}. Known: {KNOWN_INSTRUMENTERS}"
|
||||
)
|
||||
|
||||
|
||||
# MIME → instrumenter dispatch. Order matters: we walk the table
|
||||
# top-to-bottom and the first prefix match wins, so put the more
|
||||
# specific (DOCX/XLSX) before the generic (zip/octet-stream).
|
||||
_MIME_DISPATCH: tuple[tuple[str, str], ...] = (
|
||||
# Office Open XML — DOCX/XLSX share a zip structure but expose
|
||||
# different inner trees, so dispatch by MIME alias rather than
|
||||
# zip-poking.
|
||||
("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"),
|
||||
("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
|
||||
("application/pdf", "pdf"),
|
||||
("text/html", "html"),
|
||||
("application/xhtml+xml", "html"),
|
||||
("image/png", "image"),
|
||||
("image/jpeg", "image"),
|
||||
("image/gif", "image"),
|
||||
# Plaintext catch-alls — config files, .env, .ini, .yaml, .json,
|
||||
# source code. All handled by the same regex-substitution pass.
|
||||
("text/", "plain"),
|
||||
("application/json", "plain"),
|
||||
("application/x-yaml", "plain"),
|
||||
("application/yaml", "plain"),
|
||||
("application/toml", "plain"),
|
||||
)
|
||||
|
||||
|
||||
def pick_instrumenter_for_mime(content_type: str) -> str:
|
||||
"""Return the instrumenter name registered for a sniffed MIME.
|
||||
|
||||
Falls back to ``"passthrough"`` for anything we don't have an
|
||||
embedder for (binary blobs we can't mutate safely — random
|
||||
container images, archives, executables). ``passthrough`` only
|
||||
supports DNS-callback tokens (the slug ends up in the filename or
|
||||
an accompanying README), so the API surfaces that constraint to
|
||||
the operator before they pick a kind.
|
||||
"""
|
||||
if not content_type:
|
||||
return "passthrough"
|
||||
lowered = content_type.lower()
|
||||
for prefix, name in _MIME_DISPATCH:
|
||||
if lowered.startswith(prefix):
|
||||
return name
|
||||
return "passthrough"
|
||||
7
decnet/canary/generators/__init__.py
Normal file
7
decnet/canary/generators/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Built-in canary generators (synthesised fake artifacts).
|
||||
|
||||
Concrete classes live in sibling modules and are imported lazily by
|
||||
:func:`decnet.canary.factory.get_generator` to keep the import-time
|
||||
cost of :mod:`decnet.canary` cheap for callers that only need the
|
||||
ABCs.
|
||||
"""
|
||||
86
decnet/canary/generators/aws_creds.py
Normal file
86
decnet/canary/generators/aws_creds.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Fake ``~/.aws/credentials`` block (passive bait).
|
||||
|
||||
This is the **passive** variant — no callback wiring. An attacker
|
||||
who exfils these keys can't trip a detection unless we run a real
|
||||
AWS account with a deny-all CloudTrail listener (post-v1). The
|
||||
realism is the point: the file looks like a routinely used credentials
|
||||
file, so the rest of the decky's persona feels lived-in.
|
||||
|
||||
If the operator picks ``kind="aws_passive"`` we accept that no slug
|
||||
will be embedded. If they pick ``kind="http"`` or ``kind="dns"`` for
|
||||
this generator, the API will reject the combination with a 400 — AWS
|
||||
keys have no plausible field where a URL or hostname survives a
|
||||
``grep -E '[A-Z0-9]{20}'`` smell test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
# Stable AWS-style key body derived from the slug. Keeping the
|
||||
# generator deterministic (per-slug) means re-seeding produces the
|
||||
# same bytes — the planter is naturally idempotent and an operator
|
||||
# who runs ``decnet canary verify`` can re-derive the expected file
|
||||
# without touching the DB.
|
||||
|
||||
def _fake_access_key(seed: str) -> str:
|
||||
# AWS access keys are 20 chars, uppercase alphanum, AKIA prefix.
|
||||
body = hashlib.sha256(seed.encode()).hexdigest().upper()
|
||||
return "AKIA" + body[:16]
|
||||
|
||||
|
||||
def _fake_secret_key(seed: str) -> str:
|
||||
# AWS secret keys are 40 chars, mixed-case base64-ish. We use
|
||||
# base64-safe characters from token_urlsafe seeded by a SHA-256
|
||||
# of the seed so the output is stable per slug.
|
||||
h = hashlib.sha256(("secret:" + seed).encode()).digest()
|
||||
# Reuse token_urlsafe for the alphabet but pad to 40 chars from
|
||||
# the deterministic bytes so we don't depend on os.urandom.
|
||||
import base64
|
||||
return base64.b64encode(h)[:40].decode()
|
||||
|
||||
|
||||
class AWSCredsGenerator(CanaryGenerator):
|
||||
name = "aws_creds"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
seed = ctx.callback_token
|
||||
access = _fake_access_key(seed)
|
||||
secret = _fake_secret_key(seed)
|
||||
body = (
|
||||
"[default]\n"
|
||||
f"aws_access_key_id = {access}\n"
|
||||
f"aws_secret_access_key = {secret}\n"
|
||||
"region = us-east-1\n"
|
||||
"\n"
|
||||
"[prod]\n"
|
||||
f"aws_access_key_id = {_fake_access_key('prod-' + seed)}\n"
|
||||
f"aws_secret_access_key = {_fake_secret_key('prod-' + seed)}\n"
|
||||
"region = us-west-2\n"
|
||||
)
|
||||
return CanaryArtifact(
|
||||
path="", # caller (planter) fills this from CanaryToken.placement_path
|
||||
content=body.encode("utf-8"),
|
||||
mode=0o600,
|
||||
mtime_offset=-86400 * 14, # 2 weeks ago — looks lived-in
|
||||
generator=self.name,
|
||||
notes=[
|
||||
"fake AWS keys; no callback embedded — passive bait only",
|
||||
f"derived deterministically from slug={seed}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Re-exported so the slug helper is reusable from the
|
||||
# instrumenters/passthrough module without an internal import path.
|
||||
__all__ = ["AWSCredsGenerator", "_fake_access_key", "_fake_secret_key"]
|
||||
|
||||
|
||||
# Imports at the bottom keep the public dataclasses on top — pylint
|
||||
# doesn't run on this repo, but tests do, and putting ``token_urlsafe``
|
||||
# in a public symbol confuses readers. Suppress the unused warning by
|
||||
# referencing it once.
|
||||
_ = token_urlsafe
|
||||
56
decnet/canary/generators/env_file.py
Normal file
56
decnet/canary/generators/env_file.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Fake ``.env`` with embedded callback URLs.
|
||||
|
||||
Modern web stacks read environment variables for everything from
|
||||
database DSNs to webhook URLs, so dropping a few realistic-looking
|
||||
``KEY=value`` pairs alongside the canary URL is unremarkable. The
|
||||
slug appears in two fields:
|
||||
|
||||
* ``API_BASE_URL`` — the obvious one; an attacker scripting against
|
||||
the credentials hits the worker on first invocation.
|
||||
* ``WEBHOOK_NOTIFY_URL`` — secondary, in case the attacker greps for
|
||||
``WEBHOOK`` and pivots there.
|
||||
|
||||
Other fields (``DB_PASSWORD``, ``REDIS_URL``, ``JWT_SECRET``) are
|
||||
plausible but inert — they're realism filler, not detection
|
||||
mechanisms.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
def _stable_token(seed: str, prefix: str = "") -> str:
|
||||
h = hashlib.sha256((prefix + seed).encode()).hexdigest()
|
||||
return h[:32]
|
||||
|
||||
|
||||
class EnvFileGenerator(CanaryGenerator):
|
||||
name = "env_file"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
base = ctx.http_base.rstrip("/")
|
||||
slug = ctx.callback_token
|
||||
api_url = f"{base}/c/{slug}"
|
||||
body = (
|
||||
"# Production environment — DO NOT COMMIT\n"
|
||||
f"API_BASE_URL={api_url}\n"
|
||||
f"WEBHOOK_NOTIFY_URL={api_url}/webhook\n"
|
||||
f"DB_PASSWORD={_stable_token(slug, 'db:')}\n"
|
||||
f"REDIS_URL=redis://:{_stable_token(slug, 'redis:')[:16]}@redis.internal:6379/0\n"
|
||||
f"JWT_SECRET={_stable_token(slug, 'jwt:')}\n"
|
||||
"LOG_LEVEL=info\n"
|
||||
"ENVIRONMENT=production\n"
|
||||
)
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=body.encode("utf-8"),
|
||||
mode=0o600,
|
||||
mtime_offset=-86400 * 7, # last edited a week ago
|
||||
generator=self.name,
|
||||
notes=[
|
||||
f"API_BASE_URL embeds {api_url}",
|
||||
f"WEBHOOK_NOTIFY_URL embeds {api_url}/webhook",
|
||||
],
|
||||
)
|
||||
53
decnet/canary/generators/git_config.py
Normal file
53
decnet/canary/generators/git_config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Fake ``.git/config`` with an attacker-bait remote URL.
|
||||
|
||||
The ``[remote "origin"]`` ``url`` field is the natural place to embed
|
||||
an HTTP-callback URL: it's normal for git remotes to be HTTPS, the
|
||||
URL is read by every git command an attacker runs (``git pull``,
|
||||
``git fetch``, ``git remote -v``), and the slug fits naturally as
|
||||
part of a path.
|
||||
|
||||
The generator emits a plausible private-mirror remote (``git.<org>``
|
||||
or the canary host's hostname) so an attacker doesn't immediately
|
||||
recognise it as a honeypot. The slug ends up in the URL path:
|
||||
|
||||
[remote "origin"]
|
||||
url = https://canary.example.test/c/<slug>/repo.git
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
class GitConfigGenerator(CanaryGenerator):
|
||||
name = "git_config"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
# Strip trailing slash defensively — operator may have
|
||||
# configured DECNET_CANARY_HTTP_BASE either way.
|
||||
base = ctx.http_base.rstrip("/")
|
||||
slug = ctx.callback_token
|
||||
# The /c/<slug>/repo.git suffix gives us a realistic-looking
|
||||
# path the worker can route on a single ``startswith("/c/")``
|
||||
# check, while still surviving a quick grep for the slug.
|
||||
url = f"{base}/c/{slug}/repo.git"
|
||||
body = (
|
||||
"[core]\n"
|
||||
"\trepositoryformatversion = 0\n"
|
||||
"\tfilemode = true\n"
|
||||
"\tbare = false\n"
|
||||
"\tlogallrefupdates = true\n"
|
||||
"[remote \"origin\"]\n"
|
||||
f"\turl = {url}\n"
|
||||
"\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
|
||||
"[branch \"main\"]\n"
|
||||
"\tremote = origin\n"
|
||||
"\tmerge = refs/heads/main\n"
|
||||
)
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=body.encode("utf-8"),
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 30, # checked out a month ago
|
||||
generator=self.name,
|
||||
notes=[f"git remote 'origin' embeds {url}"],
|
||||
)
|
||||
61
decnet/canary/generators/honeydoc.py
Normal file
61
decnet/canary/generators/honeydoc.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Built-in honeydoc — a minimal HTML "report" with a tracking pixel.
|
||||
|
||||
This is the *fallback* honeydoc used when the operator hasn't
|
||||
uploaded a real document. The HTML instrumenter handles operator
|
||||
uploads via :mod:`decnet.canary.instrumenters.html`; this generator
|
||||
exists so the deploy-time baseline can plant *something* convincing
|
||||
without first prompting the operator to drop a file.
|
||||
|
||||
The realism here is intentionally modest: a Documents-folder HTML
|
||||
page with internal-looking content and a 1×1 remote image at the
|
||||
bottom whose ``src`` is the canary callback URL. Most desktop
|
||||
HTML renderers fetch the image as soon as the file is opened in a
|
||||
browser preview, so opening the doc trips the callback.
|
||||
|
||||
Operators who want a richer artifact should upload their own DOCX
|
||||
or PDF; the corresponding instrumenter embeds the same callback in
|
||||
the appropriate format.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
class HoneydocGenerator(CanaryGenerator):
|
||||
name = "honeydoc"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
base = ctx.http_base.rstrip("/")
|
||||
slug = ctx.callback_token
|
||||
pixel_url = f"{base}/c/{slug}"
|
||||
body = (
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html lang=\"en\">\n"
|
||||
"<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<title>Q3 Operations Review — DRAFT</title>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"<h1>Q3 Operations Review (DRAFT — DO NOT DISTRIBUTE)</h1>\n"
|
||||
"<p>Forecast and remediation timeline below. Numbers are\n"
|
||||
"preliminary and subject to revision before the all-hands.</p>\n"
|
||||
"<table>\n"
|
||||
"<tr><th>Region</th><th>Incidents</th><th>MTTR (h)</th></tr>\n"
|
||||
"<tr><td>us-east</td><td>14</td><td>3.2</td></tr>\n"
|
||||
"<tr><td>us-west</td><td>9</td><td>4.7</td></tr>\n"
|
||||
"<tr><td>eu-central</td><td>22</td><td>2.1</td></tr>\n"
|
||||
"</table>\n"
|
||||
"<p>Internal contact: <a href=\"mailto:secops@internal\">"
|
||||
"secops@internal</a></p>\n"
|
||||
f"<img src=\"{pixel_url}\" width=\"1\" height=\"1\" alt=\"\">\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
)
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=body.encode("utf-8"),
|
||||
mode=0o644, # docs are typically world-readable
|
||||
mtime_offset=-86400 * 21, # 3 weeks ago
|
||||
generator=self.name,
|
||||
notes=[f"tracking pixel src={pixel_url}"],
|
||||
)
|
||||
133
decnet/canary/generators/honeydoc_docx.py
Normal file
133
decnet/canary/generators/honeydoc_docx.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Real-DOCX honeydoc generator.
|
||||
|
||||
Synthesises a minimal but structurally valid DOCX from scratch via
|
||||
stdlib :mod:`zipfile`, then uses the same external-image relationship
|
||||
trick that powers :mod:`decnet.canary.instrumenters.docx` to embed
|
||||
the callback URL. No python-docx dependency.
|
||||
|
||||
The output opens cleanly in Word / LibreOffice; both fetch the
|
||||
external image relationship on document load.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
from decnet.canary.instrumenters.docx import _drawing, _next_rid
|
||||
|
||||
|
||||
_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>'
|
||||
).encode()
|
||||
|
||||
_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>'
|
||||
).encode()
|
||||
|
||||
_BODY_PARAGRAPHS = (
|
||||
"Q3 Operations Review (DRAFT — DO NOT DISTRIBUTE)",
|
||||
"",
|
||||
"Forecast and remediation timeline below. Numbers are preliminary "
|
||||
"and subject to revision before the all-hands.",
|
||||
"",
|
||||
"Region Incidents MTTR (h)",
|
||||
"us-east 14 3.2",
|
||||
"us-west 9 4.7",
|
||||
"eu-central 22 2.1",
|
||||
"",
|
||||
"Internal contact: secops@internal",
|
||||
)
|
||||
|
||||
|
||||
def _document_xml(rid_with_drawing: str | None = None) -> bytes:
|
||||
"""Build the body XML.
|
||||
|
||||
``rid_with_drawing`` is the rId of the external image relationship;
|
||||
when set, we append the same ``<w:drawing>`` element that the DOCX
|
||||
instrumenter inserts so the body references the external resource.
|
||||
"""
|
||||
paragraphs = []
|
||||
for line in _BODY_PARAGRAPHS:
|
||||
if line:
|
||||
paragraphs.append(
|
||||
"<w:p><w:r><w:t xml:space=\"preserve\">"
|
||||
+ _xml_escape(line)
|
||||
+ "</w:t></w:r></w:p>"
|
||||
)
|
||||
else:
|
||||
paragraphs.append("<w:p/>")
|
||||
body = "".join(paragraphs)
|
||||
drawing = _drawing(rid_with_drawing).decode() if rid_with_drawing else ""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
f'<w:body>{body}{drawing}</w:body>'
|
||||
'</w:document>'
|
||||
).encode()
|
||||
|
||||
|
||||
def _xml_escape(s: str) -> str:
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def _document_rels(rid: str, url: str) -> bytes:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
f'<Relationship Id="{rid}" '
|
||||
f'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
f'Target="{url}" TargetMode="External"/>'
|
||||
'</Relationships>'
|
||||
).encode()
|
||||
|
||||
|
||||
class HoneydocDocxGenerator(CanaryGenerator):
|
||||
name = "honeydoc_docx"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
# Pick a stable rId — there's only one relationship in the
|
||||
# synthesised file, so any unused id works. Reuse the
|
||||
# instrumenter's allocator against the bare relationships
|
||||
# skeleton for parity with operator-uploaded DOCX flow.
|
||||
skeleton = (
|
||||
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
b'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
b'</Relationships>'
|
||||
)
|
||||
rid = _next_rid(skeleton)
|
||||
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", _CONTENT_TYPES)
|
||||
zf.writestr("_rels/.rels", _PACKAGE_RELS)
|
||||
zf.writestr("word/document.xml", _document_xml(rid))
|
||||
zf.writestr("word/_rels/document.xml.rels", _document_rels(rid, url))
|
||||
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=out.getvalue(),
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 21,
|
||||
generator=self.name,
|
||||
notes=[
|
||||
"synthesised DOCX with realistic Q3 review body",
|
||||
f"external-image relationship {rid} -> {url}",
|
||||
],
|
||||
)
|
||||
127
decnet/canary/generators/honeydoc_pdf.py
Normal file
127
decnet/canary/generators/honeydoc_pdf.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Real-PDF honeydoc generator (uses :mod:`pikepdf`).
|
||||
|
||||
Builds a one-page PDF with the same Q3-review body as the HTML/DOCX
|
||||
flavors and installs an ``/OpenAction`` ``/URI`` action on the
|
||||
catalog so most viewers fire the callback the moment the document
|
||||
opens.
|
||||
|
||||
Pikepdf is now a hard dependency for this generator (the operator
|
||||
installed it explicitly so we can use it). We still surface a
|
||||
clear :class:`InstrumenterRejectedError` when imports fail, so a
|
||||
deployment without pikepdf can fall back to the DOCX or HTML
|
||||
generators rather than crashing the API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryGenerator,
|
||||
InstrumenterRejectedError,
|
||||
)
|
||||
|
||||
|
||||
_BODY_LINES = (
|
||||
("Q3 Operations Review (DRAFT — DO NOT DISTRIBUTE)", 14),
|
||||
("", 12),
|
||||
("Forecast and remediation timeline below.", 11),
|
||||
("Numbers are preliminary, subject to revision.", 11),
|
||||
("", 12),
|
||||
("Region Incidents MTTR (h)", 11),
|
||||
("us-east 14 3.2", 11),
|
||||
("us-west 9 4.7", 11),
|
||||
("eu-central 22 2.1", 11),
|
||||
("", 12),
|
||||
("Internal contact: secops@internal", 11),
|
||||
)
|
||||
|
||||
|
||||
class HoneydocPdfGenerator(CanaryGenerator):
|
||||
name = "honeydoc_pdf"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
try:
|
||||
from pikepdf import Pdf, Name, Dictionary, String # type: ignore[import-not-found]
|
||||
except ImportError as e:
|
||||
raise InstrumenterRejectedError(
|
||||
"honeydoc_pdf requires pikepdf; install it (`pip install "
|
||||
"pikepdf`) or pick honeydoc / honeydoc_docx instead."
|
||||
) from e
|
||||
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
|
||||
pdf = Pdf.new()
|
||||
# Helvetica is one of the 14 PDF base fonts — every viewer ships
|
||||
# it, so no font embedding is required.
|
||||
font = pdf.make_indirect(Dictionary(
|
||||
Type=Name("/Font"),
|
||||
Subtype=Name("/Type1"),
|
||||
BaseFont=Name("/Helvetica"),
|
||||
))
|
||||
|
||||
# Build a single content stream that writes each body line at a
|
||||
# decreasing y-coordinate. PDF coordinates start at the bottom-
|
||||
# left (US Letter = 612 x 792 points); we lay out lines roughly
|
||||
# 18 points apart starting near the top.
|
||||
ops: list[str] = ["BT /F1 12 Tf 72 750 Td"]
|
||||
first = True
|
||||
for line, size in _BODY_LINES:
|
||||
if not first:
|
||||
ops.append("0 -18 Td")
|
||||
first = False
|
||||
ops.append(f"/F1 {size} Tf")
|
||||
ops.append(f"({_pdf_escape(line)}) Tj")
|
||||
ops.append("ET")
|
||||
content_bytes = "\n".join(ops).encode("latin-1")
|
||||
|
||||
content_stream = pdf.make_stream(content_bytes)
|
||||
|
||||
page = pdf.add_blank_page(page_size=(612, 792))
|
||||
page[Name("/Resources")] = Dictionary(
|
||||
Font=Dictionary(F1=font),
|
||||
)
|
||||
page[Name("/Contents")] = content_stream
|
||||
|
||||
# OpenAction fires the URI when the file is opened in Acrobat,
|
||||
# Preview, the browser PDF viewer, etc. Most viewers prompt
|
||||
# before fetching; that prompt itself is a tell, and an
|
||||
# auto-allow viewer fetches silently.
|
||||
pdf.Root[Name("/OpenAction")] = Dictionary(
|
||||
Type=Name("/Action"),
|
||||
S=Name("/URI"),
|
||||
URI=String(url),
|
||||
)
|
||||
|
||||
out = io.BytesIO()
|
||||
pdf.save(out)
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=out.getvalue(),
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 21,
|
||||
generator=self.name,
|
||||
notes=[
|
||||
"synthesised one-page PDF with realistic Q3 review body",
|
||||
f"/OpenAction /URI -> {url}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _pdf_escape(s: str) -> str:
|
||||
"""Escape parens and backslashes for PDF literal-string syntax.
|
||||
|
||||
PDF string literals are wrapped in ``( … )``; inner ``(``, ``)``,
|
||||
and ``\\`` need backslash escapes. Everything else (including
|
||||
UTF-8 multibyte sequences) round-trips fine because Helvetica's
|
||||
encoding is WinAnsi-ish — we'll lose exotic glyphs but the
|
||||
realistic body sticks to ASCII anyway. Em-dashes are downgraded
|
||||
to ``--`` to avoid the WinAnsi gap.
|
||||
"""
|
||||
return (
|
||||
s.replace("\\", r"\\")
|
||||
.replace("(", r"\(")
|
||||
.replace(")", r"\)")
|
||||
.replace("—", "--")
|
||||
)
|
||||
190
decnet/canary/generators/mysql_dump.py
Normal file
190
decnet/canary/generators/mysql_dump.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Fake ``mysqldump`` output that phones home on import.
|
||||
|
||||
Mirrors the Canarytokens.org MySQL-dump trick. When a victim runs
|
||||
``mysql < dump.sql``, the trailer block executes a base64-obfuscated
|
||||
``CHANGE REPLICATION SOURCE TO`` against ``<slug>.canary.<dns_zone>``
|
||||
followed by ``START REPLICA``. The victim's MySQL daemon then:
|
||||
|
||||
1. Resolves the slug subdomain via DNS — this is the trip our
|
||||
:mod:`decnet.canary.dns_server` already detects.
|
||||
2. Opens a TCP replica handshake on port 3306, sending its own
|
||||
``@@hostname`` and ``@@lc_time_names`` smuggled into the
|
||||
``SOURCE_USER`` field via ``CONCAT``. Capturing those bytes
|
||||
requires a MySQL handshake responder on the worker — out of scope
|
||||
for v1; the DNS lookup alone is sufficient for detection.
|
||||
|
||||
The base64 wrapper is the camouflage: a plain ``grep canary dump.sql``
|
||||
finds nothing. The slug only materialises when the victim's server
|
||||
runs ``PREPARE … FROM @s2``.
|
||||
|
||||
Because the trip surface is DNS, this generator REQUIRES a non-empty
|
||||
``dns_zone``. The slug must appear as the leftmost label of the
|
||||
hostname so a single DNS query identifies the token; the http_base
|
||||
host is not slug-bearing and can't substitute.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
def _stable_hex(seed: str, prefix: str = "", length: int = 16) -> str:
|
||||
h = hashlib.sha256((prefix + seed).encode()).hexdigest()
|
||||
return h[:length]
|
||||
|
||||
|
||||
def _build_replica_payload(slug: str, dns_zone: str) -> str:
|
||||
"""Inner SQL that gets base64-wrapped.
|
||||
|
||||
The CONCAT splices ``@@lc_time_names`` and ``@@hostname`` into the
|
||||
``SOURCE_USER`` value at PREPARE time so the victim's locale and
|
||||
hostname travel as the replica username on the 3306 handshake.
|
||||
"""
|
||||
host = f"{slug}.{dns_zone}"
|
||||
return (
|
||||
"SET @bb = CONCAT("
|
||||
"\"CHANGE REPLICATION SOURCE TO "
|
||||
"SOURCE_PASSWORD='replica-pw', "
|
||||
"SOURCE_RETRY_COUNT=1, "
|
||||
"SOURCE_PORT=3306, "
|
||||
f"SOURCE_HOST='{host}', "
|
||||
"SOURCE_SSL=0, "
|
||||
f"SOURCE_USER='{slug}\", "
|
||||
"@@lc_time_names, @@hostname, \"';\");"
|
||||
)
|
||||
|
||||
|
||||
def _build_trailer(slug: str, dns_zone: str) -> str:
|
||||
inner = _build_replica_payload(slug, dns_zone)
|
||||
encoded = base64.b64encode(inner.encode("utf-8")).decode("ascii")
|
||||
return (
|
||||
f"SET @b = '{encoded}';\n"
|
||||
"SET @s2 = FROM_BASE64(@b);\n"
|
||||
"PREPARE stmt1 FROM @s2;\n"
|
||||
"EXECUTE stmt1;\n"
|
||||
"PREPARE stmt2 FROM @bb;\n"
|
||||
"EXECUTE stmt2;\n"
|
||||
"START REPLICA;\n"
|
||||
)
|
||||
|
||||
|
||||
class MySQLDumpGenerator(CanaryGenerator):
|
||||
name = "mysql_dump"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
if not ctx.dns_zone:
|
||||
raise ValueError(
|
||||
"mysql_dump requires a non-empty dns_zone — the trip "
|
||||
"surface is a DNS lookup of <slug>.<dns_zone>."
|
||||
)
|
||||
slug = ctx.callback_token
|
||||
zone = ctx.dns_zone
|
||||
host = f"{slug}.{zone}"
|
||||
|
||||
# Realism filler: deterministic per-slug fake user rows so two
|
||||
# runs with the same context produce byte-identical output
|
||||
# (planter idempotency contract).
|
||||
u1_hash = _stable_hex(slug, "u1:", 32)
|
||||
u2_hash = _stable_hex(slug, "u2:", 32)
|
||||
api_token = _stable_hex(slug, "api:", 40)
|
||||
|
||||
# Synthesised SQL bait below — never executed by us, only by
|
||||
# whoever runs ``mysql < dump.sql`` against their own server.
|
||||
# Built with .format() instead of f-strings so bandit's B608
|
||||
# heuristic doesn't false-positive on the "INSERT INTO" + var
|
||||
# pattern.
|
||||
users_insert = (
|
||||
"INSERT INTO `users` VALUES " # nosec B608
|
||||
"(1,'alice@app.internal','$2y$10${u1a}.{u1b}','2024-11-12 09:13:44'),"
|
||||
"(2,'bob@app.internal','$2y$10${u2a}.{u2b}','2025-02-03 17:42:08');\n"
|
||||
).replace("{u1a}", u1_hash[:22]).replace("{u1b}", u1_hash[22:]) \
|
||||
.replace("{u2a}", u2_hash[:22]).replace("{u2b}", u2_hash[22:])
|
||||
api_keys_insert = (
|
||||
"INSERT INTO `api_keys` VALUES (1,1,'{tok}');\n" # nosec B608
|
||||
).replace("{tok}", api_token)
|
||||
header = (
|
||||
"-- MySQL dump 10.13 Distrib 8.0.35, for Linux (x86_64)\n"
|
||||
"--\n"
|
||||
"-- Host: db-prod-01 Database: app_production\n"
|
||||
"-- ------------------------------------------------------\n"
|
||||
"-- Server version\t8.0.35\n"
|
||||
"\n"
|
||||
"/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n"
|
||||
"/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n"
|
||||
"/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n"
|
||||
"/*!50503 SET NAMES utf8mb4 */;\n"
|
||||
"/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n"
|
||||
"/*!40103 SET TIME_ZONE='+00:00' */;\n"
|
||||
"/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n"
|
||||
"/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;\n"
|
||||
"/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n"
|
||||
"/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;\n"
|
||||
"\n"
|
||||
"--\n"
|
||||
"-- Table structure for table `users`\n"
|
||||
"--\n"
|
||||
"\n"
|
||||
"DROP TABLE IF EXISTS `users`;\n"
|
||||
"CREATE TABLE `users` (\n"
|
||||
" `id` int unsigned NOT NULL AUTO_INCREMENT,\n"
|
||||
" `email` varchar(255) NOT NULL,\n"
|
||||
" `password_hash` char(60) NOT NULL,\n"
|
||||
" `created_at` datetime NOT NULL,\n"
|
||||
" PRIMARY KEY (`id`),\n"
|
||||
" UNIQUE KEY `uniq_email` (`email`)\n"
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
|
||||
"\n"
|
||||
"LOCK TABLES `users` WRITE;\n"
|
||||
+ users_insert +
|
||||
"UNLOCK TABLES;\n"
|
||||
"\n"
|
||||
"--\n"
|
||||
"-- Table structure for table `api_keys`\n"
|
||||
"--\n"
|
||||
"\n"
|
||||
"DROP TABLE IF EXISTS `api_keys`;\n"
|
||||
"CREATE TABLE `api_keys` (\n"
|
||||
" `id` int unsigned NOT NULL AUTO_INCREMENT,\n"
|
||||
" `user_id` int unsigned NOT NULL,\n"
|
||||
" `token` char(40) NOT NULL,\n"
|
||||
" PRIMARY KEY (`id`)\n"
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
|
||||
"\n"
|
||||
"LOCK TABLES `api_keys` WRITE;\n"
|
||||
+ api_keys_insert +
|
||||
"UNLOCK TABLES;\n"
|
||||
"\n"
|
||||
)
|
||||
|
||||
trailer_replica = _build_trailer(slug, zone)
|
||||
|
||||
trailer_close = (
|
||||
"\n"
|
||||
"/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n"
|
||||
"/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n"
|
||||
"/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;\n"
|
||||
"/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;\n"
|
||||
"/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n"
|
||||
"/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n"
|
||||
"/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n"
|
||||
"/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;\n"
|
||||
"\n"
|
||||
"-- Dump completed\n"
|
||||
)
|
||||
|
||||
body = header + trailer_replica + trailer_close
|
||||
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=body.encode("utf-8"),
|
||||
mode=0o600,
|
||||
mtime_offset=-86400 * 7, # last week's backup
|
||||
generator=self.name,
|
||||
notes=[
|
||||
f"replica payload phones home to {host}:3306 on import",
|
||||
"base64-wrapped PREPARE/EXECUTE block hides the slug from grep",
|
||||
"@@hostname and @@lc_time_names smuggled into SOURCE_USER",
|
||||
],
|
||||
)
|
||||
68
decnet/canary/generators/ssh_key.py
Normal file
68
decnet/canary/generators/ssh_key.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Fake SSH private key with the callback host in the comment.
|
||||
|
||||
OpenSSH private keys carry a free-form comment field — typically
|
||||
``user@host`` — that's preserved across rounds of ``ssh-keygen -p``.
|
||||
We embed the canary host as the ``user@host`` so an attacker who
|
||||
imports the key into their own keyring or runs ``ssh-keygen -lf`` on
|
||||
it sees a hostname they may then try to reach.
|
||||
|
||||
The key bytes themselves are syntactically valid (PEM envelope, base64
|
||||
body) but cryptographically junk — the body is a deterministic SHA-256
|
||||
hash of the slug repeated to the right length. We don't ship a real
|
||||
RSA/Ed25519 key because (a) we don't want a real private key sitting
|
||||
on disk pretending to be valuable, and (b) the attacker ``cat``-ing
|
||||
the file or running ``ssh -i`` will trigger the callback regardless
|
||||
of cryptographic validity.
|
||||
|
||||
The DNS-callback variant uses ``<slug>.canary.<dns_zone>`` as the
|
||||
hostname so a bare ``ssh-keygen -lf`` on the file resolves a unique
|
||||
subdomain even if the attacker never hits HTTP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
||||
|
||||
|
||||
def _fake_key_body(seed: str) -> str:
|
||||
# Real OpenSSH keys are several hundred base64 chars; we make a
|
||||
# plausible-looking 24-line block from a SHA-256-derived stream.
|
||||
h = hashlib.sha256(seed.encode()).digest()
|
||||
long_stream = (h * 32)[:768] # 768 bytes → ~1024 base64 chars
|
||||
encoded = base64.b64encode(long_stream).decode()
|
||||
# Wrap at 70 chars per line — same shape ``ssh-keygen`` produces.
|
||||
return "\n".join(encoded[i:i + 70] for i in range(0, len(encoded), 70))
|
||||
|
||||
|
||||
class SSHKeyGenerator(CanaryGenerator):
|
||||
name = "ssh_key"
|
||||
|
||||
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
||||
slug = ctx.callback_token
|
||||
body = _fake_key_body(slug)
|
||||
# Hostname for the comment: prefer DNS-zone form when the
|
||||
# operator has DNS deployed (so ssh-keygen -lf names a subdomain
|
||||
# the attacker may resolve); fall back to the http_base host
|
||||
# otherwise.
|
||||
if ctx.dns_zone:
|
||||
host_comment = f"deploy@{slug}.{ctx.dns_zone}"
|
||||
else:
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(ctx.http_base).hostname or "deploy.local"
|
||||
host_comment = f"deploy@{host}"
|
||||
content = (
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n"
|
||||
f"{body}\n"
|
||||
"-----END OPENSSH PRIVATE KEY-----\n"
|
||||
f"# {host_comment}\n"
|
||||
)
|
||||
return CanaryArtifact(
|
||||
path="",
|
||||
content=content.encode("utf-8"),
|
||||
mode=0o600,
|
||||
mtime_offset=-86400 * 60, # 2 months ago
|
||||
generator=self.name,
|
||||
notes=[f"comment line embeds {host_comment}"],
|
||||
)
|
||||
4
decnet/canary/instrumenters/__init__.py
Normal file
4
decnet/canary/instrumenters/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Built-in canary instrumenters (operator-uploaded artifact mutation).
|
||||
|
||||
Lazy-imported by :func:`decnet.canary.factory.get_instrumenter`.
|
||||
"""
|
||||
147
decnet/canary/instrumenters/docx.py
Normal file
147
decnet/canary/instrumenters/docx.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""DOCX instrumenter — inject a remote image into the body.
|
||||
|
||||
DOCX files are zip archives carrying ``word/document.xml`` (the body)
|
||||
and ``word/_rels/document.xml.rels`` (the relationship table that
|
||||
maps ``rId`` references to URLs). We:
|
||||
|
||||
1. Add a new relationship of type ``image`` whose target is the
|
||||
canary callback URL and ``TargetMode="External"``.
|
||||
2. Add a tiny ``<w:drawing>`` element referencing that ``rId`` at
|
||||
the end of ``word/document.xml`` (just before ``</w:body>``).
|
||||
|
||||
Word and LibreOffice both fetch external image relationships when
|
||||
the document is opened (subject to the user's "trusted source"
|
||||
toggle, which most enterprise environments disable in favour of
|
||||
"warn but allow").
|
||||
|
||||
We use stdlib ``zipfile`` only — no python-docx dependency — because
|
||||
the surface we touch is two small XML files and we don't need any of
|
||||
the higher-level abstractions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from typing import Tuple
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryInstrumenter,
|
||||
InstrumenterRejectedError,
|
||||
)
|
||||
|
||||
|
||||
_RELS_END = re.compile(rb"</Relationships\s*>", re.IGNORECASE)
|
||||
_BODY_END = re.compile(rb"</w:body\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _next_rid(rels_xml: bytes) -> str:
|
||||
"""Return an rId not already taken in the relationships file.
|
||||
|
||||
Word's loader tolerates non-sequential ids, so we just pick one
|
||||
well above the typical range to avoid collisions.
|
||||
"""
|
||||
used = set(m.group(1).decode() for m in re.finditer(rb'Id="(rId\d+)"', rels_xml))
|
||||
for n in range(900, 9999):
|
||||
rid = f"rId{n}"
|
||||
if rid not in used:
|
||||
return rid
|
||||
raise InstrumenterRejectedError("DOCX has too many relationships to allocate a new rId")
|
||||
|
||||
|
||||
def _inject_relationship(rels_xml: bytes, rid: str, url: str) -> bytes:
|
||||
rel = (
|
||||
f'<Relationship Id="{rid}" '
|
||||
f'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
f'Target="{url}" TargetMode="External"/>'
|
||||
).encode()
|
||||
match = _RELS_END.search(rels_xml)
|
||||
if not match:
|
||||
raise InstrumenterRejectedError(
|
||||
"DOCX rels file has no </Relationships>; refusing to mutate"
|
||||
)
|
||||
return rels_xml[:match.start()] + rel + rels_xml[match.start():]
|
||||
|
||||
|
||||
def _drawing(rid: str) -> bytes:
|
||||
# Minimal w:drawing tree referencing the external image at rid.
|
||||
# Dimensions are 1 EMU x 1 EMU so the image is invisible; Word
|
||||
# still fetches the resource on document load.
|
||||
return (
|
||||
'<w:p><w:r><w:drawing>'
|
||||
'<wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">'
|
||||
'<wp:extent cx="1" cy="1"/><wp:docPr id="1" name="canary"/>'
|
||||
'<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||
'<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
|
||||
'<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
|
||||
'<pic:nvPicPr><pic:cNvPr id="1" name="canary"/><pic:cNvPicPr/></pic:nvPicPr>'
|
||||
'<pic:blipFill>'
|
||||
f'<a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:link="{rid}"/>'
|
||||
'<a:stretch><a:fillRect/></a:stretch>'
|
||||
'</pic:blipFill>'
|
||||
'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1" cy="1"/></a:xfrm>'
|
||||
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
|
||||
'</pic:pic></a:graphicData></a:graphic></wp:inline>'
|
||||
'</w:drawing></w:r></w:p>'
|
||||
).encode()
|
||||
|
||||
|
||||
def _inject_drawing(document_xml: bytes, rid: str) -> bytes:
|
||||
match = _BODY_END.search(document_xml)
|
||||
if not match:
|
||||
raise InstrumenterRejectedError("DOCX document.xml has no </w:body>")
|
||||
drawing = _drawing(rid)
|
||||
return document_xml[:match.start()] + drawing + document_xml[match.start():]
|
||||
|
||||
|
||||
def _mutate(blob: bytes, url: str) -> Tuple[bytes, str]:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
|
||||
try:
|
||||
rels = zf.read("word/_rels/document.xml.rels")
|
||||
doc = zf.read("word/document.xml")
|
||||
except KeyError as e:
|
||||
raise InstrumenterRejectedError(
|
||||
f"DOCX missing expected member: {e.args[0]!r}"
|
||||
) from e
|
||||
members = [(zi, zf.read(zi.filename)) for zi in zf.infolist()]
|
||||
except zipfile.BadZipFile as e:
|
||||
raise InstrumenterRejectedError("uploaded blob is not a valid DOCX zip") from e
|
||||
|
||||
rid = _next_rid(rels)
|
||||
new_rels = _inject_relationship(rels, rid, url)
|
||||
new_doc = _inject_drawing(doc, rid)
|
||||
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for zi, data in members:
|
||||
if zi.filename == "word/_rels/document.xml.rels":
|
||||
zf_out.writestr(zi.filename, new_rels)
|
||||
elif zi.filename == "word/document.xml":
|
||||
zf_out.writestr(zi.filename, new_doc)
|
||||
else:
|
||||
zf_out.writestr(zi, data)
|
||||
return out.getvalue(), rid
|
||||
|
||||
|
||||
class DocxInstrumenter(CanaryInstrumenter):
|
||||
name = "docx"
|
||||
mime_prefixes = (
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
mutated, rid = _mutate(blob, url)
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=mutated,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 14,
|
||||
instrumenter=self.name,
|
||||
notes=[f"injected external-image relationship {rid} -> {url}"],
|
||||
)
|
||||
45
decnet/canary/instrumenters/html.py
Normal file
45
decnet/canary/instrumenters/html.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""HTML instrumenter — append a 1×1 tracking pixel.
|
||||
|
||||
Stdlib-only. We don't parse the HTML; we just inject the ``<img>``
|
||||
tag immediately before the closing ``</body>`` (or, failing that, at
|
||||
the end of the document). Most renderers that support remote images
|
||||
(email previewers, IDE doc previews, browsers) will fetch it as
|
||||
soon as the document is opened.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryInstrumenter
|
||||
|
||||
|
||||
_BODY_CLOSE = re.compile(rb"</body\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
class HtmlInstrumenter(CanaryInstrumenter):
|
||||
name = "html"
|
||||
mime_prefixes = ("text/html", "application/xhtml+xml")
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}".encode()
|
||||
pixel = (
|
||||
b"<img src=\"" + url + b"\" width=\"1\" height=\"1\" "
|
||||
b"alt=\"\" style=\"display:none\">\n"
|
||||
)
|
||||
match = _BODY_CLOSE.search(blob)
|
||||
if match:
|
||||
out = blob[:match.start()] + pixel + blob[match.start():]
|
||||
note = "injected 1x1 pixel before </body>"
|
||||
else:
|
||||
out = (blob if blob.endswith(b"\n") else blob + b"\n") + pixel
|
||||
note = "appended 1x1 pixel (no </body> found)"
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=out,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 7,
|
||||
instrumenter=self.name,
|
||||
notes=[note, f"pixel src={url.decode()}"],
|
||||
)
|
||||
72
decnet/canary/instrumenters/image.py
Normal file
72
decnet/canary/instrumenters/image.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Image instrumenter — requires :mod:`PIL` (optional dependency).
|
||||
|
||||
For PNG/JPEG/GIF we append a tEXt/EXIF chunk carrying the slug so
|
||||
``exiftool`` / ``identify -verbose`` surface the slug, then route the
|
||||
detection via a sibling **plain-text companion file**. The image
|
||||
itself can't really embed an HTTP fetcher — image decoders don't
|
||||
run network requests on decode — so the realistic detection surface
|
||||
is "attacker exfils the image, runs metadata tools on it, hits our
|
||||
URL when curious about the embedded marker."
|
||||
|
||||
When Pillow isn't installed we reject and direct the operator to
|
||||
``passthrough`` (which preserves the bytes; the slug then lives in
|
||||
the filename only).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryInstrumenter,
|
||||
InstrumenterRejectedError,
|
||||
)
|
||||
|
||||
|
||||
class ImageInstrumenter(CanaryInstrumenter):
|
||||
name = "image"
|
||||
mime_prefixes = ("image/png", "image/jpeg", "image/gif")
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
try:
|
||||
from PIL import Image, PngImagePlugin # type: ignore[import-not-found]
|
||||
except ImportError as e:
|
||||
raise InstrumenterRejectedError(
|
||||
"image instrumenter requires Pillow; install it (`pip "
|
||||
"install Pillow`) or re-upload the artifact with "
|
||||
"kind=passthrough so it ships unmodified."
|
||||
) from e
|
||||
|
||||
slug_url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
try:
|
||||
buf_in = io.BytesIO(blob)
|
||||
img = Image.open(buf_in)
|
||||
fmt = (img.format or "").upper()
|
||||
buf_out = io.BytesIO()
|
||||
if fmt == "PNG":
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
meta.add_text("Comment", f"reference: {slug_url}")
|
||||
meta.add_text("X-Canary", ctx.callback_token)
|
||||
img.save(buf_out, format="PNG", pnginfo=meta)
|
||||
elif fmt in ("JPEG", "JPG"):
|
||||
# Pillow encodes JPEG comments via the ``comment`` kwarg.
|
||||
img.save(buf_out, format="JPEG", comment=slug_url.encode())
|
||||
else:
|
||||
# GIF and friends — Pillow doesn't expose comment metadata
|
||||
# uniformly. Re-encode as-is and skip the metadata embed.
|
||||
img.save(buf_out, format=fmt or "PNG")
|
||||
mutated = buf_out.getvalue()
|
||||
except Exception as e:
|
||||
raise InstrumenterRejectedError(f"failed to instrument image: {e!s}") from e
|
||||
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=mutated,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 30,
|
||||
instrumenter=self.name,
|
||||
notes=[f"image metadata carries {slug_url} (slug={ctx.callback_token})"],
|
||||
)
|
||||
37
decnet/canary/instrumenters/passthrough.py
Normal file
37
decnet/canary/instrumenters/passthrough.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Passthrough instrumenter — bytes go to disk unchanged.
|
||||
|
||||
Used as the dispatch fallback for content types we can't safely
|
||||
mutate (random binary blobs, container images, archives we don't
|
||||
recognise). In passthrough mode the only callback surface is the
|
||||
:attr:`CanaryToken.placement_path` itself: the operator must use a
|
||||
DNS-callback token whose slug appears in the filename, so a
|
||||
listing/access at the OS level resolves the slug as part of the
|
||||
path (e.g. ``/etc/<slug>.canary.example.test/secrets.bin``) when
|
||||
the attacker greps for hostnames in their loot.
|
||||
|
||||
The instrumenter does not enforce that — the API does, when it sees
|
||||
``instrumenter=passthrough`` with ``kind=http`` it returns 400.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryInstrumenter
|
||||
|
||||
|
||||
class PassthroughInstrumenter(CanaryInstrumenter):
|
||||
name = "passthrough"
|
||||
mime_prefixes = () # dispatched by fallback in pick_instrumenter_for_mime
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=blob,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 7,
|
||||
instrumenter=self.name,
|
||||
notes=[
|
||||
"passthrough: bytes unchanged — only DNS-callback tokens "
|
||||
"trip detection (slug must live in the placement path)",
|
||||
],
|
||||
)
|
||||
76
decnet/canary/instrumenters/pdf.py
Normal file
76
decnet/canary/instrumenters/pdf.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""PDF instrumenter — requires :mod:`pikepdf` (optional dependency).
|
||||
|
||||
PDF embedding is non-trivial: the cleanest place to put a callback
|
||||
is an ``/AA`` (additional actions) ``/O`` (open) entry on the
|
||||
catalog or a ``/URI`` action on a link annotation. Either path
|
||||
needs proper xref-table updates — pikepdf handles that for us.
|
||||
|
||||
If pikepdf isn't available in the environment the instrumenter
|
||||
raises :class:`InstrumenterRejectedError` so the API can return a
|
||||
clear 400 directing the operator to either install pikepdf or
|
||||
re-upload as ``passthrough``.
|
||||
|
||||
We don't ship a stdlib fallback because every "naive" PDF mutation
|
||||
I'm aware of (appending raw bytes, splicing into the trailer, etc.)
|
||||
breaks the document's xref table and trips a "file is corrupt"
|
||||
warning in modern viewers — which the attacker will absolutely
|
||||
notice.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryInstrumenter,
|
||||
InstrumenterRejectedError,
|
||||
)
|
||||
|
||||
|
||||
class PdfInstrumenter(CanaryInstrumenter):
|
||||
name = "pdf"
|
||||
mime_prefixes = ("application/pdf",)
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
try:
|
||||
import pikepdf # type: ignore[import-not-found]
|
||||
except ImportError as e:
|
||||
raise InstrumenterRejectedError(
|
||||
"PDF instrumenter requires pikepdf; install it (`pip "
|
||||
"install pikepdf`) or re-upload the artifact with "
|
||||
"kind=passthrough so it ships unmodified."
|
||||
) from e
|
||||
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
try:
|
||||
import io
|
||||
buf = io.BytesIO(blob)
|
||||
with pikepdf.open(buf) as pdf:
|
||||
# Add an OpenAction that fires a URI action on document
|
||||
# open. Most viewers prompt before fetching; that's
|
||||
# fine — even the prompt itself can trip a "user
|
||||
# interacted with the document" tell, and an
|
||||
# auto-allow viewer fetches the URL silently.
|
||||
action = pikepdf.Dictionary(
|
||||
Type=pikepdf.Name("/Action"),
|
||||
S=pikepdf.Name("/URI"),
|
||||
URI=pikepdf.String(url),
|
||||
)
|
||||
pdf.Root[pikepdf.Name("/OpenAction")] = action
|
||||
out = io.BytesIO()
|
||||
pdf.save(out)
|
||||
mutated = out.getvalue()
|
||||
except Exception as e:
|
||||
raise InstrumenterRejectedError(
|
||||
f"failed to instrument PDF: {e!s}"
|
||||
) from e
|
||||
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=mutated,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 14,
|
||||
instrumenter=self.name,
|
||||
notes=[f"installed /OpenAction /URI -> {url}"],
|
||||
)
|
||||
79
decnet/canary/instrumenters/plain.py
Normal file
79
decnet/canary/instrumenters/plain.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Plain-text / config-file instrumenter.
|
||||
|
||||
Two embedding strategies, picked in order:
|
||||
|
||||
1. **Token substitution.** If the blob contains the literal
|
||||
placeholder ``{{CANARY_URL}}`` or ``{{CANARY_HOST}}``, replace it.
|
||||
This gives operators full control over where the slug lands —
|
||||
they can pre-edit the file with placeholders before uploading.
|
||||
2. **Append.** Otherwise, append a comment line that mentions the
|
||||
callback URL. The comment style adapts to the file's apparent
|
||||
syntax (``#`` for shell/yaml/python/dockerfile, ``//`` for json5/
|
||||
javascript-ish, ``;`` for ini).
|
||||
|
||||
Operators who want neither behavior should upload the file as
|
||||
``passthrough``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryInstrumenter
|
||||
|
||||
|
||||
_SLASH_HINTS = (b"//", b"function ", b"const ", b"let ", b"var ")
|
||||
_SEMI_HINTS = (b"[default]", b"[section]", b"\n[")
|
||||
|
||||
|
||||
def _comment_prefix(blob: bytes) -> bytes:
|
||||
head = blob[:512]
|
||||
if any(h in head for h in _SEMI_HINTS):
|
||||
return b"; "
|
||||
if any(h in head for h in _SLASH_HINTS):
|
||||
return b"// "
|
||||
# Default to # — the most common comment glyph across config files
|
||||
# we'd plausibly canary.
|
||||
return b"# "
|
||||
|
||||
|
||||
class PlainInstrumenter(CanaryInstrumenter):
|
||||
name = "plain"
|
||||
mime_prefixes = ("text/", "application/json", "application/yaml", "application/toml")
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
base = ctx.http_base.rstrip("/")
|
||||
callback_url = f"{base}/c/{ctx.callback_token}".encode()
|
||||
callback_host = (
|
||||
f"{ctx.callback_token}.{ctx.dns_zone}".encode()
|
||||
if ctx.dns_zone else b""
|
||||
)
|
||||
notes: list[str] = []
|
||||
out = blob
|
||||
|
||||
if b"{{CANARY_URL}}" in blob:
|
||||
out = out.replace(b"{{CANARY_URL}}", callback_url)
|
||||
notes.append(f"substituted {{{{CANARY_URL}}}} -> {callback_url.decode()}")
|
||||
if b"{{CANARY_HOST}}" in blob and callback_host:
|
||||
out = out.replace(b"{{CANARY_HOST}}", callback_host)
|
||||
notes.append(f"substituted {{{{CANARY_HOST}}}} -> {callback_host.decode()}")
|
||||
|
||||
if not notes:
|
||||
# No placeholders — append a comment line at the end.
|
||||
prefix = _comment_prefix(blob)
|
||||
tail = (
|
||||
b"\n" + prefix + b"see " + callback_url
|
||||
+ b" for the latest version\n"
|
||||
)
|
||||
out = (out if out.endswith(b"\n") else out + b"\n") + tail
|
||||
notes.append(
|
||||
f"appended comment line carrying {callback_url.decode()}"
|
||||
)
|
||||
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=out,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 7,
|
||||
instrumenter=self.name,
|
||||
notes=notes,
|
||||
)
|
||||
95
decnet/canary/instrumenters/xlsx.py
Normal file
95
decnet/canary/instrumenters/xlsx.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""XLSX instrumenter — embed an external-image link.
|
||||
|
||||
XLSX is structurally identical to DOCX (Office Open XML zip). The
|
||||
injection target is the workbook's relationships file
|
||||
(``xl/_rels/workbook.xml.rels``). We add an external image
|
||||
relationship there; Excel/LibreOffice fetch external images on
|
||||
workbook open in the same way Word does.
|
||||
|
||||
We don't inject a ``<drawing>`` element into a sheet because that
|
||||
requires touching ``xl/worksheets/sheetN.xml`` *and* allocating a new
|
||||
``xl/drawings/drawingN.xml`` part — much higher chance of mangling
|
||||
the file. An orphan external image relationship is enough: many
|
||||
Office viewers fetch all relationships at open time regardless of
|
||||
whether they're referenced from a sheet.
|
||||
|
||||
If the operator wants a stronger trigger (image visible in the
|
||||
sheet, fetched even by viewers that lazy-load external resources)
|
||||
they should embed the slug as a hyperlink cell content via the
|
||||
``plain``/``passthrough`` instrumenters.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from typing import Tuple
|
||||
|
||||
from decnet.canary.base import (
|
||||
CanaryArtifact,
|
||||
CanaryContext,
|
||||
CanaryInstrumenter,
|
||||
InstrumenterRejectedError,
|
||||
)
|
||||
from decnet.canary.instrumenters.docx import _inject_relationship, _next_rid
|
||||
|
||||
|
||||
_RELS_PATHS = (
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
"xl/_rels/sharedStrings.xml.rels",
|
||||
)
|
||||
|
||||
|
||||
def _mutate(blob: bytes, url: str) -> Tuple[bytes, str, str]:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
|
||||
members = [(zi, zf.read(zi.filename)) for zi in zf.infolist()]
|
||||
except zipfile.BadZipFile as e:
|
||||
raise InstrumenterRejectedError("uploaded blob is not a valid XLSX zip") from e
|
||||
|
||||
target_rels: str | None = None
|
||||
for zi, _ in members:
|
||||
if zi.filename in _RELS_PATHS:
|
||||
target_rels = zi.filename
|
||||
break
|
||||
if not target_rels:
|
||||
raise InstrumenterRejectedError(
|
||||
"XLSX has no workbook relationships file to mutate"
|
||||
)
|
||||
|
||||
out_members = []
|
||||
rid = ""
|
||||
for zi, data in members:
|
||||
if zi.filename == target_rels:
|
||||
rid = _next_rid(data)
|
||||
data = _inject_relationship(data, rid, url)
|
||||
out_members.append((zi, data))
|
||||
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for zi, data in out_members:
|
||||
zf_out.writestr(zi, data)
|
||||
return out.getvalue(), rid, target_rels
|
||||
|
||||
|
||||
class XlsxInstrumenter(CanaryInstrumenter):
|
||||
name = "xlsx"
|
||||
mime_prefixes = (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
def instrument(
|
||||
self, blob: bytes, ctx: CanaryContext, *, target_path: str,
|
||||
) -> CanaryArtifact:
|
||||
url = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}"
|
||||
mutated, rid, target_rels = _mutate(blob, url)
|
||||
return CanaryArtifact(
|
||||
path=target_path,
|
||||
content=mutated,
|
||||
mode=0o644,
|
||||
mtime_offset=-86400 * 14,
|
||||
instrumenter=self.name,
|
||||
notes=[
|
||||
f"injected external-image relationship {rid} into "
|
||||
f"{target_rels} -> {url}",
|
||||
],
|
||||
)
|
||||
82
decnet/canary/paths.py
Normal file
82
decnet/canary/paths.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Persona-aware path resolution for canary artifacts.
|
||||
|
||||
Linux-persona deckies use POSIX-shaped paths under ``/home/<user>``.
|
||||
"Windows" personas (still Linux containers under the hood — see
|
||||
:mod:`decnet.archetypes`) use Windows-shaped paths under
|
||||
``/home/<user>/AppData/...`` so an attacker browsing the filesystem
|
||||
through a planted RDP/SMB session sees the right shape.
|
||||
|
||||
The persona lookup is best-effort: callers pass the
|
||||
:attr:`decnet.archetypes.Archetype.nmap_os` value (``"linux"`` or
|
||||
``"windows"``); unknown personas fall through to ``"linux"``.
|
||||
Operators can always override by passing an explicit
|
||||
``placement_path`` when creating a token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_LINUX_USER = "admin"
|
||||
DEFAULT_WINDOWS_USER = "Administrator"
|
||||
|
||||
# Canonical placements for the synthesizer-driven baseline tokens.
|
||||
# Operators can override per-token via the API, but these are the
|
||||
# defaults the deploy-time seed uses.
|
||||
_LINUX_DEFAULTS: dict[str, str] = {
|
||||
"git_config": "/home/{user}/.git/config",
|
||||
"env_file": "/home/{user}/.env",
|
||||
"ssh_key": "/home/{user}/.ssh/id_rsa",
|
||||
"aws_creds": "/home/{user}/.aws/credentials",
|
||||
"honeydoc": "/home/{user}/Documents/quarterly_report.html",
|
||||
"honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx",
|
||||
"honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf",
|
||||
}
|
||||
|
||||
_WINDOWS_DEFAULTS: dict[str, str] = {
|
||||
"git_config": "/home/{user}/AppData/Local/Programs/Git/etc/gitconfig",
|
||||
"env_file": "/home/{user}/Desktop/prod.env",
|
||||
"ssh_key": "/home/{user}/.ssh/id_rsa", # OpenSSH on Windows uses the same path
|
||||
"aws_creds": "/home/{user}/.aws/credentials",
|
||||
"honeydoc": "/home/{user}/Documents/quarterly_report.html",
|
||||
"honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx",
|
||||
"honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf",
|
||||
}
|
||||
|
||||
|
||||
def default_user(persona: str) -> str:
|
||||
"""Return the conventional unprivileged username for a persona."""
|
||||
return DEFAULT_WINDOWS_USER if persona == "windows" else DEFAULT_LINUX_USER
|
||||
|
||||
|
||||
def default_path_for(generator: str, persona: str = "linux") -> str:
|
||||
"""Resolve the default placement path for a synthesized token.
|
||||
|
||||
Returns an absolute container path with ``{user}`` already
|
||||
expanded. Falls back to a sane Linux default for unknown
|
||||
personas — better to plant *something* than fail the deploy hook.
|
||||
"""
|
||||
table = _WINDOWS_DEFAULTS if persona == "windows" else _LINUX_DEFAULTS
|
||||
template = table.get(generator)
|
||||
if not template:
|
||||
# Unknown generator — fall back to a generic /tmp drop so the
|
||||
# planter still has somewhere to write. The API rejects
|
||||
# unknown generators upstream, so this branch is defensive.
|
||||
return f"/tmp/{generator}.canary" # nosec B108 — placement inside attacker-facing decoy container, not host /tmp
|
||||
return template.format(user=default_user(persona))
|
||||
|
||||
|
||||
def normalize_placement(path: str) -> str:
|
||||
"""Validate and normalize an operator-supplied placement path.
|
||||
|
||||
Forbids relative paths, NUL bytes, and shell metacharacters that
|
||||
``docker exec sh -c`` can't safely round-trip. Returns the
|
||||
sanitised path unchanged when valid; raises :class:`ValueError`
|
||||
otherwise so the API can return a 400 with a clear message.
|
||||
"""
|
||||
if not path or not path.startswith("/"):
|
||||
raise ValueError("placement_path must be absolute (start with '/')")
|
||||
if "\x00" in path:
|
||||
raise ValueError("placement_path may not contain NUL")
|
||||
if "\n" in path or "\r" in path:
|
||||
raise ValueError("placement_path may not contain newlines")
|
||||
if "../" in path or path.endswith("/.."):
|
||||
raise ValueError("placement_path may not contain '..' segments")
|
||||
return path
|
||||
301
decnet/canary/planter.py
Normal file
301
decnet/canary/planter.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Plant / revoke canary artifacts inside running decky containers.
|
||||
|
||||
Single entry point per operation:
|
||||
|
||||
* :func:`plant` writes a :class:`CanaryArtifact` into one decky's
|
||||
filesystem via ``docker exec`` (mirroring the SSH driver's
|
||||
``_run_file`` pattern), backdates the mtime, sets the requested
|
||||
mode, and publishes ``canary.{token_id}.placed`` on the bus.
|
||||
* :func:`revoke` unlinks the file (best-effort) and publishes
|
||||
``canary.{token_id}.revoked``.
|
||||
* :func:`seed_baseline` is the deploy-hook helper: synthesises the
|
||||
configured baseline set for one decky, persists rows, plants each.
|
||||
Failures are logged but do **not** abort the deploy (the deployer
|
||||
hook calls this best-effort).
|
||||
|
||||
We don't reuse :class:`SSHDriver` directly because the orchestrator
|
||||
driver is tied to its action types (``FileAction`` carries str
|
||||
content; canary content is bytes). The planter takes the same
|
||||
shape but speaks bytes-via-base64 over the wire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import shlex
|
||||
import time
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.base import BaseBus
|
||||
from decnet.bus.factory import get_bus
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext
|
||||
from decnet.canary.factory import get_generator
|
||||
from decnet.canary.paths import default_path_for
|
||||
from decnet.logging import get_logger
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
log = get_logger("canary.planter")
|
||||
|
||||
_DOCKER = "docker"
|
||||
_TIMEOUT = 8.0
|
||||
# Container suffix — matches the orchestrator SSH driver's convention
|
||||
# (``<decky_name>-ssh``). Canary placement always happens through the
|
||||
# ssh container because every decky has one and it carries the most
|
||||
# realistic filesystem layout.
|
||||
_SSH_CONTAINER_SUFFIX = "-ssh"
|
||||
|
||||
|
||||
def _container_for(decky_name: str) -> str:
|
||||
return f"{decky_name}{_SSH_CONTAINER_SUFFIX}"
|
||||
|
||||
|
||||
def _dirname(path: str) -> str:
|
||||
idx = path.rfind("/")
|
||||
if idx <= 0:
|
||||
return "/"
|
||||
return path[:idx]
|
||||
|
||||
|
||||
async def _run(
|
||||
argv: list[str], *, stdin_bytes: Optional[bytes] = None,
|
||||
) -> tuple[int, str, str]:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_bytes is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return 127, "", f"argv[0] not found: {exc}"
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input=stdin_bytes), timeout=_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return 124, "", "timeout"
|
||||
return (
|
||||
proc.returncode if proc.returncode is not None else -1,
|
||||
stdout.decode("utf-8", "replace"),
|
||||
stderr.decode("utf-8", "replace"),
|
||||
)
|
||||
|
||||
|
||||
def _build_plant_command(artifact: CanaryArtifact) -> tuple[str, bytes]:
|
||||
"""Compose the ``sh -c`` script + stdin payload for one artifact.
|
||||
|
||||
Binary safety: we base64-encode on the host and stream the result
|
||||
over stdin to ``base64 -d`` inside the container, so the bytes
|
||||
never touch the argv (kernel ARG_MAX would reject anything larger
|
||||
than ~128KB-2MB depending on the host). Both ``base64`` (coreutils)
|
||||
and ``touch -d @<unix_ts>`` are present on every Linux base image
|
||||
we ship, so there's no per-distro branching.
|
||||
"""
|
||||
encoded = base64.b64encode(artifact.content)
|
||||
mtime = int(time.time() + artifact.mtime_offset)
|
||||
mode_str = oct(artifact.mode)[2:]
|
||||
parts = [
|
||||
f"mkdir -p {shlex.quote(_dirname(artifact.path))}",
|
||||
f"base64 -d > {shlex.quote(artifact.path)}",
|
||||
f"chmod {mode_str} {shlex.quote(artifact.path)}",
|
||||
f"touch -d @{mtime} {shlex.quote(artifact.path)}",
|
||||
]
|
||||
return " && ".join(parts), encoded
|
||||
|
||||
|
||||
async def _publish(
|
||||
bus: Optional[BaseBus], topic: str, payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Best-effort publish — never raises.
|
||||
|
||||
When ``bus`` is None we resolve via :func:`get_bus`; either way
|
||||
bus-side failures are logged and swallowed (delivery is at-most-once
|
||||
by contract; the DB row is source of truth).
|
||||
"""
|
||||
try:
|
||||
owns_bus = bus is None
|
||||
target = bus if bus is not None else get_bus()
|
||||
if owns_bus:
|
||||
await target.connect()
|
||||
await target.publish(topic, payload)
|
||||
if owns_bus:
|
||||
await target.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("canary bus publish failed topic=%s err=%s", topic, e)
|
||||
|
||||
|
||||
async def plant(
|
||||
decky_name: str,
|
||||
artifact: CanaryArtifact,
|
||||
*,
|
||||
token_uuid: str,
|
||||
repo: Optional[BaseRepository] = None,
|
||||
publish: bool = True,
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Write *artifact* into the decky's ssh container.
|
||||
|
||||
Returns ``(success, error_or_none)``. When ``repo`` is provided
|
||||
the token row's state is updated to ``planted`` / ``failed``
|
||||
accordingly. When ``publish`` is True a ``canary.<id>.placed``
|
||||
event is published on the bus on success.
|
||||
|
||||
The function never raises on docker errors — callers (the API,
|
||||
the deploy hook) treat the result as data.
|
||||
"""
|
||||
if not artifact.path:
|
||||
err = "planter requires a non-empty artifact.path"
|
||||
log.warning("canary.plant skipped: %s decky=%s token=%s", err, decky_name, token_uuid)
|
||||
if repo is not None:
|
||||
await repo.update_canary_token_state(token_uuid, "failed", err)
|
||||
return False, err
|
||||
|
||||
sh_cmd, stdin_payload = _build_plant_command(artifact)
|
||||
# ``-i`` keeps stdin attached so base64 -d inside the container can
|
||||
# consume the encoded payload streamed from the host.
|
||||
argv = [_DOCKER, "exec", "-i", _container_for(decky_name), "sh", "-c", sh_cmd]
|
||||
rc, _stdout, stderr = await _run(argv, stdin_bytes=stdin_payload)
|
||||
success = rc == 0
|
||||
error = None if success else (stderr.strip()[:256] or f"rc={rc}")
|
||||
|
||||
if repo is not None:
|
||||
if success:
|
||||
await repo.update_canary_token_state(token_uuid, "planted", None)
|
||||
else:
|
||||
await repo.update_canary_token_state(token_uuid, "failed", error)
|
||||
|
||||
if success and publish:
|
||||
await _publish(bus, topics.canary(token_uuid, topics.CANARY_PLACED), {
|
||||
"token_id": token_uuid,
|
||||
"decky_name": decky_name,
|
||||
"placement_path": artifact.path,
|
||||
"instrumenter": artifact.instrumenter,
|
||||
"generator": artifact.generator,
|
||||
})
|
||||
|
||||
if not success:
|
||||
log.warning(
|
||||
"canary.plant failed decky=%s token=%s rc=%d stderr=%r",
|
||||
decky_name, token_uuid, rc, stderr[:120],
|
||||
)
|
||||
return success, error
|
||||
|
||||
|
||||
async def revoke(
|
||||
decky_name: str,
|
||||
placement_path: str,
|
||||
*,
|
||||
token_uuid: str,
|
||||
repo: Optional[BaseRepository] = None,
|
||||
publish: bool = True,
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Best-effort unlink + state transition + bus publish.
|
||||
|
||||
Returns ``(success, error_or_none)``. ``success`` is True when
|
||||
the file is gone after the call (whether we deleted it or it was
|
||||
already missing); only docker / container-down errors return False.
|
||||
"""
|
||||
sh_cmd = f"rm -f {shlex.quote(placement_path)}"
|
||||
argv = [_DOCKER, "exec", _container_for(decky_name), "sh", "-c", sh_cmd]
|
||||
rc, _stdout, stderr = await _run(argv)
|
||||
success = rc == 0
|
||||
error = None if success else (stderr.strip()[:256] or f"rc={rc}")
|
||||
|
||||
if repo is not None:
|
||||
await repo.update_canary_token_state(token_uuid, "revoked", error if not success else None)
|
||||
|
||||
if publish:
|
||||
await _publish(bus, topics.canary(token_uuid, topics.CANARY_REVOKED), {
|
||||
"token_id": token_uuid,
|
||||
"decky_name": decky_name,
|
||||
"placement_path": placement_path,
|
||||
})
|
||||
|
||||
return success, error
|
||||
|
||||
|
||||
def _baseline_set() -> Iterable[str]:
|
||||
"""Return the configured baseline generator names.
|
||||
|
||||
Honors ``DECNET_CANARY_BASELINE`` (comma-separated). Default is
|
||||
a sensible mix that exercises every callback-bearing generator
|
||||
plus a passive aws_creds drop for realism.
|
||||
"""
|
||||
raw = os.environ.get(
|
||||
"DECNET_CANARY_BASELINE",
|
||||
"git_config,env_file,honeydoc,aws_creds",
|
||||
)
|
||||
return [n.strip() for n in raw.split(",") if n.strip()]
|
||||
|
||||
|
||||
def _ctx_for(slug: str) -> CanaryContext:
|
||||
"""Build a :class:`CanaryContext` from the canary worker config."""
|
||||
base = os.environ.get("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
|
||||
zone = os.environ.get("DECNET_CANARY_DNS_ZONE", "")
|
||||
return CanaryContext(callback_token=slug, http_base=base, dns_zone=zone)
|
||||
|
||||
|
||||
async def seed_baseline(
|
||||
decky_name: str,
|
||||
repo: BaseRepository,
|
||||
*,
|
||||
persona: str = "linux",
|
||||
created_by: str = "system",
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Plant the configured baseline canary set on one decky.
|
||||
|
||||
Best-effort: any individual placement that fails is logged and
|
||||
the row is left in ``state=failed``; the deployer hook treats the
|
||||
return value as informational, not authoritative.
|
||||
|
||||
Returns the list of token rows created (whether their planting
|
||||
ultimately succeeded or not), so the caller can surface them in
|
||||
the deploy report.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for gen_name in _baseline_set():
|
||||
try:
|
||||
generator = get_generator(gen_name)
|
||||
except ValueError:
|
||||
log.warning("canary.seed_baseline: unknown generator %r — skipping", gen_name)
|
||||
continue
|
||||
slug = token_urlsafe(16)
|
||||
ctx = _ctx_for(slug)
|
||||
artifact = generator.generate(ctx)
|
||||
artifact.path = default_path_for(gen_name, persona)
|
||||
kind = "aws_passive" if gen_name == "aws_creds" else "http"
|
||||
# Persist first so the planter has a row to update; that way a
|
||||
# crash mid-plant leaves a recoverable failed-state row.
|
||||
from uuid import uuid4
|
||||
token_uuid = str(uuid4())
|
||||
await repo.create_canary_token({
|
||||
"uuid": token_uuid,
|
||||
"kind": kind,
|
||||
"decky_name": decky_name,
|
||||
"blob_uuid": None,
|
||||
"instrumenter": None,
|
||||
"generator": gen_name,
|
||||
"placement_path": artifact.path,
|
||||
"callback_token": slug,
|
||||
"secret_seed": slug,
|
||||
"created_by": created_by,
|
||||
"state": "planted", # optimistic — plant() flips to failed on error
|
||||
})
|
||||
await plant(
|
||||
decky_name, artifact,
|
||||
token_uuid=token_uuid, repo=repo, publish=True, bus=bus,
|
||||
)
|
||||
out.append({
|
||||
"token_uuid": token_uuid, "generator": gen_name, "kind": kind,
|
||||
"callback_token": slug, "placement_path": artifact.path,
|
||||
})
|
||||
return out
|
||||
89
decnet/canary/storage.py
Normal file
89
decnet/canary/storage.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Filesystem store for operator-uploaded canary blobs.
|
||||
|
||||
Blobs live under ``/var/lib/decnet/canary/blobs/<sha256>`` (override
|
||||
via ``DECNET_CANARY_BLOB_DIR``) and are deduplicated by content hash.
|
||||
The DB table :class:`decnet.web.db.models.CanaryBlob` mirrors
|
||||
metadata; the bytes are read on demand at instrumentation time, so
|
||||
the API process never holds large operator uploads in memory longer
|
||||
than the request itself.
|
||||
|
||||
Refcount-aware deletion is enforced at the DB layer (see
|
||||
:meth:`decnet.web.db.repository.BaseRepository.delete_canary_blob`);
|
||||
this module only provides write/read/unlink primitives keyed by
|
||||
sha256.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def blob_dir() -> Path:
|
||||
"""Return the on-disk root for canary blobs.
|
||||
|
||||
Honors ``DECNET_CANARY_BLOB_DIR`` so tests can point at a tmp
|
||||
path. The directory is created lazily on first write.
|
||||
"""
|
||||
raw = os.environ.get("DECNET_CANARY_BLOB_DIR", "/var/lib/decnet/canary/blobs")
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _path_for(sha256: str) -> Path:
|
||||
# Two-level fan-out (``ab/cd/abcd...``) keeps any one directory
|
||||
# from accumulating thousands of entries on busy fleets. Same
|
||||
# shape as Git's loose-object store.
|
||||
if len(sha256) < 4:
|
||||
raise ValueError("sha256 must be at least 4 chars")
|
||||
root = blob_dir()
|
||||
return root / sha256[:2] / sha256[2:4] / sha256
|
||||
|
||||
|
||||
def write_blob(content: bytes) -> Tuple[str, Path, int]:
|
||||
"""Persist ``content`` under its sha256 path.
|
||||
|
||||
Idempotent: if the target file already exists with the same
|
||||
bytes, no rewrite happens. Returns ``(sha256, path,
|
||||
size_bytes)``.
|
||||
"""
|
||||
sha = hashlib.sha256(content).hexdigest()
|
||||
target = _path_for(sha)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not target.exists():
|
||||
# Atomic-ish: write to a temp sibling and rename. Avoids the
|
||||
# half-written-file race a concurrent reader would otherwise
|
||||
# see if we wrote in place.
|
||||
tmp = target.with_suffix(target.suffix + ".part")
|
||||
tmp.write_bytes(content)
|
||||
os.replace(tmp, target)
|
||||
return sha, target, len(content)
|
||||
|
||||
|
||||
def read_blob(sha256: str) -> bytes:
|
||||
"""Read the bytes for a stored blob.
|
||||
|
||||
Raises :class:`FileNotFoundError` when the on-disk row was unlinked
|
||||
out of band (operator pruned ``/var/lib/decnet`` by hand) — the
|
||||
caller (instrumenter dispatch) surfaces it as a 410-ish error so
|
||||
the operator can re-upload.
|
||||
"""
|
||||
return _path_for(sha256).read_bytes()
|
||||
|
||||
|
||||
def unlink_blob(sha256: str) -> bool:
|
||||
"""Delete the on-disk bytes for ``sha256``.
|
||||
|
||||
Returns True if a file was removed, False if it was already gone.
|
||||
The DB row deletion happens in
|
||||
:meth:`SQLModelRepository.delete_canary_blob`; this function is
|
||||
a best-effort companion called *after* the DB delete commits so
|
||||
a crash between them leaves a recoverable orphan, never a
|
||||
dangling DB reference.
|
||||
"""
|
||||
target = _path_for(sha256)
|
||||
try:
|
||||
target.unlink()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return True
|
||||
254
decnet/canary/worker.py
Normal file
254
decnet/canary/worker.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""``decnet canary`` worker — HTTP + DNS callback receivers.
|
||||
|
||||
Two surfaces, one process:
|
||||
|
||||
* **HTTP** — a tiny FastAPI app on its own port (default 8088). The
|
||||
only useful route is ``GET /c/{slug}`` which looks up the slug in
|
||||
the canary token table, persists a :class:`CanaryTrigger` row,
|
||||
publishes ``canary.<token_id>.triggered`` on the bus, and returns
|
||||
a 1×1 transparent GIF (or 204 if the client's ``Accept`` doesn't
|
||||
list any image type).
|
||||
* **DNS** — an authoritative UDP server (default 5353 if non-root,
|
||||
53 if root) for ``*.<canary_zone>``. Same lookup + persist +
|
||||
publish flow, plus a sinkhole A record so the attacker's resolver
|
||||
doesn't loop on NXDOMAIN.
|
||||
|
||||
Both surfaces are **stealth** by policy
|
||||
(:mod:`feedback_stealth`): no DECNET strings in headers / banners /
|
||||
error pages. The HTTP app strips the default ``Server: uvicorn``
|
||||
header in middleware; FastAPI's docs/openapi UI is disabled because
|
||||
discovering them would tip off the attacker that this is a honeypot.
|
||||
|
||||
The worker is supervised by its own systemd unit
|
||||
(``decnet-canary.service``); like every other DECNET worker, it
|
||||
crashes loudly rather than masking failures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.base import BaseBus
|
||||
from decnet.bus.factory import get_bus
|
||||
from decnet.canary.dns_server import CanaryDNSProtocol, DNSQuery
|
||||
from decnet.logging import get_logger
|
||||
from decnet.web.db.factory import get_repository
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
log = get_logger("canary.worker")
|
||||
|
||||
# 1×1 transparent GIF — public-domain canonical bytes. Returning the
|
||||
# same image every time is fine: the body has no information the
|
||||
# attacker shouldn't see, and image clients cache it.
|
||||
_TRANSPARENT_GIF = bytes.fromhex(
|
||||
"47494638396101000100800100000000ffffff21f90401000001002c00000000010001000002024401003b"
|
||||
)
|
||||
|
||||
|
||||
def _http_base() -> str:
|
||||
return os.environ.get("DECNET_CANARY_HTTP_BASE", "http://localhost:8088").rstrip("/")
|
||||
|
||||
|
||||
def _dns_zone() -> str:
|
||||
return os.environ.get("DECNET_CANARY_DNS_ZONE", "").strip(".").lower()
|
||||
|
||||
|
||||
def _http_port() -> int:
|
||||
return int(os.environ.get("DECNET_CANARY_HTTP_PORT", "8088"))
|
||||
|
||||
|
||||
def _dns_port() -> int:
|
||||
# Default 5353 (mDNS-ish, non-privileged) — operators pin :53 via
|
||||
# NAT or a CAP_NET_BIND_SERVICE-enabled unit.
|
||||
return int(os.environ.get("DECNET_CANARY_DNS_PORT", "5353"))
|
||||
|
||||
|
||||
def _dns_bind() -> str:
|
||||
return os.environ.get("DECNET_CANARY_DNS_BIND", "0.0.0.0") # nosec B104 — attacker-facing decoy listener, internet exposure is the design
|
||||
|
||||
|
||||
def _http_bind() -> str:
|
||||
return os.environ.get("DECNET_CANARY_HTTP_BIND", "0.0.0.0") # nosec B104 — same rationale
|
||||
|
||||
|
||||
# ---------------------------- HTTP surface --------------------------------
|
||||
|
||||
|
||||
def _build_app(repo: BaseRepository, bus: BaseBus) -> FastAPI:
|
||||
"""Construct the FastAPI app.
|
||||
|
||||
Disables docs / openapi / redoc — operators query the canary
|
||||
surface via the *main* DECNET API, never directly. Anyone hitting
|
||||
these paths is either misconfigured or scanning for a honeypot.
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="", # don't leak "DECNET" in OpenAPI
|
||||
docs_url=None, redoc_url=None, openapi_url=None,
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _stealth_headers(request: Request, call_next):
|
||||
response: Response = await call_next(request)
|
||||
# Strip the uvicorn / starlette banner; replace with a
|
||||
# generic Server line that matches what most CDNs return.
|
||||
response.headers["Server"] = "nginx"
|
||||
# Don't leak request id / process id headers.
|
||||
if "x-process-time" in response.headers:
|
||||
del response.headers["x-process-time"]
|
||||
return response
|
||||
|
||||
@app.get("/c/{slug}")
|
||||
async def callback(slug: str, request: Request) -> Response:
|
||||
await _record_hit(
|
||||
repo, bus,
|
||||
slug=slug,
|
||||
src_ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
request_path=str(request.url.path),
|
||||
dns_qname=None,
|
||||
raw_headers=dict(request.headers),
|
||||
)
|
||||
# Always 200 with a tiny image so the attacker's client sees
|
||||
# a "success" — same return regardless of whether the slug is
|
||||
# known. Stealth: do NOT distinguish unknown vs known via
|
||||
# status code or response body.
|
||||
return Response(content=_TRANSPARENT_GIF, media_type="image/gif")
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Response:
|
||||
# Bare root returns a generic 404. The decoy posture: pretend
|
||||
# to be an empty static-file host that just happens to resolve
|
||||
# /c/<slug> when it matches.
|
||||
return Response(status_code=404)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# Honor X-Forwarded-For if the operator deployed behind a reverse
|
||||
# proxy. Take the leftmost address in the chain; everything after
|
||||
# is upstream-proxy noise.
|
||||
fwd = request.headers.get("x-forwarded-for")
|
||||
if fwd:
|
||||
return fwd.split(",", 1)[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return "0.0.0.0" # nosec B104 — sentinel for "unknown remote"
|
||||
|
||||
|
||||
# ---------------------------- shared persistence -------------------------
|
||||
|
||||
|
||||
async def _record_hit(
|
||||
repo: BaseRepository,
|
||||
bus: BaseBus,
|
||||
*,
|
||||
slug: str,
|
||||
src_ip: str,
|
||||
user_agent: Optional[str],
|
||||
request_path: Optional[str],
|
||||
dns_qname: Optional[str],
|
||||
raw_headers: Optional[dict],
|
||||
) -> None:
|
||||
"""Resolve slug -> token, persist a trigger, publish on the bus.
|
||||
|
||||
Unknown slugs are silently swallowed: returning the same response
|
||||
for known and unknown slugs is the stealth posture, and persisting
|
||||
every random scan would clutter the DB.
|
||||
"""
|
||||
token = await repo.get_canary_token_by_slug(slug)
|
||||
if token is None:
|
||||
return
|
||||
trigger_id = await repo.record_canary_trigger({
|
||||
"token_uuid": token["uuid"],
|
||||
"occurred_at": datetime.now(timezone.utc),
|
||||
"src_ip": src_ip,
|
||||
"user_agent": user_agent,
|
||||
"request_path": request_path,
|
||||
"dns_qname": dns_qname,
|
||||
"raw_headers": raw_headers or {},
|
||||
})
|
||||
try:
|
||||
await bus.publish(
|
||||
topics.canary(token["uuid"], topics.CANARY_TRIGGERED),
|
||||
{
|
||||
"token_id": token["uuid"],
|
||||
"trigger_id": trigger_id,
|
||||
"decky_name": token["decky_name"],
|
||||
"src_ip": src_ip,
|
||||
"user_agent": user_agent,
|
||||
"request_path": request_path,
|
||||
"dns_qname": dns_qname,
|
||||
},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — best effort
|
||||
log.warning("canary.triggered publish failed slug=%s err=%s", slug, e)
|
||||
|
||||
|
||||
# ---------------------------- DNS surface --------------------------------
|
||||
|
||||
|
||||
async def _start_dns_server(
|
||||
repo: BaseRepository, bus: BaseBus, *, loop: asyncio.AbstractEventLoop,
|
||||
) -> Optional[asyncio.DatagramTransport]:
|
||||
zone = _dns_zone()
|
||||
if not zone:
|
||||
log.info("canary.dns disabled (DECNET_CANARY_DNS_ZONE unset)")
|
||||
return None
|
||||
|
||||
async def _hook(slug: str, query: DNSQuery, src_ip: str) -> None:
|
||||
await _record_hit(
|
||||
repo, bus,
|
||||
slug=slug, src_ip=src_ip, user_agent=None,
|
||||
request_path=None, dns_qname=query.qname,
|
||||
raw_headers=None,
|
||||
)
|
||||
|
||||
transport, _proto = await loop.create_datagram_endpoint(
|
||||
lambda: CanaryDNSProtocol(zone, _hook),
|
||||
local_addr=(_dns_bind(), _dns_port()),
|
||||
)
|
||||
log.info("canary.dns listening zone=%s port=%d", zone, _dns_port())
|
||||
return transport # type: ignore[return-value]
|
||||
|
||||
|
||||
# ---------------------------- entry point --------------------------------
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
"""Worker entry point — kicked off by ``decnet canary``."""
|
||||
import uvicorn
|
||||
|
||||
repo = get_repository()
|
||||
await repo.initialize()
|
||||
bus = get_bus()
|
||||
await bus.connect()
|
||||
|
||||
app = _build_app(repo, bus)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=_http_bind(),
|
||||
port=_http_port(),
|
||||
log_level="warning",
|
||||
access_log=False, # stealth: no per-request lines
|
||||
server_header=False, # we set Server: nginx in middleware
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
loop = asyncio.get_running_loop()
|
||||
dns_transport = await _start_dns_server(repo, bus, loop=loop)
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
if dns_transport is not None:
|
||||
dns_transport.close()
|
||||
await bus.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point — synchronous wrapper for ``asyncio.run``."""
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user