AID-DV-006: Synthetic System-Information Decoy Routes

Version 1.20261015 · Actionable technique

Expose and monitor narrowly scoped, explicitly isolated reconnaissance paths that return synthetic framework, version, topology, debug, or environment metadata and generate high-confidence interaction telemetry. The decoy route must not read production configuration, secrets, diagnostics, or model identity.

Pillars: infra, model, app

Lifecycle: operation

Scope boundary

Owns gateway or proxy population, priority, interception, and non-fallback routing for narrow synthetic reconnaissance paths. It must never read or fall through to truthful production diagnostics, configuration, secrets, or model identity.

AID-H-032.003: AID-DV-006 deliberately serves synthetic metadata on isolated trap routes; AID-H-032.003 minimizes or disables truthful inference telemetry and debug surfaces. A production route that should disclose nothing belongs to AID-H-032.003. A separately isolated route intentionally populated with synthetic reconnaissance data belongs here.

AID-DV-001: AID-DV-006 implements selected synthetic reconnaissance routes; AID-DV-001 operates a full decoy model, agent, or AI API service. Use this technique when a gateway or proxy can intercept a narrow path without exposing a broader imitation service.

Implementation guidance

AID-DV-006-G001

Expose honeypot /debug or /env endpoints that return fake stack details and raise alerts.

Runtime policy: Load request/response byte ceilings and evidence connect/read deadlines from the signed, versioned responder profile bound to this trap route, fixture digest, and image release, bind its version/digest to receipts and boundary probes, and return ERROR if the profile or any value is absent, invalid, or unverifiable.

Method boundary and prerequisites

This step handles the isolated decoy responder and its per-request evidence receipt. The gateway route and proof that production was not selected belong to the next guidance; full attacker-interaction honeypots belong to AID-DV-001. Consume signed-configuration verification from AID-H-021.002. The responder image must have no model endpoint, production configuration, cloud metadata, secret-manager, or business-data access. Its only permitted egress is an mTLS-authenticated evidence collector whose append-only retention is controlled by a separate security identity.

Define a signed decoy fixture

The values below are illustrative synthetic content. The deploying organization chooses and approves its own fixture, route population, request limit, and evidence endpoint in a signed policy; none of them are framework defaults.

{
  "schema_version": "aidefend.dv006.decoy_endpoint.v2",
  "decoy_id": "legacy-inference-debug-v1",
  "policy_version": "decoy-policy-2026-07",
  "runtime_profile_version": "dv006-responder-usw2-2026-07",
  "responder_image_digest": "sha256:3eb1bd439947eb762998e566ccc2e099b09d04c5c94641dd3f31452f3a923964",
  "gateway_id": "public-ai-gateway-usw2",
  "gateway_route_config_sha256": "8c14a537de588d8bcff7c7f41fdf57cb7cbd6716136564f28730b93ff64d6e82",
  "gateway_assertion_public_key_sha256": "36c159d76b52e03e6b910faf145498748b298b8168cfd6bf7769c27aa4ce2ad8",
  "allowed_paths": ["/internal/debug/env"],
  "allowed_methods": ["GET"],
  "response_status": 200,
  "maximum_route_entries": 8,
  "max_request_bytes": 65536,
  "max_response_bytes": 16384,
  "request_body_timeout_seconds": 3.0,
  "gateway_assertion_maximum_bytes": 8192,
  "gateway_assertion_maximum_age_seconds": 15,
  "gateway_assertion_clock_skew_seconds": 2,
  "evidence_sink_url": "https://evidence.internal.example.org/v1/security-events",
  "evidence_connect_timeout_seconds": 1.0,
  "evidence_read_timeout_seconds": 2.0,
  "evidence_write_timeout_seconds": 2.0,
  "evidence_pool_timeout_seconds": 1.0,
  "evidence_total_timeout_seconds": 4.0,
  "evidence_max_response_bytes": 16384,
  "evidence_response_chunk_bytes": 4096,
  "evidence_ca_maximum_bytes": 131072,
  "evidence_client_certificate_maximum_bytes": 131072,
  "evidence_client_key_maximum_bytes": 131072,
  "response_body": {
    "status": "OK",
    "service": "InferenceEngine",
    "runtime": "Java-1.8.0_151",
    "os": "CentOS 7",
    "dependencies": {"log4j": "2.14.1", "spring-boot": "2.5.0"}
  }
}

Store the canonical JSON and Cosign v3.1.2 bundle at the fixed read-only policy paths and the manifest and raw Ed25519 gateway verification keys at the fixed root-owned trust paths shown below. The signed gateway_route_config_sha256 is the SHA-256 of the exact canonical gateway assertion contract from the next guidance; that contract intentionally excludes the responder-manifest digest to avoid a circular digest dependency. The image digest and all operational timeouts, byte limits, assertion lifetime, and evidence bounds are signed fields; environment variables do not select them. CI must scan the exact fixture bytes against the production secret inventory and block promotion on any match; intentionally fake credential-shaped strings must be registered as decoys so the scanner exception is exact and reviewable.

