diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf29b72..4825706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,13 @@ jobs: - name: Compile the example against the installed SDK run: mvn -B -ntp -f examples/httpserver/pom.xml verify + + contract: + name: Shared contract files match the lock + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - name: Contract files match .shieldlabs-contract.lock + run: python3 scripts/sync_contract.py --check diff --git a/.github/workflows/contract-sync.yml b/.github/workflows/contract-sync.yml new file mode 100644 index 0000000..049919c --- /dev/null +++ b/.github/workflows/contract-sync.yml @@ -0,0 +1,175 @@ +name: Contract sync + +# Takes the shared API contract (test fixtures, see contract-sync.json) from the newest +# shieldlabs-openapi release, or from the release given by hand or by a contract-release +# dispatch. When files change, it runs the full test suite and opens a pull request with the +# result. Pull requests opened with GITHUB_TOKEN start no other workflow, so the tests run here. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + inputs: + ref: + description: "Contract release: 'latest' or a tag such as v1.0.1" + required: false + default: latest + type: string + repository_dispatch: + types: [contract-release] + +permissions: + contents: read + +concurrency: + group: contract-sync-${{ github.repository }} + cancel-in-progress: false + +jobs: + sync: + name: Sync the shared contract + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Sync the contract files + id: sync + env: + INPUT_REF: ${{ inputs.ref }} + DISPATCH_REF: ${{ github.event.client_payload.ref }} + # Only raises the GitHub API rate limit when resolving 'latest'. + GITHUB_TOKEN: ${{ github.token }} + run: | + ref="${DISPATCH_REF:-${INPUT_REF:-latest}}" + if ! [[ "$ref" =~ ^(latest|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Invalid contract ref: use latest or a tag such as v1.0.1." + exit 1 + fi + python3 scripts/sync_contract.py --ref "$ref" + resolved="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["ref"])')" + if ! [[ "$resolved" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Unexpected ref in .shieldlabs-contract.lock." + exit 1 + fi + mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))') + if [ -n "$(git status --porcelain -- "${files[@]}")" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + # Same bytes under a newer tag: keep the lock as it is, no pull request. + git checkout -- .shieldlabs-contract.lock + echo "Contract files are already those of $resolved." + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + echo "ref=$resolved" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + if: steps.sync.outputs.changed == 'true' + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Run the full test suite + id: tests + if: steps.sync.outputs.changed == 'true' + continue-on-error: true + shell: bash + run: | + { + mvn -B -ntp install + mvn -B -ntp -f examples/httpserver/pom.xml verify + } 2>&1 | tee "$RUNNER_TEMP/contract-tests.log" + + - name: Push the branch and open or update the pull request + if: steps.sync.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + CONTRACT_REF: ${{ steps.sync.outputs.ref }} + TESTS: ${{ steps.tests.outcome }} + run: | + set -euo pipefail + if ! [[ "$CONTRACT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Unexpected contract ref." + exit 1 + fi + export GH_REPO="$GITHUB_REPOSITORY" + base="$GITHUB_REF_NAME" + branch="contract-sync/$CONTRACT_REF" + label="contract-change-needs-code" + version="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["contract_version"])')" + mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))') + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add -- "${files[@]}" .shieldlabs-contract.lock + changes="$(git diff --cached --name-status)" + git commit -q -m "chore: sync the shared contract $CONTRACT_REF" + + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" --jq .object.sha 2>/dev/null || true)" + remote_tree="" + if [[ "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then + remote_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$remote_sha" --jq .tree.sha)" + fi + if [ "$remote_tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then + echo "$branch already holds these changes." + else + git push --force "https://x-access-token:$GH_TOKEN@${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$branch" + fi + + body="$RUNNER_TEMP/contract-pr.md" + { + echo "Syncs the shared API contract to [$CONTRACT_REF](https://github.com/ShieldLabs-ai/shieldlabs-openapi/releases/tag/$CONTRACT_REF) of shieldlabs-openapi (contract_version $version)." + echo + echo "Changed files:" + echo + echo '```' + echo "$changes" + echo '```' + echo + if [ "$TESTS" = "success" ]; then + echo "Tests: the full test suite passes with these files." + else + echo "Tests: **the full test suite fails with these files** ($TESTS). The SDK must change before it can take this contract; push the fixes to this branch." + echo + echo "
Last lines of the test log" + echo + echo '```' + tail -n 80 "$RUNNER_TEMP/contract-tests.log" 2>/dev/null | cut -c1-300 || true + echo '```' + echo + echo "
" + fi + echo + echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo + echo "Pull requests opened by this workflow start no other workflow. Close and reopen this pull request (or push to its branch) to run the usual checks." + } > "$body" + + number="$(gh pr list --head "$branch" --base "$base" --state open --json number --jq '.[0].number // empty')" + if [ -n "$number" ]; then + gh pr edit "$number" --body-file "$body" + elif ! gh pr create --base "$base" --head "$branch" --title "chore: sync the shared contract $CONTRACT_REF" --body-file "$body"; then + echo "::error::Could not open the pull request. Enable 'Allow GitHub Actions to create and approve pull requests' for this repository, or open it by hand: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/$base...$branch" + exit 1 + fi + + if [ "$TESTS" = "success" ]; then + gh pr edit "$branch" --remove-label "$label" >/dev/null 2>&1 || true + else + gh label create "$label" --force --color D93F0B --description "A contract sync whose tests fail: the SDK must change first" \ + || echo "::warning::Could not create the $label label." + gh pr edit "$branch" --add-label "$label" + fi + + - name: Fail when the new contract breaks the tests + if: steps.tests.outcome == 'failure' + run: | + echo "::error::The test suite fails with the new contract files. See the pull request labeled contract-change-needs-code." + exit 1 diff --git a/.shieldlabs-contract.lock b/.shieldlabs-contract.lock new file mode 100644 index 0000000..a4cda52 --- /dev/null +++ b/.shieldlabs-contract.lock @@ -0,0 +1,21 @@ +{ + "ref": "v1.0.1", + "contract_version": "1.0.1", + "files": { + "error-responses.json": "2be02da69063dcbbf40643559ac6eb6250ae22f5b41562f79810e97e9e3915e0", + "history-empty.json": "5616a1180d8234908daca7a08a05afa49a2d0ce06426cc97ebaf7971ba05b339", + "history-page.json": "50abc4a9622f800154043b2c67c0479f7e445d01ea46e882e973b217f32f7c9e", + "management-profile-expected.json": "5069ddc195bd0a11f5f53c8c964a6096bb54c116c0797fd3a6dd5d26149bc6f9", + "management-profile.json": "a02126112d37f0e5857f71d51cbfa201e876217001433af4f41ff27c31e488e6", + "normalization-cases.json": "66f1dae0293c26bf55370e713dc54a801b81d433e892022eede22ab592c5bcbe", + "risk-band-cases.json": "c9b168f5df1738faccffd69cea23bfdc8d299329ac2a129206ddf7cf9891c20c", + "signal-slug-cases.json": "8a79760a89503d48eb11c1e5e5d9b7dbe6756734092fc5a0a93f110f46228365", + "webhook-identification-scored.json": "be64dd698516acd46c773c62000bacf5e152ff54b7845c3b1819a563b948ffb2", + "webhook-identification-scored.raw.txt": "4cfa0fd3fe0fcfed1de1172d2b7d94c17246c0d6f27c2125909f03d05c71b029", + "webhook-ping.json": "90569a442e10c0af00393fcc455e5347d88b1e60624749056815b09ca267a471", + "webhook-ping.raw.txt": "5d0fde10a301d142970dc184d2a61cbb03ee58394481abb2e316065a390d4f77", + "webhook-rate-limited.json": "4985e08b2b306187d4ad3a78dc9e9d6ecf3bfcbd5fb781921bc02bb9f8a90ccd", + "webhook-signature-vectors.json": "33daee0c2823bb9b86b63a688b3065e9cfde3b8ecec14f9a988dfbdd5552fe89", + "webhook-test-delivery.json": "7678d32cf757ec604adb35b5b1dbe2a226530cec25266756ed96268f81c6d16d" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17bf990..0835be4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,11 @@ mvn -f examples/httpserver/pom.xml verify - Tests must not need network access: use `TestServer` (a local `com.sun.net.httpserver` server) and `FakeTimer` for anything that waits. - The files in `src/test/resources/fixtures` are shared test fixtures that every ShieldLabs server - SDK passes. Do not edit them by hand; open an issue if one looks wrong. + SDK passes. Do not edit them by hand; open an issue if one looks wrong. They are synced from + `contract/` in shieldlabs-openapi: `contract-sync.json` maps each file, + `.shieldlabs-contract.lock` records the release, CI runs + `python3 scripts/sync_contract.py --check`, and the `contract-sync.yml` workflow opens a pull + request when a new release changes them. - Documentation and comments use plain, technical English: "risk signals", the three risk bands (trusted 0-29, suspicious 30-59, dangerous 60-100), colons or parentheses instead of dashes. - Use conventional commit messages (`feat: ...`, `fix: ...`, `test: ...`, `docs: ...`, `ci: ...`) and diff --git a/contract-sync.json b/contract-sync.json new file mode 100644 index 0000000..b4c6b01 --- /dev/null +++ b/contract-sync.json @@ -0,0 +1,20 @@ +{ + "source": "shieldlabs-openapi", + "files": { + "error-responses.json": "src/test/resources/fixtures/error-responses.json", + "history-empty.json": "src/test/resources/fixtures/history-empty.json", + "history-page.json": "src/test/resources/fixtures/history-page.json", + "management-profile-expected.json": "src/test/resources/fixtures/management-profile-expected.json", + "management-profile.json": "src/test/resources/fixtures/management-profile.json", + "normalization-cases.json": "src/test/resources/fixtures/normalization-cases.json", + "risk-band-cases.json": "src/test/resources/fixtures/risk-band-cases.json", + "signal-slug-cases.json": "src/test/resources/fixtures/signal-slug-cases.json", + "webhook-identification-scored.json": "src/test/resources/fixtures/webhook-identification-scored.json", + "webhook-identification-scored.raw.txt": "src/test/resources/fixtures/webhook-identification-scored.raw.txt", + "webhook-ping.json": "src/test/resources/fixtures/webhook-ping.json", + "webhook-ping.raw.txt": "src/test/resources/fixtures/webhook-ping.raw.txt", + "webhook-rate-limited.json": "src/test/resources/fixtures/webhook-rate-limited.json", + "webhook-signature-vectors.json": "src/test/resources/fixtures/webhook-signature-vectors.json", + "webhook-test-delivery.json": "src/test/resources/fixtures/webhook-test-delivery.json" + } +} diff --git a/scripts/sync_contract.py b/scripts/sync_contract.py new file mode 100644 index 0000000..bf25d3a --- /dev/null +++ b/scripts/sync_contract.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Sync the shared ShieldLabs API contract files into an SDK repository. + +The contract lives in the public shieldlabs-openapi repository under contract/: shared test +fixtures plus contract/manifest.json, which lists the SHA-256 of every file. An SDK repository +says where each file goes in contract-sync.json: + + {"source": "shieldlabs-openapi", + "files": {"normalization-cases.json": "tests/data/normalization-cases.json"}} + +and records what it synced in .shieldlabs-contract.lock: + + {"ref": "v1.0.1", "contract_version": "1.0.1", + "files": {"normalization-cases.json": ""}} + +Usage, from the repository root (Python 3.9 or later, standard library only): + + python3 scripts/sync_contract.py sync the newest vX.Y.Z tag + python3 scripts/sync_contract.py --ref v1.0.1 sync one release + python3 scripts/sync_contract.py --check offline: committed files match the lock + +The canonical copy of this script is scripts/sync_contract.py in shieldlabs-openapi. SDK +repositories keep an identical copy, so --check works offline. +""" + +import argparse +import hashlib +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath +from typing import Callable, Optional, TextIO, Union + +DEFAULT_OWNER = "ShieldLabs-ai" +CONFIG_FILE = "contract-sync.json" +LOCK_FILE = ".shieldlabs-contract.lock" +MANIFEST_FILE = "manifest.json" +CONTRACT_DIR = "contract" +RAW_BASE = "https://raw.githubusercontent.com" +API_BASE = "https://api.github.com" +USER_AGENT = "shieldlabs-contract-sync" + +REF_PATTERN = re.compile(r"latest|v[0-9]+\.[0-9]+\.[0-9]+") +TAG_PATTERN = re.compile(r"v([0-9]+)\.([0-9]+)\.([0-9]+)") +NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +SOURCE_PATTERN = re.compile(r"(?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+") +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"') + +# (url, headers) -> (body, URL of the next page from the Link header, if any) +Fetch = Callable[[str, dict[str, str]], tuple[bytes, Optional[str]]] + + +class ContractError(Exception): + """A problem to fix: bad input, a missing file, a hash mismatch or drift.""" + + +class NotFoundError(ContractError): + """The requested file does not exist.""" + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def validate_ref(ref: str) -> str: + if not REF_PATTERN.fullmatch(ref): + raise ContractError(f"invalid ref {ref!r}: use 'latest' or a tag such as v1.2.3") + return ref + + +def validate_name(name: object) -> str: + if not isinstance(name, str) or not NAME_PATTERN.fullmatch(name) or name == MANIFEST_FILE: + raise ContractError(f"invalid contract file name {name!r}") + return name + + +def validate_digests(files: dict, where: str) -> dict[str, str]: + for name, digest in files.items(): + validate_name(name) + if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest): + raise ContractError(f"{where}: invalid SHA-256 for {name}") + return files + + +def destination(root: Path, value: object) -> Path: + if not isinstance(value, str) or not value or "\\" in value: + raise ContractError(f"invalid destination path {value!r}") + rel = PurePosixPath(value) + if rel.is_absolute() or ".." in rel.parts or not rel.parts: + raise ContractError(f"destination must be a relative path inside the repository: {value!r}") + return root.joinpath(*rel.parts) + + +def read_json(path: Path, what: str) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise ContractError(f"{what} not found: {path}") from None + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"{what} is not valid JSON: {path}: {exc}") from None + + +class Config: + """contract-sync.json: the source repository and where each contract file goes.""" + + def __init__(self, root: Path, owner: str, repo: str, files: dict[str, Path]) -> None: + self.root = root + self.owner = owner + self.repo = repo + self.files = files + + @classmethod + def load(cls, root: Path, path: Path) -> "Config": + data = read_json(path, CONFIG_FILE) + if not isinstance(data, dict): + raise ContractError(f"{path}: expected a JSON object") + source = data.get("source") + if not isinstance(source, str) or not SOURCE_PATTERN.fullmatch(source): + raise ContractError(f"{path}: 'source' must be a repository such as shieldlabs-openapi") + owner, _, repo = source.rpartition("/") + files = data.get("files") + if not isinstance(files, dict) or not files: + raise ContractError(f"{path}: 'files' must map contract file names to paths") + mapped: dict[str, Path] = {} + for name, value in files.items(): + target = destination(root, value) + if target in mapped.values(): + raise ContractError(f"{path}: two contract files map to {value}") + mapped[validate_name(name)] = target + return cls(root, owner or DEFAULT_OWNER, repo, mapped) + + def display(self, path: Path) -> str: + return path.relative_to(self.root).as_posix() + + +def parse_manifest(data: bytes) -> tuple[str, dict[str, str]]: + try: + manifest = json.loads(data.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"{MANIFEST_FILE} is not valid JSON: {exc}") from None + if not isinstance(manifest, dict): + raise ContractError(f"{MANIFEST_FILE}: expected a JSON object") + version, files = manifest.get("contract_version"), manifest.get("files") + if not isinstance(version, str) or not version or not isinstance(files, dict): + raise ContractError(f"{MANIFEST_FILE} needs 'contract_version' and 'files'") + return version, validate_digests(files, MANIFEST_FILE) + + +def load_lock(path: Path) -> tuple[str, str, dict[str, str]]: + data = read_json(path, LOCK_FILE) + if not isinstance(data, dict): + raise ContractError(f"{path}: expected a JSON object") + ref, version, files = data.get("ref"), data.get("contract_version"), data.get("files") + if not isinstance(ref, str) or not TAG_PATTERN.fullmatch(ref): + raise ContractError(f"{path}: 'ref' must be a tag such as v1.2.3") + if not isinstance(version, str) or not isinstance(files, dict): + raise ContractError(f"{path}: 'contract_version' and 'files' are required") + return ref, version, validate_digests(files, str(path)) + + +def render_lock(ref: str, version: str, files: dict[str, str]) -> str: + lock = {"ref": ref, "contract_version": version, "files": dict(sorted(files.items()))} + return json.dumps(lock, indent=2) + "\n" + + +def http_fetch(url: str, headers: dict[str, str]) -> tuple[bytes, Optional[str]]: + """GET with a timeout; network errors and 5xx answers are retried twice.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers}) + for attempt in range(3): + if attempt: + time.sleep(2**attempt) + try: + with urllib.request.urlopen(request, timeout=30) as response: + match = NEXT_LINK.search(response.headers.get("Link") or "") + return response.read(), match.group(1) if match else None + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise NotFoundError(f"not found: {url}") from None + if exc.code < 500 or attempt == 2: + raise ContractError(f"HTTP {exc.code} for {url}") from None + except (urllib.error.URLError, OSError) as exc: + if attempt == 2: + raise ContractError(f"cannot fetch {url}: {exc}") from None + raise ContractError(f"cannot fetch {url}") + + +def newest_tag(names: list[str]) -> Optional[str]: + versions = [] + for name in names: + match = TAG_PATTERN.fullmatch(name) + if match: + versions.append((tuple(int(part) for part in match.groups()), name)) + return max(versions)[1] if versions else None + + +def resolve_latest(owner: str, repo: str, fetch: Fetch = http_fetch) -> str: + """The newest vX.Y.Z tag of owner/repo from the public GitHub API. No token is needed; + GITHUB_TOKEN, when set, only raises the API rate limit.""" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + url: Optional[str] = f"{API_BASE}/repos/{owner}/{repo}/tags?per_page=100" + names: list[str] = [] + pages = 0 + while url and pages < 20: + body, url = fetch(url, headers) + pages += 1 + try: + page = json.loads(body.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"unexpected answer from the GitHub API: {exc}") from None + if not isinstance(page, list): + raise ContractError("unexpected answer from the GitHub API: expected a list of tags") + names.extend( + t["name"] for t in page if isinstance(t, dict) and isinstance(t.get("name"), str) + ) + tag = newest_tag(names) + if tag is None: + raise ContractError(f"{owner}/{repo} has no vX.Y.Z tag yet") + return tag + + +class RemoteSource: + """Contract files of one tag, downloaded from raw.githubusercontent.com.""" + + def __init__(self, owner: str, repo: str, ref: str, fetch: Fetch = http_fetch) -> None: + self.base = f"{RAW_BASE}/{owner}/{repo}/{ref}/{CONTRACT_DIR}" + self.ref = ref + self.fetch = fetch + + def read(self, name: str) -> bytes: + try: + return self.fetch(f"{self.base}/{name}", {})[0] + except NotFoundError: + if name == MANIFEST_FILE: + raise ContractError( + f"{self.ref} has no {CONTRACT_DIR}/{MANIFEST_FILE}: " + "that release predates the contract" + ) from None + raise + + +class LocalSource: + """Contract files from a local directory, such as contract/ in a checkout.""" + + def __init__(self, directory: Path) -> None: + self.directory = directory + + def read(self, name: str) -> bytes: + try: + return (self.directory / name).read_bytes() + except FileNotFoundError: + raise NotFoundError(f"not found: {self.directory / name}") from None + + +Source = Union[RemoteSource, LocalSource] + + +def write_atomic(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def sync(config: Config, source: Source, ref: str, lock: Path, out: TextIO) -> list[str]: + """Downloads and verifies every mapped file, then writes the files and the lock. + + Nothing is written unless every file matches the manifest. Returns the changed paths.""" + version, manifest = parse_manifest(source.read(MANIFEST_FILE)) + missing = sorted(set(config.files) - set(manifest)) + if missing: + raise ContractError(f"contract {ref} does not contain: {', '.join(missing)}") + payloads: dict[str, bytes] = {} + for name in sorted(config.files): + data = source.read(name) + digest = sha256_bytes(data) + if digest != manifest[name]: + raise ContractError( + f"{name} at {ref} does not match {MANIFEST_FILE} " + f"(expected {manifest[name][:12]}, got {digest[:12]}); nothing was written" + ) + payloads[name] = data + lock_bytes = render_lock(ref, version, {n: manifest[n] for n in payloads}).encode("utf-8") + changed = [] + for path, data in [*((config.files[n], d) for n, d in payloads.items()), (lock, lock_bytes)]: + if not path.is_file() or path.read_bytes() != data: + write_atomic(path, data) + changed.append(config.display(path)) + print(f"Contract {ref} (contract_version {version}): {len(payloads)} files", file=out) + for path in changed: + print(f" updated {path}", file=out) + if not changed: + print(" already up to date", file=out) + return changed + + +def check(config: Config, lock: Path, out: TextIO) -> list[str]: + """Offline: compares the committed files with the lock. Returns one message per problem.""" + ref, version, locked = load_lock(lock) + mapped, listed = set(config.files), set(locked) + problems = [f"{n}: in {CONFIG_FILE} but not in the lock" for n in sorted(mapped - listed)] + problems += [f"{n}: in the lock but not in {CONFIG_FILE}" for n in sorted(listed - mapped)] + for name in sorted(mapped & listed): + path = config.files[name] + if not path.is_file(): + problems.append(f"{config.display(path)}: missing") + continue + digest = sha256_bytes(path.read_bytes()) + if digest != locked[name]: + problems.append( + f"{config.display(path)}: SHA-256 {digest[:12]} differs from the lock " + f"({locked[name][:12]}, contract {ref})" + ) + if not problems: + print( + f"Contract files match the lock: {ref}, " + f"contract_version {version}, {len(locked)} files", + file=out, + ) + return problems + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Sync the shared ShieldLabs API contract files into this repository." + ) + parser.add_argument( + "--ref", default="latest", help="'latest' (default) or a tag such as v1.2.3" + ) + parser.add_argument( + "--check", action="store_true", help="offline: compare the files with the lock" + ) + parser.add_argument("--root", default=".", help="repository root (default: current directory)") + parser.add_argument("--config", default=CONFIG_FILE, help="mapping file, relative to --root") + parser.add_argument("--lock", default=LOCK_FILE, help="lock file, relative to --root") + parser.add_argument( + "--source-dir", help="read the contract from a local directory (needs a tag in --ref)" + ) + args = parser.parse_args(argv) + root = Path(args.root).resolve() + try: + config = Config.load(root, root / args.config) + lock = root / args.lock + if args.check: + problems = check(config, lock, sys.stdout) + if problems: + print(f"Contract drift: these files do not match {args.lock}:", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print( + "Contract files are not edited by hand. Change them in shieldlabs-openapi, or " + "restore them with: python3 scripts/sync_contract.py --ref ", + file=sys.stderr, + ) + return 1 + return 0 + ref = validate_ref(args.ref) + source: Source + if args.source_dir: + if ref == "latest": + raise ContractError("--source-dir needs a tag in --ref, such as v1.2.3") + source = LocalSource(Path(args.source_dir)) + else: + if ref == "latest": + ref = resolve_latest(config.owner, config.repo) + source = RemoteSource(config.owner, config.repo, ref) + sync(config, source, ref, lock, sys.stdout) + return 0 + except ContractError as exc: + print(f"contract-sync: error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/test/java/ai/shieldlabs/HistoryClientTest.java b/src/test/java/ai/shieldlabs/HistoryClientTest.java index 66b831c..e4e2326 100644 --- a/src/test/java/ai/shieldlabs/HistoryClientTest.java +++ b/src/test/java/ai/shieldlabs/HistoryClientTest.java @@ -24,17 +24,17 @@ void searchParsesTheFixturePage() { client.history() .search( LookupType.DEVICE_ID, - "AC7C303D-971B-41D1-8E25-CD5B46B46AED", + "D8E0F2A4-B6C8-4D0E-BF2A-4B6C8D0E2F4A", HistorySearchOptions.builder().limit(50).offset(10).build()); assertEquals(37, page.getTotal()); assertEquals(5, page.getIdentifications().size()); - assertEquals("02f1d973-84db-4156-a7f7-e799e6bf389b", page.getIdentifications().get(0).getRequestId()); + assertEquals("a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", page.getIdentifications().get(0).getRequestId()); assertEquals(Identification.Source.HISTORY, page.getIdentifications().get(0).getSource()); assertTrue(page.toString().contains("total=37")); TestServer.Recorded request = server.requests().get(0); assertEquals("GET", request.method); - assertEquals("/api/v1/history/device_id/ac7c303d-971b-41d1-8e25-cd5b46b46aed", request.rawPath); + assertEquals("/api/v1/history/device_id/d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", request.rawPath); assertEquals("limit=50&offset=10", request.rawQuery); assertEquals("Bearer " + Clients.API_KEY, request.header("Authorization")); assertEquals("application/json", request.header("Accept")); diff --git a/src/test/java/ai/shieldlabs/HistoryIteratorTest.java b/src/test/java/ai/shieldlabs/HistoryIteratorTest.java index 655fb5a..e25e58c 100644 --- a/src/test/java/ai/shieldlabs/HistoryIteratorTest.java +++ b/src/test/java/ai/shieldlabs/HistoryIteratorTest.java @@ -18,7 +18,7 @@ class HistoryIteratorTest { private static final String B = "bbbbbbbb-0000-4000-8000-000000000002"; private static final String C = "cccccccc-0000-4000-8000-000000000003"; private static final String D = "dddddddd-0000-4000-8000-000000000004"; - private static final String DEVICE = "ac7c303d-971b-41d1-8e25-cd5b46b46aed"; + private static final String DEVICE = "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a"; private static List ids(Stream stream) { return stream.map(Identification::getRequestId).collect(Collectors.toList()); diff --git a/src/test/java/ai/shieldlabs/JsonMappingTest.java b/src/test/java/ai/shieldlabs/JsonMappingTest.java index 7a88a69..ef7ca2a 100644 --- a/src/test/java/ai/shieldlabs/JsonMappingTest.java +++ b/src/test/java/ai/shieldlabs/JsonMappingTest.java @@ -12,7 +12,7 @@ class JsonMappingTest { @Test void identificationSerializesWithWebhookNamesInOrder() { Identification id = - Identification.fromHistoryRow(Fixtures.map(Fixtures.normalizationCase("history_02f1d973").get("input"))); + Identification.fromHistoryRow(Fixtures.map(Fixtures.normalizationCase("history_a5b7c9d1").get("input"))); Map json = Fixtures.serialized(id); assertEquals( List.of( diff --git a/src/test/java/ai/shieldlabs/NormalizerEdgeCasesTest.java b/src/test/java/ai/shieldlabs/NormalizerEdgeCasesTest.java index c853564..50c7168 100644 --- a/src/test/java/ai/shieldlabs/NormalizerEdgeCasesTest.java +++ b/src/test/java/ai/shieldlabs/NormalizerEdgeCasesTest.java @@ -216,7 +216,7 @@ void valueSemantics() { assertEquals(a.getTrafficSource().hashCode(), b.getTrafficSource().hashCode()); assertEquals(a.getDetectionFlags(), b.getDetectionFlags()); assertEquals(a.getDetectionFlags().hashCode(), b.getDetectionFlags().hashCode()); - assertTrue(a.toString().contains("02f1d973-84db-4156-a7f7-e799e6bf389b")); + assertTrue(a.toString().contains("a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d")); assertTrue(a.toString().contains("band=dangerous")); assertTrue(a.getSignals().get(0).toString().contains("proxy")); assertTrue(a.getPublicIp().toString().contains("Netherlands")); @@ -225,7 +225,7 @@ void valueSemantics() { assertEquals("203.0.113.9", new IpInfo("203.0.113.9", "").toString()); Identification history = - Identification.fromHistoryRow(Fixtures.map(Fixtures.normalizationCase("history_02f1d973").get("input"))); + Identification.fromHistoryRow(Fixtures.map(Fixtures.normalizationCase("history_a5b7c9d1").get("input"))); assertNotEquals(a, history, "different sources are different identifications"); assertNotEquals(a, null); assertNotEquals(a, "x"); diff --git a/src/test/java/ai/shieldlabs/WebhookEventsTest.java b/src/test/java/ai/shieldlabs/WebhookEventsTest.java index f577cf2..73a0f9a 100644 --- a/src/test/java/ai/shieldlabs/WebhookEventsTest.java +++ b/src/test/java/ai/shieldlabs/WebhookEventsTest.java @@ -166,7 +166,7 @@ void eventsSerializeWithWireNames() { assertEquals("2026-06-01", json.get("schema_version")); assertEquals("2026-09-30T12:34:57.482913041Z", json.get("created_at")); Map data = new LinkedHashMap<>(Fixtures.map(json.get("data"))); - assertEquals("02f1d973-84db-4156-a7f7-e799e6bf389b", data.get("request_id")); + assertEquals("a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", data.get("request_id")); assertEquals(80, data.get("risk_score")); } } diff --git a/src/test/resources/fixtures/history-page.json b/src/test/resources/fixtures/history-page.json index 047866b..6b8c00c 100644 --- a/src/test/resources/fixtures/history-page.json +++ b/src/test/resources/fixtures/history-page.json @@ -1,14 +1,14 @@ { "data": [ { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "domain": "shop.example.com", "site_domain": "example.com", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", "ip": "203.0.113.24", "os": "Windows", "browser": "Chrome", diff --git a/src/test/resources/fixtures/normalization-cases.json b/src/test/resources/fixtures/normalization-cases.json index 0ccaff3..0ffe1ac 100644 --- a/src/test/resources/fixtures/normalization-cases.json +++ b/src/test/resources/fixtures/normalization-cases.json @@ -2,17 +2,17 @@ "description": "History API rows and webhook data objects with the Identification every SDK must produce. Compare observed_at at millisecond precision (truncate, never round). Keys not listed in expected (for example raw) are free.", "cases": [ { - "name": "history_02f1d973", + "name": "history_a5b7c9d1", "source": "history", "input": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "domain": "shop.example.com", "site_domain": "example.com", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", "ip": "203.0.113.24", "os": "Windows", "browser": "Chrome", @@ -65,11 +65,11 @@ "is_suspicious_paid_click": true }, "expected": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { @@ -632,11 +632,11 @@ "name": "webhook_scored", "source": "webhook", "input": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { @@ -701,11 +701,11 @@ "observed_at": "2026-09-30T12:34:57.482913041Z" }, "expected": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { diff --git a/src/test/resources/fixtures/webhook-identification-scored.json b/src/test/resources/fixtures/webhook-identification-scored.json index f2de2d2..7978a58 100644 --- a/src/test/resources/fixtures/webhook-identification-scored.json +++ b/src/test/resources/fixtures/webhook-identification-scored.json @@ -3,11 +3,11 @@ "schema_version": "2026-06-01", "created_at": "2026-09-30T12:34:57.482913041Z", "data": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", - "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { diff --git a/src/test/resources/fixtures/webhook-identification-scored.raw.txt b/src/test/resources/fixtures/webhook-identification-scored.raw.txt index a4da089..4657d9f 100644 --- a/src/test/resources/fixtures/webhook-identification-scored.raw.txt +++ b/src/test/resources/fixtures/webhook-identification-scored.raw.txt @@ -1 +1 @@ -{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"02f1d973-84db-4156-a7f7-e799e6bf389b","visitor_id":"bde0e249-20d8-4544-838c-ed9a0b6d7a36","device_id":"ac7c303d-971b-41d1-8e25-cd5b46b46aed","session_id":"bde78778-efd2-4c49-952f-1f11b9c05f35","cookie_id":"4449bb58-590c-444c-ae1f-d1ddc768dbdd","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google\u0026utm_medium=cpc\u0026gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"privacy_relay":false,"browser_vpn_proxy":false,"tor":false,"proxy":true,"datacenter_ip":true,"abuser":false,"os_mismatch":false,"os_not_detected":false,"timezone_mismatch":false,"anti_detect_browser":true,"browser_automation":false,"ip_mismatch":true,"incognito":false,"search_bot":false,"suspicious_paid_click":true,"javascript_disabled":false,"stun_not_checked":false,"check_incomplete":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file +{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d","visitor_id":"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b","device_id":"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a","session_id":"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e","cookie_id":"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google\u0026utm_medium=cpc\u0026gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"privacy_relay":false,"browser_vpn_proxy":false,"tor":false,"proxy":true,"datacenter_ip":true,"abuser":false,"os_mismatch":false,"os_not_detected":false,"timezone_mismatch":false,"anti_detect_browser":true,"browser_automation":false,"ip_mismatch":true,"incognito":false,"search_bot":false,"suspicious_paid_click":true,"javascript_disabled":false,"stun_not_checked":false,"check_incomplete":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file diff --git a/src/test/resources/fixtures/webhook-signature-vectors.json b/src/test/resources/fixtures/webhook-signature-vectors.json index 7529cf0..2085113 100644 --- a/src/test/resources/fixtures/webhook-signature-vectors.json +++ b/src/test/resources/fixtures/webhook-signature-vectors.json @@ -149,20 +149,20 @@ { "name": "valid_scored_with_escaped_ampersand", "secret": "whsec_00112233445566778899aabbccddeeff", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", - "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062", "valid": true, "note": "Server bodies escape & as \\u0026; verify the bytes as received", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" }, { "name": "invalid_scored_unescaped_reserialization", "secret": "whsec_00112233445566778899aabbccddeeff", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", - "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062", "valid": false, "note": "Re-serializing (unescaping \\u0026) changes the bytes", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" }, { "name": "valid_rotation_second_secret_matches",