feat(canary): fingerprint_html + fingerprint_svg generators
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 <body>. 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 <script> CDATA block. SVG <script> only fires for top-level loads / <object> / <iframe>; <img>-referenced renders are safely inert. Both derive the mint UUID via uuid.uuid5 from the callback token, so re-mints are byte-identical (preserving the generator determinism contract) AND the same token produces the same mint UUID across HTML and SVG variants — the worker can correlate beacons across artifact shapes. Wired into the factory + KNOWN_GENERATORS, default placement paths under ~/Documents/asset_directory.html and ~/Documents/network_topology.svg for both linux and windows personas. Tests cover determinism, per-token divergence, structural validity (DOCTYPE/SVG headers), and that the beacon URL stays inside the obfuscated string array (not in plaintext). The two new entries skip in test_generators.py when Node toolchain is absent so bare CI checkouts still pass.
This commit is contained in:
103
tests/canary/test_fingerprint_generators.py
Normal file
103
tests/canary/test_fingerprint_generators.py
Normal file
@@ -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("<!DOCTYPE html>")
|
||||
assert "<script>" in body and "</script>" in body
|
||||
assert "Internal Asset Directory" in body
|
||||
assert "<table>" 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("<?xml version=\"1.0\"")
|
||||
assert "<svg" in body and "</svg>" in body
|
||||
assert "<script" in body and "<![CDATA[" in body
|
||||
assert art.mode == 0o644
|
||||
assert art.generator == "fingerprint_svg"
|
||||
|
||||
|
||||
def test_fingerprint_svg_is_deterministic_per_token() -> 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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user