Run a fail-closed FastAPI responder
# File: deception/decoy_endpoints.py
from __future__ import annotations

import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import math
import os
import ssl
import stat
import subprocess
import tempfile
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit

import httpx
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse


MANIFEST_PATH = Path("/run/policy/dv006-decoy-manifest.json")
MANIFEST_BUNDLE_PATH = Path("/run/policy/dv006-decoy-manifest.sigstore.json")
MANIFEST_VERIFY_KEY_PATH = Path("/run/trust/dv006-decoy-manifest.pub")
GATEWAY_ASSERTION_VERIFY_KEY_PATH = Path("/run/trust/dv006-gateway-assertion.ed25519")
EVIDENCE_CA_PATH = Path("/run/trust/dv006-evidence-ca.pem")
EVIDENCE_CLIENT_CERT_PATH = Path("/run/identity/dv006-evidence-client.pem")
EVIDENCE_CLIENT_KEY_PATH = Path("/run/identity/dv006-evidence-client.key")
COSIGN_BINARY = Path("/usr/local/bin/cosign")
# Compiled ceilings are used only before the signed responder profile is trusted.
MANIFEST_BOOTSTRAP_ARTIFACT_CEILING = 1 * 1024 * 1024  # bytes
MANIFEST_BUNDLE_BOOTSTRAP_ARTIFACT_CEILING = 1 * 1024 * 1024  # bytes
MANIFEST_KEY_BOOTSTRAP_ARTIFACT_CEILING = 64 * 1024  # bytes
COSIGN_VERIFICATION_DEADLINE_CEILING_SECONDS = 10.0


MANIFEST_FIELDS = {
    "schema_version", "decoy_id", "policy_version", "runtime_profile_version",
    "responder_image_digest", "gateway_id", "gateway_route_config_sha256",
    "gateway_assertion_public_key_sha256", "allowed_paths",
    "allowed_methods", "response_status", "maximum_route_entries",
    "max_request_bytes", "max_response_bytes", "request_body_timeout_seconds",
    "gateway_assertion_maximum_bytes", "gateway_assertion_maximum_age_seconds",
    "gateway_assertion_clock_skew_seconds", "evidence_sink_url",
    "evidence_connect_timeout_seconds", "evidence_read_timeout_seconds",
    "evidence_write_timeout_seconds", "evidence_pool_timeout_seconds",
    "evidence_total_timeout_seconds", "evidence_max_response_bytes",
    "evidence_response_chunk_bytes", "evidence_ca_maximum_bytes",
    "evidence_client_certificate_maximum_bytes",
    "evidence_client_key_maximum_bytes", "response_body",
}
GATEWAY_ASSERTION_FIELDS = {
    "schema_version", "gateway_id", "gateway_route_config_sha256",
    "request_id", "trace_id", "method", "raw_path_sha256",
    "raw_query_sha256", "body_sha256", "body_bytes",
    "issued_at", "expires_at",
}
SUPPORTED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
HEX = set("0123456789abcdef")


def canonical(value: object) -> bytes:
    return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")


def unique_object(pairs: list[tuple[str, object]]) -> dict:
    result: dict = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"duplicate JSON key: {key}")
        result[key] = value
    return result


def digest_ok(value: object) -> bool:
    return isinstance(value, str) and len(value) == 64 and not (set(value) - HEX)


def positive_integer(value: object) -> bool:
    return type(value) is int and value > 0


def positive_number(value: object) -> bool:
    return type(value) in (int, float) and math.isfinite(value) and value > 0


def read_stable_file(path: Path, maximum_bytes: int, *, privileged_trust: bool = False) -> bytes:
    flags = os.O_RDONLY | os.O_CLOEXEC
    if hasattr(os, "O_NOFOLLOW"):
        flags |= os.O_NOFOLLOW
    descriptor = os.open(path, flags)
    try:
        before = os.fstat(descriptor)
        if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= maximum_bytes:
            raise ValueError("artifact must be a bounded regular file")
        if privileged_trust and (before.st_uid != 0 or before.st_mode & 0o022):
            raise PermissionError("trust anchor must be root-owned and not group/world writable")
        data = b""
        while len(data) <= maximum_bytes:
            # The fixed chunk size bounds each syscall only; the required profile value bounds the file.
            chunk = os.read(descriptor, min(65_536, maximum_bytes + 1 - len(data)))
            if not chunk:
                break
            data += chunk
        after = os.fstat(descriptor)
        if (
            len(data) != before.st_size
            or len(data) > maximum_bytes
            or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
            != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
        ):
            raise RuntimeError("artifact changed while being read")
        return data
    finally:
        os.close(descriptor)


