From f64e78f78cbce58bb5febda8d931203c3ac9a90a Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 16:22:18 -0400 Subject: [PATCH] feat(canary): fingerprint_html + fingerprint_svg generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new synthesised-artifact generators that bake the obfuscated fingerprint payload into plausible-looking decoy files: * fingerprint_html — a mundane "Internal Asset Directory" page with a small table of fake hosts; the obfuscated payload is inlined at the bottom of . Visible content (row pool slice, sync timestamp) also varies per mint via SHA-256-derived stable ints, so two extracted canaries don't diff to zero even on the rendered surface. * fingerprint_svg — standalone SVG with an embedded + + +""" + + +_ROW_POOL = ( + ("ny-app-01.corp.local", "k.tanaka", "app server", "vlan20", "primary"), + ("ny-db-01.corp.local", "ops", "postgres primary", "vlan30", "backup nightly"), + ("ny-build-02.corp.local", "ci-bot", "jenkins agent", "vlan40", ""), + ("sf-vpn-01.corp.local", "netsec", "wireguard endpoint", "vlan10", "external"), + ("ldn-mail-03.corp.local", "j.weber", "exchange edge", "vlan50", ""), + ("hk-cache-01.corp.local", "ops", "redis replica", "vlan30", "lag <1s"), + ("br-dev-04.corp.local", "m.silva", "dev sandbox", "vlan60", "ephemeral"), + ("eu-bastion-02.corp.local", "secops", "ssh jump host", "vlan10", "mfa required"), + ("us-archive-01.corp.local", "compliance", "log archive", "vlan70", "retain 7y"), +) + + +def _build_rows(callback_token: str) -> tuple[str, int]: + pick = _stable_int(callback_token, "pick") % len(_ROW_POOL) + take = 5 + (_stable_int(callback_token, "take") % 4) + selected = [_ROW_POOL[(pick + i) % len(_ROW_POOL)] for i in range(take)] + cells = "\n".join( + "" + "".join(f"{c}" for c in row) + "" + for row in selected + ) + return cells, len(selected) + + +def _sync_label(callback_token: str) -> str: + day = _stable_int(callback_token, "day") % 28 + 1 + hour = _stable_int(callback_token, "hour") % 24 + return f"2026-04-{day:02d} {hour:02d}:14 UTC" + + +class FingerprintHtmlGenerator(CanaryGenerator): + """Synthesise an HTML page that fingerprints the browser opening it.""" + + name = "fingerprint_html" + + def generate(self, ctx: CanaryContext) -> CanaryArtifact: + mint_uuid = _mint_uuid_for(ctx.callback_token) + payload = render_fingerprint_js( + callback_token=ctx.callback_token, + http_base=ctx.http_base, + mint_uuid=mint_uuid, + ) + rows, row_count = _build_rows(ctx.callback_token) + body = _PAGE_TEMPLATE.format( + sync_label=_sync_label(ctx.callback_token), + row_count=row_count, + rows=rows, + payload=payload, + ) + beacon = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}" + return CanaryArtifact( + path="", + content=body.encode("utf-8"), + mode=0o644, + mtime_offset=-86400 * 14, + generator=self.name, + notes=[ + f"obfuscated fingerprinter beacons={beacon}", + f"mint_uuid={mint_uuid}", + ], + ) diff --git a/decnet/canary/generators/fingerprint_svg.py b/decnet/canary/generators/fingerprint_svg.py new file mode 100644 index 00000000..fb6c418a --- /dev/null +++ b/decnet/canary/generators/fingerprint_svg.py @@ -0,0 +1,85 @@ +"""SVG fingerprint canary — standalone SVG with an embedded `` + +""" + + +_REGIONS = ("us-east", "eu-central", "ap-south", "us-west", "sa-east") + + +class FingerprintSvgGenerator(CanaryGenerator): + """Synthesise an SVG that fingerprints the browser opening it.""" + + name = "fingerprint_svg" + + def generate(self, ctx: CanaryContext) -> CanaryArtifact: + mint_uuid = _mint_uuid_for(ctx.callback_token) + payload = render_fingerprint_js( + callback_token=ctx.callback_token, + http_base=ctx.http_base, + mint_uuid=mint_uuid, + ) + region = _REGIONS[_stable_int(ctx.callback_token, "reg") % len(_REGIONS)] + ver = 1 + (_stable_int(ctx.callback_token, "ver") % 6) + day = _stable_int(ctx.callback_token, "day") % 28 + 1 + body = _DIAGRAM_TEMPLATE.format( + region=region, + ver=ver, + review=f"2026-03-{day:02d}", + payload=payload, + ) + beacon = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}" + return CanaryArtifact( + path="", + content=body.encode("utf-8"), + mode=0o644, + mtime_offset=-86400 * 30, + generator=self.name, + notes=[ + f"obfuscated fingerprinter beacons={beacon}", + f"mint_uuid={mint_uuid}", + ], + ) diff --git a/decnet/canary/paths.py b/decnet/canary/paths.py index 5700ad0f..b2b731a5 100644 --- a/decnet/canary/paths.py +++ b/decnet/canary/paths.py @@ -28,6 +28,8 @@ _LINUX_DEFAULTS: dict[str, str] = { "honeydoc": "/home/{user}/Documents/quarterly_report.html", "honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx", "honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf", + "fingerprint_html": "/home/{user}/Documents/asset_directory.html", + "fingerprint_svg": "/home/{user}/Documents/network_topology.svg", } _WINDOWS_DEFAULTS: dict[str, str] = { @@ -38,6 +40,8 @@ _WINDOWS_DEFAULTS: dict[str, str] = { "honeydoc": "/home/{user}/Documents/quarterly_report.html", "honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx", "honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf", + "fingerprint_html": "/home/{user}/Documents/asset_directory.html", + "fingerprint_svg": "/home/{user}/Documents/network_topology.svg", } diff --git a/tests/canary/test_fingerprint_generators.py b/tests/canary/test_fingerprint_generators.py new file mode 100644 index 00000000..b2dd4c4f --- /dev/null +++ b/tests/canary/test_fingerprint_generators.py @@ -0,0 +1,103 @@ +"""Tests for the HTML/SVG fingerprint canary generators. + +Skipped when the Node toolchain (or vendored javascript-obfuscator) is +not installed, mirroring :mod:`tests.canary.test_obfuscator`. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from decnet.canary import CanaryContext, get_generator + + +def _toolchain_ready() -> bool: + if shutil.which("node") is None: + return False + canary_dir = Path(__file__).resolve().parents[2] / "decnet" / "canary" + return (canary_dir / "node_modules" / "javascript-obfuscator").is_dir() + + +pytestmark = pytest.mark.skipif( + not _toolchain_ready(), + reason="node + javascript-obfuscator not installed under decnet/canary", +) + + +def _ctx(callback_token: str = "fp-tok-123") -> CanaryContext: + return CanaryContext( + callback_token=callback_token, + http_base="https://canary.example.test", + dns_zone="canary.example.test", + persona="linux", + ) + + +def test_fingerprint_html_renders_full_page() -> None: + art = get_generator("fingerprint_html").generate(_ctx()) + body = art.content.decode("utf-8") + assert body.startswith("") + assert "" in body + assert "Internal Asset Directory" in body + assert "" in body + # Beacon URL must NOT appear in plaintext — it's inside the + # obfuscated string array. (Sanity: this is the whole point of + # obfuscating the payload.) + assert "/c/fp-tok-123" not in body + # Visible content shouldn't leak the slug either. + assert "fp-tok-123" not in body + assert art.mode == 0o644 + assert art.generator == "fingerprint_html" + + +def test_fingerprint_html_is_deterministic_per_token() -> None: + a = get_generator("fingerprint_html").generate(_ctx("tokA")) + b = get_generator("fingerprint_html").generate(_ctx("tokA")) + assert a.content == b.content + + +def test_fingerprint_html_differs_across_tokens() -> None: + a = get_generator("fingerprint_html").generate(_ctx("tokA")) + b = get_generator("fingerprint_html").generate(_ctx("tokB")) + assert a.content != b.content + + +def test_fingerprint_html_notes_carry_mint_uuid_and_beacon() -> None: + art = get_generator("fingerprint_html").generate(_ctx("tok-notes")) + joined = " | ".join(art.notes) + assert "mint_uuid=" in joined + assert "https://canary.example.test/c/tok-notes" in joined + + +def test_fingerprint_svg_renders_valid_svg_with_script() -> None: + art = get_generator("fingerprint_svg").generate(_ctx()) + body = art.content.decode("utf-8") + assert body.startswith("" in body + assert " None: + a = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + b = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + assert a.content == b.content + + +def test_fingerprint_svg_differs_across_tokens() -> None: + a = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + b = get_generator("fingerprint_svg").generate(_ctx("svgTokB")) + assert a.content != b.content + + +def test_mint_uuid_stable_across_html_and_svg() -> None: + # Same callback token → same mint UUID across both generators, so + # the worker can correlate beacons regardless of artifact shape. + html = get_generator("fingerprint_html").generate(_ctx("shared-tok")) + svg = get_generator("fingerprint_svg").generate(_ctx("shared-tok")) + html_uuid = next(n for n in html.notes if n.startswith("mint_uuid=")) + svg_uuid = next(n for n in svg.notes if n.startswith("mint_uuid=")) + assert html_uuid == svg_uuid diff --git a/tests/canary/test_generators.py b/tests/canary/test_generators.py index dae7dc5f..5b35ac89 100644 --- a/tests/canary/test_generators.py +++ b/tests/canary/test_generators.py @@ -9,12 +9,31 @@ the artifact" property. from __future__ import annotations import re +import shutil +from pathlib import Path import pytest from decnet.canary import CanaryContext, get_generator from decnet.canary.factory import KNOWN_GENERATORS +# fingerprint_* generators shell out to javascript-obfuscator via Node. +# Skip those parametrized cases when the toolchain isn't installed so a +# bare CI checkout doesn't fail before `npm install` runs. +_NEEDS_NODE = {"fingerprint_html", "fingerprint_svg"} + + +def _node_toolchain_ready() -> bool: + if shutil.which("node") is None: + return False + canary_dir = Path(__file__).resolve().parents[2] / "decnet" / "canary" + return (canary_dir / "node_modules" / "javascript-obfuscator").is_dir() + + +def _maybe_skip(name: str) -> None: + if name in _NEEDS_NODE and not _node_toolchain_ready(): + pytest.skip(f"{name} requires node + javascript-obfuscator") + def _ctx(**kw) -> CanaryContext: defaults = dict( @@ -29,6 +48,7 @@ def _ctx(**kw) -> CanaryContext: @pytest.mark.parametrize("name", KNOWN_GENERATORS) def test_generator_is_deterministic(name: str) -> None: + _maybe_skip(name) g = get_generator(name) a = g.generate(_ctx()) b = g.generate(_ctx()) @@ -184,5 +204,7 @@ def test_artifacts_carry_notes() -> None: # check what we did before the file lands. Empty notes would mean # the operator is staring at opaque bytes. for name in KNOWN_GENERATORS: + if name in _NEEDS_NODE and not _node_toolchain_ready(): + continue art = get_generator(name).generate(_ctx()) assert art.notes, f"{name} produced no notes"