AID-DV-006: Synthetic System-Information Decoy Routes
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.
- AID-DV-006-G002: Use an API gateway or proxy to intercept and spoof responses for high-risk reconnaissance paths.
AID-DV-006-G001
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
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: falseUse 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
- Kong Gateway OSS (Apache-2.0)
- Tyk Gateway Community Edition (MPL-2.0)
- NGINX Open Source (BSD-2-Clause)
- FastAPI
- Express.js
Commercial / hosted tools
- Acalvio ShadowPlex (decoy-service substrate; route behavior remains gateway-defined)
- Commvault Cloud Threatwise (decoy-service substrate; route behavior remains gateway-defined)
- Akamai EdgeWorkers
- Cloudflare Workers
Threat mappings
MITRE ATLAS
- AML.T0007 Discover AI Artifacts (synthetic reconnaissance routes expose probing without revealing production artifacts)
- AML.T0069 Discover LLM System Information (decoy routes return synthetic metadata and record the probe)
- AML.T0013 Discover AI Model Ontology (synthetic ontology details misdirect and expose reconnaissance)
- AML.T0014 Discover AI Model Family (synthetic model-family details misdirect and expose reconnaissance)
- AML.T0006 Active Scanning (honeypot endpoints attract and log scanning)
- AML.T0069.001 Discover LLM System Information: System Instruction Keywords (synthetic keyword metadata exposes targeted probing)
- AML.T0069.002 Discover LLM System Information: System Prompt (synthetic prompt details expose targeted probing)
- AML.T0084 Discover AI Agent Configuration (synthetic configuration metadata exposes agent reconnaissance)
- AML.T0084.000 Discover AI Agent Configuration: Embedded Knowledge (synthetic knowledge-source metadata exposes reconnaissance)
- AML.T0084.001 Discover AI Agent Configuration: Tool Definitions (synthetic tool definitions expose reconnaissance)
- AML.T0089 Enterprise Environment Discovery (decoy routes return synthetic topology and environment metadata to reconnaissance probes)
- AML.T0075 Enterprise Resource Discovery (synthetic topology and service metadata misdirect resource enumeration)
- AML.T0116 Autonomous Reconnaissance (decoy routes feed synthetic environment metadata to recursive autonomous probing)
- AML.T0006.002 Active Scanning: Scan for Exposed AI Infrastructure (synthetic system-information routes record service fingerprint probes without exposing production configuration)
MAESTRO
- N/A
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure (isolated decoy routes return only synthetic metadata while exposing disclosure probes)
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- N/A
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- AISubtech-8.3.2 System Information Exposure (synthetic system-information trap routes only)
- AITech-8.2 Data Exfiltration / Exposure (replaces policy-selected debug/env disclosure routes with synthetic responses and no production fallback)
- AITech-8.3 Information Disclosure (replaces policy-selected system-information routes with isolated synthetic responses)
Google Secure AI Framework 2.0 - Risks
- MRE: Model Reverse Engineering (synthetic model metadata misdirects and exposes reverse-engineering reconnaissance)
- SDD: Sensitive Data Disclosure (isolated decoy routes return synthetic metadata and cannot read production secrets or configuration)
Databricks AI Security Framework 3.0
- Model Serving - Inference requests 9.6: Discover ML model ontology (synthetic ontology routes expose reconnaissance attempts)
- Model Serving - Inference response 10.3: Discover ML model ontology (decoy responses return only synthetic ontology details)
- Model Serving - Inference response 10.4: Discover ML model family (decoy responses return only synthetic model-family details)