def load_verified_manifest() -> tuple[dict, dict]:
    cosign_metadata = COSIGN_BINARY.stat(follow_symlinks=False)
    if (
        not stat.S_ISREG(cosign_metadata.st_mode)
        or cosign_metadata.st_uid != 0
        or cosign_metadata.st_mode & 0o022
        or not cosign_metadata.st_mode & 0o111
    ):
        raise RuntimeError("fixed Cosign verifier must be a root-controlled executable")
    manifest_bytes = read_stable_file(
        MANIFEST_PATH, MANIFEST_BOOTSTRAP_ARTIFACT_CEILING, privileged_trust=True,
    )
    signature_bytes = read_stable_file(
        MANIFEST_BUNDLE_PATH, MANIFEST_BUNDLE_BOOTSTRAP_ARTIFACT_CEILING,
        privileged_trust=True,
    )
    public_key_bytes = read_stable_file(
        MANIFEST_VERIFY_KEY_PATH, MANIFEST_KEY_BOOTSTRAP_ARTIFACT_CEILING,
        privileged_trust=True,
    )
    with tempfile.TemporaryDirectory() as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        manifest_path = root / "manifest.json"
        signature_path = root / "manifest.sigstore.json"
        key_path = root / "verify.pub"
        manifest_path.write_bytes(manifest_bytes)
        signature_path.write_bytes(signature_bytes)
        key_path.write_bytes(public_key_bytes)
        for snapshot in (manifest_path, signature_path, key_path):
            os.chmod(snapshot, 0o400)
        completed = subprocess.run(
            [
                str(COSIGN_BINARY), "verify-blob", "--key", str(key_path),
                "--bundle", str(signature_path), str(manifest_path),
            ],
            check=True,
            capture_output=True,
            timeout=COSIGN_VERIFICATION_DEADLINE_CEILING_SECONDS,
        )
        if (
            manifest_path.read_bytes() != manifest_bytes
            or signature_path.read_bytes() != signature_bytes
            or key_path.read_bytes() != public_key_bytes
        ):
            raise OSError("verified manifest snapshot changed")
    manifest = json.loads(
        manifest_bytes.decode("utf-8", errors="strict"),
        object_pairs_hook=unique_object,
        parse_constant=lambda value: (_ for _ in ()).throw(
            ValueError(f"invalid JSON constant: {value}")
        ),
    )
    if not isinstance(manifest, dict) or set(manifest) != MANIFEST_FIELDS:
        raise ValueError("decoy manifest schema mismatch")
    if manifest["schema_version"] != "aidefend.dv006.decoy_endpoint.v2":
        raise ValueError("unsupported decoy manifest schema")
    for field in (
        "decoy_id", "policy_version", "runtime_profile_version", "gateway_id",
    ):
        if not isinstance(manifest[field], str) or not manifest[field].strip():
            raise ValueError(f"invalid {field}")
    if (
        not isinstance(manifest["responder_image_digest"], str)
        or not manifest["responder_image_digest"].startswith("sha256:")
        or not digest_ok(manifest["responder_image_digest"][7:])
        or not digest_ok(manifest["gateway_route_config_sha256"])
        or not digest_ok(manifest["gateway_assertion_public_key_sha256"])
    ):
        raise ValueError("manifest release or gateway binding is invalid")
    paths = manifest["allowed_paths"]
    methods = manifest["allowed_methods"]
    if (
        not isinstance(paths, list)
        or not paths
        or len(paths) != len(set(paths))
        or any(
            not isinstance(path, str) or not path.startswith("/")
            or len(path) > 2048 or any(character in path for character in ("?", "#", "\\"))
            or path != path.encode("ascii", errors="strict").decode("ascii")
            for path in paths
        )
    ):
        raise ValueError("allowed_paths must be unique absolute paths")
    if (
        not isinstance(methods, list)
        or not methods
        or len(methods) != len(set(methods))
        or any(type(method) is not str or method not in SUPPORTED_METHODS for method in methods)
    ):
        raise ValueError("allowed_methods are invalid")
    if type(manifest["response_status"]) is not int or not 200 <= manifest["response_status"] < 300:
        raise ValueError("decoy response status must be 2xx")
    for field in (
        "maximum_route_entries", "max_request_bytes", "max_response_bytes",
        "gateway_assertion_maximum_bytes", "gateway_assertion_maximum_age_seconds",
        "evidence_max_response_bytes", "evidence_response_chunk_bytes",
        "evidence_ca_maximum_bytes",
        "evidence_client_certificate_maximum_bytes",
        "evidence_client_key_maximum_bytes",
    ):
        if not positive_integer(manifest[field]):
            raise ValueError(f"{field} must be a positive signed policy integer")
    if (
        type(manifest["gateway_assertion_clock_skew_seconds"]) is not int
        or manifest["gateway_assertion_clock_skew_seconds"] < 0
    ):
        raise ValueError("gateway assertion clock skew must be a non-negative integer")
    for field in (
        "request_body_timeout_seconds", "evidence_connect_timeout_seconds",
        "evidence_read_timeout_seconds", "evidence_write_timeout_seconds",
        "evidence_pool_timeout_seconds", "evidence_total_timeout_seconds",
    ):
        if not positive_number(manifest[field]):
            raise ValueError(f"{field} must be a positive finite signed policy number")
    if len(paths) * len(methods) > manifest["maximum_route_entries"]:
        raise ValueError("route population exceeds the signed profile bound")
    if (
        manifest["evidence_response_chunk_bytes"]
        > manifest["evidence_max_response_bytes"]
        or manifest["evidence_total_timeout_seconds"]
        < max(
            manifest["evidence_connect_timeout_seconds"],
            manifest["evidence_read_timeout_seconds"],
            manifest["evidence_write_timeout_seconds"],
            manifest["evidence_pool_timeout_seconds"],
        )
    ):
        raise ValueError("evidence response or deadline bounds are inconsistent")
    if not isinstance(manifest["response_body"], dict):
        raise ValueError("response_body must be a JSON object")
    if len(canonical(manifest["response_body"])) > manifest["max_response_bytes"]:
        raise ValueError("decoy response exceeds the signed response bound")
    sink = urlsplit(manifest["evidence_sink_url"])
    if sink.scheme != "https" or not sink.hostname or sink.username or sink.password or sink.query or sink.fragment:
        raise ValueError("evidence sink must be an HTTPS URL without userinfo/query/fragment")
    receipt = {
        "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
        "signature_sha256": hashlib.sha256(signature_bytes).hexdigest(),
        "public_key_sha256": hashlib.sha256(public_key_bytes).hexdigest(),
        "verifier_output_sha256": hashlib.sha256(completed.stdout).hexdigest(),
        "runtime_profile_version": manifest["runtime_profile_version"],
        "responder_image_digest": manifest["responder_image_digest"],
        "gateway_route_config_sha256": manifest["gateway_route_config_sha256"],
    }
    return manifest, receipt


def load_gateway_assertion_key(manifest: dict) -> tuple[Ed25519PublicKey, str]:
    key_bytes = read_stable_file(
        GATEWAY_ASSERTION_VERIFY_KEY_PATH, 32, privileged_trust=True,
    )
    if len(key_bytes) != 32:
        raise ValueError("gateway assertion key must be a raw 32-byte Ed25519 key")
    key_sha256 = hashlib.sha256(key_bytes).hexdigest()
    if not hmac.compare_digest(
        key_sha256, manifest["gateway_assertion_public_key_sha256"]
    ):
        raise PermissionError("gateway assertion key differs from the signed profile")
    return Ed25519PublicKey.from_public_bytes(key_bytes), key_sha256


def parse_utc(value: object, field: str) -> datetime:
    if not isinstance(value, str) or not value.endswith("Z"):
        raise ValueError(f"{field} must be a UTC Z timestamp")
    parsed = datetime.fromisoformat(value[:-1] + "+00:00")
    if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
        raise ValueError(f"{field} must be UTC")
    return parsed


def single_header(request: Request, name: bytes) -> bytes:
    values = [value for key, value in request.scope.get("headers", []) if key.lower() == name]
    if len(values) != 1 or not values[0]:
        raise PermissionError(f"exactly one authenticated {name.decode()} header is required")
    return values[0]


def decode_base64url(value: bytes, maximum_decoded_bytes: int, field: str) -> bytes:
    if len(value) > ((maximum_decoded_bytes * 4 + 2) // 3) + 4:
        raise ValueError(f"{field} exceeds the signed bound")
    try:
        text = value.decode("ascii", errors="strict")
        if not text or "=" in text or any(
            character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
            for character in text
        ):
            raise ValueError(f"{field} is not canonical base64url")
        padded = text + "=" * ((4 - len(text) % 4) % 4)
        decoded = base64.b64decode(
            padded.encode("ascii"), altchars=b"-_", validate=True,
        )
    except (UnicodeError, binascii.Error) as error:
        raise ValueError(f"{field} is not valid base64url") from error
    if (
        not 1 <= len(decoded) <= maximum_decoded_bytes
        or not hmac.compare_digest(base64.urlsafe_b64encode(decoded).rstrip(b"="), value)
    ):
        raise ValueError(f"{field} is not canonical or exceeds the signed bound")
    return decoded


def verify_gateway_assertion(
    request: Request, body_sha256: str, body_bytes: int, now: datetime,
) -> tuple[dict, bytes, bytes]:
    manifest = request.app.state.manifest
    assertion_bytes = decode_base64url(
        single_header(request, b"x-aidefend-gateway-assertion"),
        manifest["gateway_assertion_maximum_bytes"],
        "gateway assertion",
    )
    signature = decode_base64url(
        single_header(request, b"x-aidefend-gateway-signature"), 64,
        "gateway signature",
    )
    if len(signature) != 64:
        raise ValueError("gateway signature must be 64 bytes")
    try:
        request.app.state.gateway_assertion_key.verify(signature, assertion_bytes)
    except InvalidSignature as error:
        raise PermissionError("gateway request assertion signature is invalid") from error
    assertion = json.loads(
        assertion_bytes.decode("utf-8", errors="strict"),
        object_pairs_hook=unique_object,
        parse_constant=lambda value: (_ for _ in ()).throw(
            ValueError(f"invalid gateway assertion constant: {value}")
        ),
    )
    if not isinstance(assertion, dict) or set(assertion) != GATEWAY_ASSERTION_FIELDS:
        raise ValueError("gateway assertion schema mismatch")
    if assertion["schema_version"] != "aidefend.dv006.gateway_request.v1":
        raise ValueError("unsupported gateway assertion schema")
    if (
        assertion["gateway_id"] != manifest["gateway_id"]
        or assertion["gateway_route_config_sha256"]
        != manifest["gateway_route_config_sha256"]
    ):
        raise PermissionError("gateway assertion is outside the signed route scope")
    try:
        request_uuid = uuid.UUID(assertion["request_id"])
    except (AttributeError, TypeError, ValueError) as error:
        raise ValueError("gateway request_id must be a canonical UUIDv4") from error
    if request_uuid.version != 4 or str(request_uuid) != assertion["request_id"]:
        raise ValueError("gateway request_id must be a canonical UUIDv4")
    trace_id = assertion["trace_id"]
    if (
        not isinstance(trace_id, str) or len(trace_id) != 32
        or set(trace_id) - HEX or trace_id == "0" * 32
    ):
        raise ValueError("gateway trace_id must be a non-zero lowercase W3C trace ID")
    for field in ("raw_path_sha256", "raw_query_sha256", "body_sha256"):
        if not digest_ok(assertion[field]):
            raise ValueError(f"invalid gateway {field}")
    if type(assertion["body_bytes"]) is not int or assertion["body_bytes"] < 0:
        raise ValueError("gateway body_bytes must be a non-negative integer")
    raw_path = request.scope.get("raw_path")
    raw_query = request.scope.get("query_string")
    if not isinstance(raw_path, bytes) or not isinstance(raw_query, bytes):
        raise RuntimeError("ASGI server did not preserve raw path/query bytes")
    expected = {
        "method": request.method,
        "raw_path_sha256": hashlib.sha256(raw_path).hexdigest(),
        "raw_query_sha256": hashlib.sha256(raw_query).hexdigest(),
        "body_sha256": body_sha256,
        "body_bytes": body_bytes,
    }
    if any(assertion[field] != value for field, value in expected.items()):
        raise PermissionError("gateway assertion does not bind the exact upstream request")
    issued_at = parse_utc(assertion["issued_at"], "issued_at")
    expires_at = parse_utc(assertion["expires_at"], "expires_at")
    maximum_age = timedelta(seconds=manifest["gateway_assertion_maximum_age_seconds"])
    clock_skew = timedelta(seconds=manifest["gateway_assertion_clock_skew_seconds"])
    if (
        expires_at < issued_at or expires_at - issued_at > maximum_age
        or issued_at > now + clock_skew or now - issued_at > maximum_age + clock_skew
        or expires_at < now - clock_skew
    ):
        raise PermissionError("gateway assertion is stale, premature, or overlong")
    return assertion, assertion_bytes, signature


async def request_body_digest(request: Request, maximum_bytes: int) -> tuple[str, int, bool]:
    claimed = request.headers.get("content-length")
    if claimed is not None:
        try:
            if int(claimed) < 0 or int(claimed) > maximum_bytes:
                return hashlib.sha256(b"").hexdigest(), 0, True
        except ValueError:
            return hashlib.sha256(b"").hexdigest(), 0, True
    digest = hashlib.sha256()
    size = 0
    async for chunk in request.stream():
        size += len(chunk)
        if size > maximum_bytes:
            return digest.hexdigest(), size, True
        digest.update(chunk)
    return digest.hexdigest(), size, False


async def persist_event(app: FastAPI, event: dict) -> dict:
    payload = canonical(event)
    event_sha256 = hashlib.sha256(payload).hexdigest()
    async with app.state.evidence_client.stream(
        "POST", app.state.manifest["evidence_sink_url"],
        content=payload,
        headers={"content-type": "application/json", "idempotency-key": event["event_id"], "accept-encoding": "identity"},
    ) as response:
        response.raise_for_status()
        chunks, observed = [], 0
        async for chunk in response.aiter_bytes(
            app.state.manifest["evidence_response_chunk_bytes"]
        ):
            observed += len(chunk)
            if observed > app.state.manifest["evidence_max_response_bytes"]:
                raise RuntimeError("evidence collector response exceeds signed bound")
            chunks.append(chunk)
    def reject(pairs):
        value = {}
        for key, item in pairs:
            if key in value:
                raise ValueError(f"duplicate receipt key: {key}")
            value[key] = item
        return value
    receipt = json.loads(b"".join(chunks).decode("utf-8", errors="strict"), object_pairs_hook=reject, parse_constant=lambda value: (_ for _ in ()).throw(ValueError('non-finite JSON constant: ' + value)))
    required = {"event_id", "event_sha256", "stored", "created", "receipt_sha256"}
    if not isinstance(receipt, dict) or set(receipt) != required:
        raise RuntimeError("evidence collector receipt schema mismatch")
    receipt_body = {key: receipt[key] for key in sorted(required - {"receipt_sha256"})}
    expected_receipt_sha256 = hashlib.sha256(canonical(receipt_body)).hexdigest()
    if (receipt["event_id"] != event["event_id"] or receipt["event_sha256"] != event_sha256
            or receipt["stored"] is not True or type(receipt["created"]) is not bool
            or not isinstance(receipt["receipt_sha256"], str)
            or not hmac.compare_digest(receipt["receipt_sha256"], expected_receipt_sha256)):
        raise RuntimeError("evidence collector did not bind the exact event")
    return receipt


@asynccontextmanager
async def lifespan(app: FastAPI):
    manifest, verification = load_verified_manifest()
    gateway_key, gateway_key_sha256 = load_gateway_assertion_key(manifest)
    ca_bytes = read_stable_file(
        EVIDENCE_CA_PATH, manifest["evidence_ca_maximum_bytes"],
        privileged_trust=True,
    )
    certificate_bytes = read_stable_file(
        EVIDENCE_CLIENT_CERT_PATH,
        manifest["evidence_client_certificate_maximum_bytes"],
        privileged_trust=True,
    )
    private_key_bytes = read_stable_file(
        EVIDENCE_CLIENT_KEY_PATH, manifest["evidence_client_key_maximum_bytes"],
        privileged_trust=True,
    )
    with tempfile.TemporaryDirectory(prefix="aidefend-dv006-tls-") as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        ca_snapshot = root / "ca.pem"
        certificate_snapshot = root / "client.pem"
        key_snapshot = root / "client.key"
        for path, content in (
            (ca_snapshot, ca_bytes),
            (certificate_snapshot, certificate_bytes),
            (key_snapshot, private_key_bytes),
        ):
            path.write_bytes(content)
            os.chmod(path, 0o400)
        tls = ssl.create_default_context(cafile=str(ca_snapshot))
        tls.minimum_version = ssl.TLSVersion.TLSv1_2
        tls.load_cert_chain(
            certfile=str(certificate_snapshot), keyfile=str(key_snapshot),
        )
        async with httpx.AsyncClient(
            verify=tls,
            timeout=httpx.Timeout(
                connect=manifest["evidence_connect_timeout_seconds"],
                read=manifest["evidence_read_timeout_seconds"],
                write=manifest["evidence_write_timeout_seconds"],
                pool=manifest["evidence_pool_timeout_seconds"],
            ),
            follow_redirects=False,
            trust_env=False,
        ) as client:
            app.state.manifest = manifest
            app.state.manifest_verification = verification
            app.state.gateway_assertion_key = gateway_key
            app.state.gateway_assertion_key_sha256 = gateway_key_sha256
            app.state.evidence_client = client
            yield


app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)


@app.api_route(
    "/{decoy_path:path}",
    methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def decoy_endpoint(request: Request, decoy_path: str):
    manifest = request.app.state.manifest
    path = "/" + decoy_path
    try:
        body_sha256, body_bytes, oversized = await asyncio.wait_for(
            request_body_digest(request, manifest["max_request_bytes"]),
            timeout=manifest["request_body_timeout_seconds"],
        )
    except TimeoutError:
        return JSONResponse({"detail": "unavailable"}, status_code=503)
    if oversized:
        return JSONResponse({"detail": "request_too_large"}, status_code=413)
    received_at = datetime.now(timezone.utc)
    try:
        assertion, assertion_bytes, assertion_signature = verify_gateway_assertion(
            request, body_sha256, body_bytes, received_at,
        )
    except Exception:
        return JSONResponse({"detail": "unavailable"}, status_code=503)
    matched = path in manifest["allowed_paths"] and request.method in manifest["allowed_methods"]
    event = {
        "schema_version": "aidefend.dv006.decoy_event.v2",
        "event_id": str(uuid.uuid5(
            uuid.NAMESPACE_URL,
            f"aidefend:dv006:{assertion['gateway_id']}:{assertion['request_id']}",
        )),
        "event_type": "decoy_endpoint_access" if matched else "decoy_route_population_mismatch",
        "received_at": received_at.isoformat(),
        "decoy_id": manifest["decoy_id"],
        "policy_version": manifest["policy_version"],
        "runtime_profile_version": manifest["runtime_profile_version"],
        "responder_image_digest": manifest["responder_image_digest"],
        "manifest_sha256": request.app.state.manifest_verification["manifest_sha256"],
        "gateway_id": assertion["gateway_id"],
        "gateway_route_config_sha256": assertion["gateway_route_config_sha256"],
        "gateway_request_id": assertion["request_id"],
        "gateway_trace_id": assertion["trace_id"],
        "gateway_issued_at": assertion["issued_at"],
        "gateway_expires_at": assertion["expires_at"],
        "gateway_assertion_sha256": hashlib.sha256(assertion_bytes).hexdigest(),
        "gateway_signature_sha256": hashlib.sha256(assertion_signature).hexdigest(),
        "gateway_public_key_sha256": request.app.state.gateway_assertion_key_sha256,
        "method": request.method,
        "path": path,
        "raw_path_sha256": assertion["raw_path_sha256"],
        "raw_query_sha256": assertion["raw_query_sha256"],
        "body_sha256": body_sha256,
        "body_bytes": body_bytes,
        "immediate_peer_ip": request.client.host if request.client else None,
    }
    try:
        await asyncio.wait_for(
            persist_event(request.app, event),
            timeout=manifest["evidence_total_timeout_seconds"],
        )
    except Exception:
        return JSONResponse({"detail": "unavailable"}, status_code=503)
    if not matched:
        return JSONResponse({"detail": "not_found"}, status_code=404)
    return JSONResponse(
        manifest["response_body"],
        status_code=manifest["response_status"],
        headers={"cache-control": "no-store"},
    )
Verify the result

Before promotion, independently verify the exact manifest/bundle/keys and compare its path/method population and canonical gateway assertion-contract digest with the signed gateway inventory from the next guidance. From an external probe identity, invoke every registered host/path/method plus unregistered, duplicate-header, stale-assertion, altered-body, replay, and oversized negative cases. The separately credentialed collector must atomically create each deterministic event ID only once and return created=true only for that first exact write. A lost-response retry may return created=false only when the collector receipt recomputes over the same event ID and exact event digest; a reused ID with different bytes fails closed. For each accepted invocation, read back the exact deterministic event ID and event digest from the append-only evidence store, correlate the authenticated gateway request ID and W3C trace ID, and confirm that the production backend has no matching request. Oversized and assertion-rejected attempts must remain visible in authenticated gateway telemetry because the responder deliberately refuses to treat an unauthenticated or incompletely read body as evidence. Retain manifest, bundle, key digests, verification receipt, image digest/SBOM, secret-scan result and exact exception, network-policy readback, complete probe population, gateway assertions, collector receipts, independent append-only readback (object lock/WORM only when the governing retention profile requires it), and promotion decision. Freshness is per deployment and per request; the responder's own return value is not evidence that the event was retained.

Action: Run the signed responder with no production access, accept only a fresh Ed25519-authenticated gateway envelope bound to the exact raw request, return the decoy only after the exact request event is durably acknowledged, and block promotion unless an independent identity reproduces every registered and negative probe outcome.

AID-DV-006-G002

Use an API gateway or proxy to intercept and spoof responses for high-risk reconnaissance paths.

Runtime policy: Load Kong connect/read/write deadlines and request/response ceilings from the signed, versioned route profile bound to this service, priority, responder receipt, and config release, bind its version/digest to rendered config and probes, and return ERROR if the profile or any value is absent, invalid, or unverifiable.

Method boundary and prerequisites

This step handles the public gateway's exact trap-route population, priority, upstream selection, and network isolation. It consumes the signed responder-readiness receipt from the previous guidance; verify that receipt locally before admitting the route. The decoy route must never fall back to a genuine backend when the responder is unavailable. Reuse AID-H-021.002 for signed configuration admission and keep full decoy-service behavior in AID-DV-001.

Declare and sign the exact route population
{
  "schema_version": "aidefend.dv006.gateway_routes.v1",
  "gateway_id": "public-ai-gateway-usw2",
  "policy_version": "decoy-route-policy-2026-07",
  "routes": [
    {
      "route_name": "dv006-debug-env",
      "hosts": ["ai-api.example.com"],
      "methods": ["GET"],
      "exact_paths": ["/internal/debug/env"],
      "upstream_service": "dv006-decoy-responder",
      "decoy_manifest_sha256": "cfb79f4ed61e88f248d69a56b7b22b0149f3d6544413eef235659018b4d558cb"
    }
  ]
}

Generate this inventory from reviewed source, reject duplicate host/method/path tuples, sign it and the gateway/network configuration, and bind the responder manifest digest. The digest above is a complete illustrative value; replace it with the actual fixture digest produced by the previous guidance.

Signed rendered-profile example: The ports, route priority, connection/read/write timeouts, retry budget, and request-size bound in the following YAML are illustrative values in a signature-verified deployment-profile example, not framework defaults. Production promotion must render every one from required typed profile fields, validate the rendered Kong and NetworkPolicy objects, and bind their profile version/digest into readback evidence.

Configure a non-fallback Kong route
# Illustrative rendered output: gateway/kong-decoy-routes.yaml
_format_version: "3.0"
_transform: true

services:
  - name: dv006-decoy-responder
    protocol: https
    host: dv006-decoy.deception.svc.cluster.local
    port: 8443
    tls_verify: true
    connect_timeout: 1000
    read_timeout: 3000
    write_timeout: 3000
    retries: 0
    routes:
      - name: dv006-debug-env
        protocols: [https]
        hosts: [ai-api.example.com]
        methods: [GET]
        paths: ["~^/internal/debug/env$"]
        regex_priority: 1000
        strip_path: false
        preserve_host: false
        path_handling: v0

plugins:
  - name: correlation-id
    route: dv006-debug-env
    config:
      header_name: X-Request-ID
      generator: uuid
      echo_downstream: true
  - name: request-size-limiting
    route: dv006-debug-env
    config:
      allowed_payload_size: 64
      size_unit: kilobytes
      require_content_length: false

Use a Kong release that supports the configured fields and validate the declarative file with the release-pinned Kong/decK validator used by the deployment. The anchored regex and priority are explicit because Kong's regex routes are ordered by regex_priority. Keep public debug headers disabled; the correlation ID is only a cross-system join key and never proves source identity or retention.

Enforce network isolation
# Illustrative rendered output: gateway/dv006-decoy-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: dv006-decoy-isolation
  namespace: deception
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: dv006-decoy
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: gateway
          podSelector:
            matchLabels:
              app.kubernetes.io/name: kong
      ports:
        - {protocol: TCP, port: 8443}
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: security-evidence
          podSelector:
            matchLabels:
              app.kubernetes.io/name: evidence-collector
      ports:
        - {protocol: TCP, port: 443}
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: security-evidence
          podSelector:
            matchLabels:
              app.kubernetes.io/name: dv006-dns-proxy
      ports:
        - {protocol: UDP, port: 53}
        - {protocol: TCP, port: 53}

Apply an equivalent egress-deny control in non-Kubernetes environments. The decoy workload identity must have no production IAM role or service-account token, and genuine backends must reject that identity independently.

Independent verification

After every gateway or responder release, use an authenticated read-only control-plane identity to export all effective services, routes, plugins, and upstreams. Normalize and compare the complete public host/method/path population with the signed route inventory; do not rely on source YAML or Kong regex collision detection alone. An external probe then calls every registered tuple plus near-miss paths, alternate methods, encoded/normalized paths, responder failure, and oversized bodies. Confirm the effective route/upstream ID in gateway telemetry, the matching append-only responder event, no matching genuine-backend access event, and a stable 5xx rather than production fallback when the decoy is unavailable. Retain signed sources, validation output, effective readback, route-population diff, network-policy and IAM readback, probe inputs/responses, gateway and responder receipts, production-log negative check, verifier identity, and promotion decision in an externally credentialed append-only store.

Action: Admit only the signed exact route set, fail closed to a gateway error when the decoy is unavailable, and block promotion until an independent probe proves the decoy handled every registered tuple while the genuine backend handled none.

The gateway-to-responder hop must use TLS with server verification and a gateway client identity (mTLS directly or through an authenticated service-mesh sidecar); the responder must reject an unverified upstream peer. The illustrated DNS allowance targets a dedicated policy-enforcing proxy that resolves only the exact responder and evidence-service names. Do not point it at unrestricted cluster DNS.

Authenticate and correlate each upstream request

In addition to the signed route inventory above, produce and sign a separate canonical gateway assertion contract whose fields are independent of the responder-manifest digest:

{
  "schema_version": "aidefend.dv006.gateway_assertion_contract.v1",
  "gateway_id": "public-ai-gateway-usw2",
  "gateway_assertion_public_key_sha256": "36c159d76b52e03e6b910faf145498748b298b8168cfd6bf7769c27aa4ce2ad8",
  "responder_image_digest": "sha256:3eb1bd439947eb762998e566ccc2e099b09d04c5c94641dd3f31452f3a923964",
  "routes": [{"hosts": ["ai-api.example.com"], "methods": ["GET"], "exact_paths": ["/internal/debug/env"], "upstream_service": "dv006-decoy-responder"}]
}

The exact raw-byte SHA-256 of this signed contract is the responder manifest's gateway_route_config_sha256. At deployment, verify both signed objects, require identical gateway, key, image, and route populations, and reject either object if the cross-readback differs. This one-way contract breaks any circular digest dependency while still binding the responder to the admitted gateway route.

Configure a release-pinned gateway extension at the final pre-proxy enforcement point. It removes all inbound X-AIDEFEND-Gateway-Assertion and X-AIDEFEND-Gateway-Signature values, enforces the signed request-body bound, creates a canonical UUIDv4 request ID and non-zero W3C trace ID, and signs canonical JSON containing schema_version, gateway_id, gateway_route_config_sha256, request_id, trace_id, method, SHA-256 values for the exact raw path, raw query, and body, body_bytes, issued_at, and expires_at. Send the canonical JSON and raw 64-byte Ed25519 signature as unpadded base64url in those two headers. Keep the signing key only in the gateway workload identity or an authenticated local signer; the responder receives only the fixed raw public key. If body buffering, signing, or header replacement fails, return a gateway error and never proxy. The ordinary Kong correlation header remains a join aid, not authentication.

Extend the independent probe to supply duplicate and forged assertion headers, mutate the path/query/body after signing, replay an expired envelope, and stop the signing extension. Confirm every case fails closed, accepted calls have one gateway request ID and trace ID in both gateway and append-only responder evidence, and no caller-supplied assertion survives effective-config readback or request capture.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-DV-006: Synthetic System-Information Decoy Routes | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version