From 73d14241c06b18d02c51cd51ccb10da244120e56 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:29:54 +0300 Subject: [PATCH 1/4] feat(webhooks): support current final-risk contract --- CHANGELOG.md | 2 + README.md | 24 +- examples/fastapi_app.py | 9 +- generated/.openapi-generator/FILES | 4 + generated/shieldlabs_generated/__init__.py | 8 + .../shieldlabs_generated/models/__init__.py | 4 + .../models/detection_flags.py | 16 +- .../models/fingerprint.py | 101 +++++ generated/shieldlabs_generated/models/hre.py | 105 +++++ .../shieldlabs_generated/models/hre_result.py | 103 +++++ .../models/identification_scored_data.py | 34 +- .../models/identification_scored_event.py | 21 +- .../shieldlabs_generated/models/risk_event.py | 97 +++++ .../models/webhook_ping_event.py | 10 +- resources/shieldlabs-api.yaml | 403 ++++++++++++++++-- src/shieldlabs/__init__.py | 2 + src/shieldlabs/_models.py | 93 +++- src/shieldlabs/webhooks.py | 28 +- tests/contracts/current.json | 243 +++++++++++ tests/test_contract_current.py | 25 ++ tests/test_package.py | 2 +- tests/test_webhooks.py | 2 +- 22 files changed, 1260 insertions(+), 76 deletions(-) create mode 100644 generated/shieldlabs_generated/models/fingerprint.py create mode 100644 generated/shieldlabs_generated/models/hre.py create mode 100644 generated/shieldlabs_generated/models/hre_result.py create mode 100644 generated/shieldlabs_generated/models/risk_event.py create mode 100644 tests/contracts/current.json create mode 100644 tests/test_contract_current.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d6427d3..f473fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project are documented in this file. The format foll ### Added +- Accept webhook contract `2026-10-06` with signed event IDs, final result/scoring versions, the complete risk-event catalogue, three HRE results and distinct fp21 hardware identity. Legacy webhook bodies remain supported. + - `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds `generated/` from it. The supported client is unchanged. ## [1.0.0] - 2026-09-30 diff --git a/README.md b/README.md index 0be23b7..0df0849 100644 --- a/README.md +++ b/README.md @@ -299,12 +299,10 @@ def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int: - Verify the raw bytes exactly as received, before parsing. Re-serialized JSON does not match. - `secret` can be a list: a delivery is valid when any secret matches, so you can rotate an endpoint secret without downtime. -- ShieldLabs sends one delivery per identification and endpoint, with a 1-second timeout and no - retries. Respond with a 2xx within 1 second and do slow work afterwards. -- Make handlers idempotent on `data.request_id`: a future release retries deliveries, and a +- Each endpoint has independent delivery state, a 1-second send timeout and bounded retries. Persist before 2xx and process asynchronously. +- Make handlers idempotent on `data.request_id`: current retries reuse event_id, and a retry resends identical bytes. -- Use the History API for guaranteed reads and for the latest state: a delivery that fails is - not sent again, and a History row can be refined after its webhook was sent. +- Use the History API for guaranteed reads and for the latest state: retry exhaustion requires recovery, and a History row can be refined after its webhook was sent. - `construct_event` returns `IdentificationScoredEvent`, `WebhookPingEvent` or `UnknownWebhookEvent`, and never raises for an unknown event type. The **Test** delivery sent from the analytics dashboard parses like production traffic. @@ -470,3 +468,19 @@ error responses) that every ShieldLabs server SDK passes. See ## License [MIT](LICENSE) + + +### Webhook contract 2026-10-06 + +Current events include signed `event_id`, optional `site_id`, the complete `data.risk_events` +catalogue (including zero-weight events), `data.fingerprint` (FP21 hardware ID distinct from +`device_id`), and `data.hre` for sharing/takeover/travel with explicit statuses. Older envelopes +remain supported. Only identification risk score is sent; AI bots/browser are planned only, +and all-time entity risks are excluded. + +Persist the verified event in a durable inbox **before** returning 2xx and deduplicate by +`event_id` (legacy scored bodies: `data.request_id`). Timeout/network/429/5xx retry with +backoff in a bounded window (8 failed sends / 15-minute retry age), then DLQ. Other 4xx are +terminal. Retried bodies and event IDs stay unchanged. `X-Shield-Event-Id` mirrors the body ID; +trust the signed body. Signature verification remains raw-body HMAC-SHA256. Delivery is not +exactly-once and later History corrections do not automatically create a new webhook event. diff --git a/examples/fastapi_app.py b/examples/fastapi_app.py index 3ba6cc2..962884e 100644 --- a/examples/fastapi_app.py +++ b/examples/fastapi_app.py @@ -122,11 +122,12 @@ async def shieldlabs_webhook(request: Request) -> Response: if isinstance(event, IdentificationScoredEvent): data = event.data - if data.request_id in processed_request_ids: + delivery_id = event.event_id or data.request_id + if delivery_id in processed_request_ids: return Response(status_code=200) # already handled: acknowledge and stop - processed_request_ids.add(data.request_id) - # Answer fast (under 1 s): a slow or failed delivery is not sent again, so queue slow - # work instead of doing it here, and read the History API when you must not miss one. + processed_request_ids.add(delivery_id) + # Demo dedup is in memory only. Production must durably persist event_id and + # the raw body before 2xx; processing may then run asynchronously. logger.info( "identification.scored request_id=%s risk_score=%s band=%s flags=%s", data.request_id, diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index 4a175b3..34d3b18 100644 --- a/generated/.openapi-generator/FILES +++ b/generated/.openapi-generator/FILES @@ -11,13 +11,17 @@ shieldlabs_generated/models/__init__.py shieldlabs_generated/models/detection_flags.py shieldlabs_generated/models/domain_profile.py shieldlabs_generated/models/error_body.py +shieldlabs_generated/models/fingerprint.py shieldlabs_generated/models/health_status.py shieldlabs_generated/models/history_page.py shieldlabs_generated/models/history_row.py +shieldlabs_generated/models/hre.py +shieldlabs_generated/models/hre_result.py shieldlabs_generated/models/identification_scored_data.py shieldlabs_generated/models/identification_scored_event.py shieldlabs_generated/models/ip_info.py shieldlabs_generated/models/legacy_snapshot.py +shieldlabs_generated/models/risk_event.py shieldlabs_generated/models/score_detail.py shieldlabs_generated/models/signal.py shieldlabs_generated/models/traffic_source.py diff --git a/generated/shieldlabs_generated/__init__.py b/generated/shieldlabs_generated/__init__.py index 37cc875..456aa65 100644 --- a/generated/shieldlabs_generated/__init__.py +++ b/generated/shieldlabs_generated/__init__.py @@ -34,6 +34,9 @@ "DetectionFlags", "DomainProfile", "ErrorBody", + "Fingerprint", + "HRE", + "HREResult", "HealthStatus", "HistoryPage", "HistoryRow", @@ -41,6 +44,7 @@ "IdentificationScoredEvent", "IpInfo", "LegacySnapshot", + "RiskEvent", "ScoreDetail", "Signal", "TrafficSource", @@ -67,6 +71,9 @@ from shieldlabs_generated.models.detection_flags import DetectionFlags as DetectionFlags from shieldlabs_generated.models.domain_profile import DomainProfile as DomainProfile from shieldlabs_generated.models.error_body import ErrorBody as ErrorBody +from shieldlabs_generated.models.fingerprint import Fingerprint as Fingerprint +from shieldlabs_generated.models.hre import HRE as HRE +from shieldlabs_generated.models.hre_result import HREResult as HREResult from shieldlabs_generated.models.health_status import HealthStatus as HealthStatus from shieldlabs_generated.models.history_page import HistoryPage as HistoryPage from shieldlabs_generated.models.history_row import HistoryRow as HistoryRow @@ -74,6 +81,7 @@ from shieldlabs_generated.models.identification_scored_event import IdentificationScoredEvent as IdentificationScoredEvent from shieldlabs_generated.models.ip_info import IpInfo as IpInfo from shieldlabs_generated.models.legacy_snapshot import LegacySnapshot as LegacySnapshot +from shieldlabs_generated.models.risk_event import RiskEvent as RiskEvent from shieldlabs_generated.models.score_detail import ScoreDetail as ScoreDetail from shieldlabs_generated.models.signal import Signal as Signal from shieldlabs_generated.models.traffic_source import TrafficSource as TrafficSource diff --git a/generated/shieldlabs_generated/models/__init__.py b/generated/shieldlabs_generated/models/__init__.py index 9560d31..2441e82 100644 --- a/generated/shieldlabs_generated/models/__init__.py +++ b/generated/shieldlabs_generated/models/__init__.py @@ -17,6 +17,9 @@ from shieldlabs_generated.models.detection_flags import DetectionFlags from shieldlabs_generated.models.domain_profile import DomainProfile from shieldlabs_generated.models.error_body import ErrorBody +from shieldlabs_generated.models.fingerprint import Fingerprint +from shieldlabs_generated.models.hre import HRE +from shieldlabs_generated.models.hre_result import HREResult from shieldlabs_generated.models.health_status import HealthStatus from shieldlabs_generated.models.history_page import HistoryPage from shieldlabs_generated.models.history_row import HistoryRow @@ -24,6 +27,7 @@ from shieldlabs_generated.models.identification_scored_event import IdentificationScoredEvent from shieldlabs_generated.models.ip_info import IpInfo from shieldlabs_generated.models.legacy_snapshot import LegacySnapshot +from shieldlabs_generated.models.risk_event import RiskEvent from shieldlabs_generated.models.score_detail import ScoreDetail from shieldlabs_generated.models.signal import Signal from shieldlabs_generated.models.traffic_source import TrafficSource diff --git a/generated/shieldlabs_generated/models/detection_flags.py b/generated/shieldlabs_generated/models/detection_flags.py index 775aeb7..7b1a1d4 100644 --- a/generated/shieldlabs_generated/models/detection_flags.py +++ b/generated/shieldlabs_generated/models/detection_flags.py @@ -19,14 +19,14 @@ import json from pydantic import BaseModel, ConfigDict, Field, StrictBool -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self from pydantic_core import to_jsonable_python class DetectionFlags(BaseModel): """ - Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and `javascript_disabled` are always `false`. + Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and `javascript_disabled` are always `false`. """ # noqa: E501 vpn: StrictBool = Field(description="A VPN was detected (scored `vpn` signal).") privacy_relay: StrictBool = Field(description="A privacy relay such as iCloud Private Relay was detected.") @@ -47,7 +47,11 @@ class DetectionFlags(BaseModel): javascript_disabled: StrictBool = Field(description="JavaScript, or the browser APIs the checks need, were unavailable.") stun_not_checked: StrictBool = Field(description="The browser network (STUN) check did not complete. Cleared again when a late network result arrives.") check_incomplete: StrictBool = Field(description="Part of the browser checks timed out, so the verdict rests on partial data. Informational.") - __properties: ClassVar[List[str]] = ["vpn", "privacy_relay", "browser_vpn_proxy", "tor", "proxy", "datacenter_ip", "abuser", "os_mismatch", "os_not_detected", "timezone_mismatch", "anti_detect_browser", "browser_automation", "ip_mismatch", "incognito", "search_bot", "suspicious_paid_click", "javascript_disabled", "stun_not_checked", "check_incomplete"] + os_mismatch2: Optional[StrictBool] = None + device_spoofing: Optional[StrictBool] = None + latency_test: Optional[StrictBool] = None + banned_ip: Optional[StrictBool] = None + __properties: ClassVar[List[str]] = ["vpn", "privacy_relay", "browser_vpn_proxy", "tor", "proxy", "datacenter_ip", "abuser", "os_mismatch", "os_not_detected", "timezone_mismatch", "anti_detect_browser", "browser_automation", "ip_mismatch", "incognito", "search_bot", "suspicious_paid_click", "javascript_disabled", "stun_not_checked", "check_incomplete", "os_mismatch2", "device_spoofing", "latency_test", "banned_ip"] model_config = ConfigDict( validate_by_name=True, @@ -118,7 +122,11 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "suspicious_paid_click": obj.get("suspicious_paid_click"), "javascript_disabled": obj.get("javascript_disabled"), "stun_not_checked": obj.get("stun_not_checked"), - "check_incomplete": obj.get("check_incomplete") + "check_incomplete": obj.get("check_incomplete"), + "os_mismatch2": obj.get("os_mismatch2"), + "device_spoofing": obj.get("device_spoofing"), + "latency_test": obj.get("latency_test"), + "banned_ip": obj.get("banned_ip") }) return _obj diff --git a/generated/shieldlabs_generated/models/fingerprint.py b/generated/shieldlabs_generated/models/fingerprint.py new file mode 100644 index 0000000..b765df6 --- /dev/null +++ b/generated/shieldlabs_generated/models/fingerprint.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class Fingerprint(BaseModel): + """ + FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. + """ # noqa: E501 + outcome: StrictStr + record_id: Optional[StrictStr] = None + hardware_id: Optional[StrictStr] = Field(default=None, description="FP21 hardware identity, distinct from device_id.") + rules_version: StrictStr + sharing: Optional[Dict[str, Any]] = None + takeover: Optional[Dict[str, Any]] = None + travel: Optional[Dict[str, Any]] = None + __properties: ClassVar[List[str]] = ["outcome", "record_id", "hardware_id", "rules_version", "sharing", "takeover", "travel"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of Fingerprint from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of Fingerprint from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "outcome": obj.get("outcome"), + "record_id": obj.get("record_id"), + "hardware_id": obj.get("hardware_id"), + "rules_version": obj.get("rules_version"), + "sharing": obj.get("sharing"), + "takeover": obj.get("takeover"), + "travel": obj.get("travel") + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/hre.py b/generated/shieldlabs_generated/models/hre.py new file mode 100644 index 0000000..541ffa2 --- /dev/null +++ b/generated/shieldlabs_generated/models/hre.py @@ -0,0 +1,105 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from shieldlabs_generated.models.hre_result import HREResult +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class HRE(BaseModel): + """ + Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate. + """ # noqa: E501 + rules_version: Optional[StrictStr] = None + account_sharing: HREResult + account_takeover: HREResult + impossible_travel: HREResult + __properties: ClassVar[List[str]] = ["rules_version", "account_sharing", "account_takeover", "impossible_travel"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of HRE from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of account_sharing + if self.account_sharing: + _dict['account_sharing'] = self.account_sharing.to_dict() + # override the default output from pydantic by calling `to_dict()` of account_takeover + if self.account_takeover: + _dict['account_takeover'] = self.account_takeover.to_dict() + # override the default output from pydantic by calling `to_dict()` of impossible_travel + if self.impossible_travel: + _dict['impossible_travel'] = self.impossible_travel.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of HRE from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "rules_version": obj.get("rules_version"), + "account_sharing": HREResult.from_dict(obj["account_sharing"]) if obj.get("account_sharing") is not None else None, + "account_takeover": HREResult.from_dict(obj["account_takeover"]) if obj.get("account_takeover") is not None else None, + "impossible_travel": HREResult.from_dict(obj["impossible_travel"]) if obj.get("impossible_travel") is not None else None + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/hre_result.py b/generated/shieldlabs_generated/models/hre_result.py new file mode 100644 index 0000000..3f37f92 --- /dev/null +++ b/generated/shieldlabs_generated/models/hre_result.py @@ -0,0 +1,103 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class HREResult(BaseModel): + """ + HREResult + """ # noqa: E501 + status: StrictStr + level: Optional[StrictStr] + reason: StrictStr + devices: Optional[Annotated[int, Field(strict=True, ge=0)]] = None + min_devices: Optional[Annotated[int, Field(strict=True, ge=1)]] = None + __properties: ClassVar[List[str]] = ["status", "level", "reason", "devices", "min_devices"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of HREResult from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if level (nullable) is None + # and model_fields_set contains the field + if self.level is None and "level" in self.model_fields_set: + _dict['level'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of HREResult from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "status": obj.get("status"), + "level": obj.get("level"), + "reason": obj.get("reason"), + "devices": obj.get("devices"), + "min_devices": obj.get("min_devices") + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/identification_scored_data.py b/generated/shieldlabs_generated/models/identification_scored_data.py index bb78dd9..d20b64a 100644 --- a/generated/shieldlabs_generated/models/identification_scored_data.py +++ b/generated/shieldlabs_generated/models/identification_scored_data.py @@ -24,7 +24,10 @@ from typing_extensions import Annotated from uuid import UUID from shieldlabs_generated.models.detection_flags import DetectionFlags +from shieldlabs_generated.models.fingerprint import Fingerprint +from shieldlabs_generated.models.hre import HRE from shieldlabs_generated.models.ip_info import IpInfo +from shieldlabs_generated.models.risk_event import RiskEvent from shieldlabs_generated.models.signal import Signal from shieldlabs_generated.models.traffic_source import TrafficSource from typing import Optional, Set @@ -33,7 +36,7 @@ class IdentificationScoredData(BaseModel): """ - The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. + Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted. """ # noqa: E501 request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") visitor_id: UUID = Field(description="Server-side visitor identifier (UUID v5). It is sticky to the device: a new cookie on a known device keeps the existing visitor ID, so clearing cookies usually does not change it. The nil UUID appears on identifications without usable device data, such as rate-limit marker rows.") @@ -52,8 +55,13 @@ class IdentificationScoredData(BaseModel): risk_score: Annotated[int, Field(strict=True, ge=0)] = Field(description="Risk Score from 0 (no risk found) to 100. Search-engine crawlers always score 0. Risk bands are computed on your side from the score; no band field exists on the wire: - trusted: 0-29 - suspicious: 30-59 - dangerous: 60-100 A value above 100 is not a score. `999` is the rate-limit marker: the visitor's IP went over the ingest rate limit, and the identification carries exactly one signal, `{\"name\":\"rate_limited\",\"weight\":999}`, usually with nil identifiers. Treat every value above 100 as rate limited. One marker is written when the IP goes over the limit; request IDs issued while it stays blocked get no row and no webhook, so they stay unverified. The score usually equals the sum of the signal weights capped at 100, but carried-forward verdicts and corrections make that unreliable: never recompute or validate it yourself.") signals: List[Signal] = Field(description="Weighted risk signals behind `risk_score`, in scoring order. Can be empty. The rate-limit marker carries exactly one entry, `{\"name\":\"rate_limited\",\"weight\":999}`.") detection_flags: DetectionFlags - observed_at: datetime = Field(description="When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits.") - __properties: ClassVar[List[str]] = ["request_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at"] + observed_at: datetime = Field(description="Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits.") + result_version: Optional[Annotated[str, Field(min_length=1, strict=True)]] = None + scoring_version: Optional[StrictStr] = Field(default=None, description="Core build source revision; core:unversioned on local builds.") + risk_events: Optional[List[RiskEvent]] = None + hre: Optional[HRE] = None + fingerprint: Optional[Fingerprint] = None + __properties: ClassVar[List[str]] = ["request_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at", "result_version", "scoring_version", "risk_events", "hre", "fingerprint"] @field_validator('observed_at') def observed_at_validate_regular_expression(cls, value): @@ -123,6 +131,19 @@ def to_dict(self) -> Dict[str, Any]: # override the default output from pydantic by calling `to_dict()` of detection_flags if self.detection_flags: _dict['detection_flags'] = self.detection_flags.to_dict() + # override the default output from pydantic by calling `to_dict()` of each item in risk_events (list) + _items = [] + if self.risk_events: + for _item_risk_events in self.risk_events: + if _item_risk_events: + _items.append(_item_risk_events.to_dict()) + _dict['risk_events'] = _items + # override the default output from pydantic by calling `to_dict()` of hre + if self.hre: + _dict['hre'] = self.hre.to_dict() + # override the default output from pydantic by calling `to_dict()` of fingerprint + if self.fingerprint: + _dict['fingerprint'] = self.fingerprint.to_dict() # set to None if user_hid (nullable) is None # and model_fields_set contains the field if self.user_hid is None and "user_hid" in self.model_fields_set: @@ -157,7 +178,12 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "risk_score": obj.get("risk_score"), "signals": [Signal.from_dict(_item) for _item in obj["signals"]] if obj.get("signals") is not None else None, "detection_flags": DetectionFlags.from_dict(obj["detection_flags"]) if obj.get("detection_flags") is not None else None, - "observed_at": obj.get("observed_at") + "observed_at": obj.get("observed_at"), + "result_version": obj.get("result_version"), + "scoring_version": obj.get("scoring_version"), + "risk_events": [RiskEvent.from_dict(_item) for _item in obj["risk_events"]] if obj.get("risk_events") is not None else None, + "hre": HRE.from_dict(obj["hre"]) if obj.get("hre") is not None else None, + "fingerprint": Fingerprint.from_dict(obj["fingerprint"]) if obj.get("fingerprint") is not None else None }) return _obj diff --git a/generated/shieldlabs_generated/models/identification_scored_event.py b/generated/shieldlabs_generated/models/identification_scored_event.py index e6177a7..be42015 100644 --- a/generated/shieldlabs_generated/models/identification_scored_event.py +++ b/generated/shieldlabs_generated/models/identification_scored_event.py @@ -20,7 +20,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from shieldlabs_generated.models.identification_scored_data import IdentificationScoredData from typing import Optional, Set @@ -32,17 +32,12 @@ class IdentificationScoredEvent(BaseModel): Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header. """ # noqa: E501 event_type: StrictStr = Field(description="Event type. Ignore events whose type you do not know instead of failing.") - schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.") - created_at: datetime = Field(description="When the event was built. Equal to `data.observed_at`.") + schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.") + created_at: datetime = Field(description="RFC 3339 timestamp in UTC with up to 9 fractional digits (trailing zeros trimmed), for example `2026-09-30T12:34:57.482913041Z`. Parse it with a parser that accepts nanoseconds.") data: IdentificationScoredData - __properties: ClassVar[List[str]] = ["event_type", "schema_version", "created_at", "data"] - - @field_validator('event_type') - def event_type_validate_enum(cls, value): - """Validates the enum""" - if value not in set(['identification.scored']): - raise ValueError("must be one of enum values ('identification.scored')") - return value + event_id: Optional[Annotated[str, Field(min_length=1, strict=True)]] = Field(default=None, description="Logical site/request/final-result-version/type identity. Stable across retries and endpoints.") + site_id: Optional[Annotated[int, Field(strict=True, ge=1)]] = Field(default=None, description="Site scope when available; legacy domain-only accounts omit it.") + __properties: ClassVar[List[str]] = ["event_type", "schema_version", "created_at", "data", "event_id", "site_id"] @field_validator('created_at') def created_at_validate_regular_expression(cls, value): @@ -111,7 +106,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "event_type": obj.get("event_type"), "schema_version": obj.get("schema_version"), "created_at": obj.get("created_at"), - "data": IdentificationScoredData.from_dict(obj["data"]) if obj.get("data") is not None else None + "data": IdentificationScoredData.from_dict(obj["data"]) if obj.get("data") is not None else None, + "event_id": obj.get("event_id"), + "site_id": obj.get("site_id") }) return _obj diff --git a/generated/shieldlabs_generated/models/risk_event.py b/generated/shieldlabs_generated/models/risk_event.py new file mode 100644 index 0000000..cd432f9 --- /dev/null +++ b/generated/shieldlabs_generated/models/risk_event.py @@ -0,0 +1,97 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class RiskEvent(BaseModel): + """ + RiskEvent + """ # noqa: E501 + code: StrictStr + detected: StrictBool = Field(description="Final scoring flag. false does not assert that every underlying probe completed.") + weight: StrictInt = Field(description="Catalogue weight, not an additive score. Banned IP 999 is a marker.") + contribution: StrictInt = Field(description="Matching score details, may contain corrections. Never recompute risk_score by summing.") + status: StrictStr = Field(description="The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events.") + __properties: ClassVar[List[str]] = ["code", "detected", "weight", "contribution", "status"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of RiskEvent from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of RiskEvent from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "code": obj.get("code"), + "detected": obj.get("detected"), + "weight": obj.get("weight"), + "contribution": obj.get("contribution"), + "status": obj.get("status") + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/webhook_ping_event.py b/generated/shieldlabs_generated/models/webhook_ping_event.py index 16e60b9..45f432e 100644 --- a/generated/shieldlabs_generated/models/webhook_ping_event.py +++ b/generated/shieldlabs_generated/models/webhook_ping_event.py @@ -20,7 +20,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from typing import Optional, Set from typing_extensions import Self @@ -31,9 +31,10 @@ class WebhookPingEvent(BaseModel): Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys arrive sorted alphabetically and `created_at` has second precision. """ # noqa: E501 event_type: StrictStr = Field(description="Event type.") - schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.") + schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.") created_at: datetime = Field(description="When the ping was sent, with second precision.") - __properties: ClassVar[List[str]] = ["event_type", "schema_version", "created_at"] + event_id: Optional[Annotated[str, Field(min_length=1, strict=True)]] = None + __properties: ClassVar[List[str]] = ["event_type", "schema_version", "created_at", "event_id"] @field_validator('event_type') def event_type_validate_enum(cls, value): @@ -105,7 +106,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "event_type": obj.get("event_type"), "schema_version": obj.get("schema_version"), - "created_at": obj.get("created_at") + "created_at": obj.get("created_at"), + "event_id": obj.get("event_id") }) return _obj diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index 2838959..b26b53a 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/shieldlabs-api.yaml @@ -467,38 +467,15 @@ webhooks: - Webhooks summary: Identification scored description: |- - Sent to every enabled webhook endpoint of your domain once for each identification, when its - scoring is final: usually about 300 ms after the browser check, and at most about 10 seconds - later when follow-up network checks run. + Sent after final snapshot scoring and mandatory fp21 + three HRE handlers. Risk score is the identification score only. AI bots and AI browser are not implemented and not emitted. - **Verify, then parse.** Compute HMAC-SHA256 over the raw request body and compare it with - `X-Shield-Signature` before you parse the JSON: - - key: the endpoint's signing secret as UTF-8 bytes, including the `whsec_` prefix (not hex- - or base64-decoded, not stripped); - - message: the exact bytes received; re-serializing parsed JSON changes them (for example, `&` - arrives escaped as `\u0026`); - - expected header: `sha256=` followed by the lowercase hex digest, compared in constant time. + Verify HMAC-SHA256 over exact raw bytes with the endpoint secret. Body event_id is signed; X-Shield-Event-Id is its convenience mirror. Persist and deduplicate by event_id before returning 2xx within the 1-second send timeout. Legacy bodies without event_id can use data.request_id. Retries preserve body and ID. Network/timeouts/429/5xx are retried with backoff up to 8 failed sends or 15 minutes of retry age; exhaustion/permanent 4xx goes to DLQ. Delivery is at-least-once attempts within that window, not guaranteed eternal delivery or exactly-once. Endpoints are independent. Redirects are not followed. - There is no timestamp, delivery ID or event-type header. Rotating a secret replaces it at - once, so accept both the old and the new secret until your deployment has switched. - - **Respond fast.** Answer any 2xx status within 1 second and process the event asynchronously; - do not redirect. Today each identification is delivered once per endpoint, with no retries. A - later release adds retries that resend identical bytes, so make your handler idempotent on - `data.request_id`. - - **Latest state.** The event is a snapshot taken when scoring finished. The History row can - still be refined afterwards (its `ver` increases) and no second event is sent. Use the History - API for guaranteed reads and for the latest state. - - **Test deliveries.** The Test button in the analytics dashboard sends a fixed sample with keys - sorted alphabetically, second-precision timestamps and two-letter country values. Its - `detection_flags` lack `browser_automation` and `search_bot`: parse missing flags as `false`. - - Deliveries are free and do not use your included identifications. + Use History for recovery/latest state; later snapshot corrections do not automatically emit another identification.scored event. security: [] parameters: - $ref: '#/components/parameters/ShieldSignature' + - $ref: '#/components/parameters/ShieldEventId' requestBody: required: true description: The event as compact JSON. Verify the signature over these exact bytes. @@ -513,11 +490,203 @@ webhooks: $ref: '#/components/examples/IdentificationScoredRateLimited' testDelivery: $ref: '#/components/examples/IdentificationScoredTestDelivery' + current: + summary: Current contract with fp21 and HRE + value: + event_id: ce256c0f66536957adc5f719250f5b10ce331bd1 + site_id: 7 + event_type: identification.scored + schema_version: '2026-10-06' + created_at: '2026-10-06T00:00:02Z' + data: + result_version: '2026-10-06T00:00:01Z' + scoring_version: core:fixture + risk_events: + - code: vpn + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: proxy + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: privacy_relay + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: check_incomplete + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: network_check_incomplete + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: os_not_detected + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: timezone_mismatch + detected: false + weight: 10 + contribution: 0 + status: evaluated + - code: javascript_disabled + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: os_mismatch + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: os_mismatch2 + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: ip_mismatch + detected: false + weight: 45 + contribution: 0 + status: evaluated + - code: latency_test + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: anti_detect_browser + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: device_spoofing + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: banned_ip + detected: false + weight: 999 + contribution: 0 + status: evaluated + - code: incognito + detected: true + weight: 0 + contribution: 0 + status: evaluated + - code: search_bot + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: browser_automation + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: tor + detected: false + weight: 99 + contribution: 0 + status: evaluated + hre: + rules_version: fp21-fixture + account_sharing: + status: evaluated + level: high + reason: '' + devices: 4 + min_devices: 4 + account_takeover: + status: evaluated + level: medium + reason: fixture + impossible_travel: + status: evaluated + level: null + reason: below_distance + request_id: 13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11 + visitor_id: 161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d + device_id: 5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f + session_id: 7a1b2c3d-4e5f-4789-abcd-ef0123456789 + cookie_id: 3f2e1d0c-9b8a-4654-b210-fedcba987654 + user_hid: test-user + domain: example.com + public_ip: + ip: 203.0.113.1 + country: '' + local_ip: + ip: '' + country: '' + connection_type: direct + os: Windows + browser: Chrome + device_type: desktop + traffic_source: + channel: '' + referrer_domain: '' + landing_url: '' + click_id_type: '' + utm_source: '' + utm_medium: '' + utm_campaign: '' + utm_content: '' + utm_term: '' + risk_score: 0 + signals: [] + detection_flags: + os_mismatch2: false + device_spoofing: false + latency_test: false + banned_ip: false + vpn: false + privacy_relay: false + browser_vpn_proxy: false + tor: false + proxy: false + datacenter_ip: false + abuser: false + os_mismatch: false + os_not_detected: false + timezone_mismatch: false + anti_detect_browser: false + browser_automation: false + ip_mismatch: false + incognito: true + search_bot: false + suspicious_paid_click: false + javascript_disabled: false + stun_not_checked: false + check_incomplete: false + observed_at: '2026-10-06T00:00:00Z' + fingerprint: + outcome: new_record + record_id: sample-record + hardware_id: sample-hardware + rules_version: fp21-fixture + sharing: + level: high + devices: 4 + min_devices: 4 + takeover: + level: medium + reason: fixture + travel: + level: '' + reason: below_distance responses: 2XX: description: Delivery accepted. The response body is ignored. 4XX: - description: Delivery rejected, for example with `401` when the signature does not verify. Any status other than 2xx, and a timeout after 1 second, counts as a failed delivery; failed deliveries are not retried today. + description: Permanent 4xx are terminal; 429 is retried. 2xx acknowledges durable acceptance. webhook.ping: post: operationId: webhookPing @@ -541,6 +710,7 @@ webhooks: security: [] parameters: - $ref: '#/components/parameters/ShieldSignature' + - $ref: '#/components/parameters/ShieldEventId' requestBody: required: true description: The ping as compact JSON with sorted keys. @@ -751,6 +921,17 @@ components: webhookPing: summary: Signature of the webhook.ping example value: sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8 + ShieldEventId: + name: X-Shield-Event-Id + in: header + required: false + description: Convenience mirror of signed body event_id. Trust the body after HMAC verification. Absent on older bodies. Retries reuse the logical ID across attempts; it is not a separate delivery-attempt identity. + schema: + type: string + examples: + event: + summary: Logical event identity + value: 79e23184cd0b5aae43125e3b14910f13b107a54b schemas: RequestId: type: string @@ -1418,8 +1599,9 @@ components: SchemaVersion: type: string minLength: 1 - description: Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler. + description: Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01. examples: + - '2026-10-06' - '2026-06-01' Rfc3339Timestamp: type: string @@ -1570,7 +1752,7 @@ components: DetectionFlags: type: object description: |- - Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on + Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and @@ -1653,9 +1835,109 @@ components: check_incomplete: type: boolean description: Part of the browser checks timed out, so the verdict rests on partial data. Informational. + os_mismatch2: + type: boolean + device_spoofing: + type: boolean + latency_test: + type: boolean + banned_ip: + type: boolean + RiskEvent: + type: object + required: + - code + - detected + - weight + - contribution + - status + properties: + code: + type: string + detected: + type: boolean + description: Final scoring flag. false does not assert that every underlying probe completed. + weight: + type: integer + description: Catalogue weight, not an additive score. Banned IP 999 is a marker. + contribution: + type: integer + description: Matching score details, may contain corrections. Never recompute risk_score by summing. + status: + type: string + description: The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events. + examples: + - evaluated + HREResult: + type: object + required: + - status + - level + - reason + properties: + status: + type: string + examples: + - evaluated + - not_evaluated + - not_applicable + level: + type: + - string + - 'null' + examples: + - medium + - high + - null + reason: + type: string + devices: + type: integer + minimum: 0 + min_devices: + type: integer + minimum: 1 + HRE: + type: object + required: + - account_sharing + - account_takeover + - impossible_travel + properties: + rules_version: + type: string + account_sharing: + $ref: '#/components/schemas/HREResult' + account_takeover: + $ref: '#/components/schemas/HREResult' + impossible_travel: + $ref: '#/components/schemas/HREResult' + description: Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate. + Fingerprint: + type: object + required: + - outcome + - rules_version + properties: + outcome: + type: string + record_id: + type: string + hardware_id: + type: string + description: FP21 hardware identity, distinct from device_id. + rules_version: + type: string + sharing: + type: object + takeover: + type: object + travel: + type: object + description: FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. IdentificationScoredData: type: object - description: The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. + description: Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted. required: - request_id - visitor_id @@ -1720,7 +2002,21 @@ components: $ref: '#/components/schemas/DetectionFlags' observed_at: $ref: '#/components/schemas/Rfc3339Timestamp' - description: When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits. + description: Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits. + result_version: + type: string + minLength: 1 + scoring_version: + type: string + description: Core build source revision; core:unversioned on local builds. + risk_events: + type: array + items: + $ref: '#/components/schemas/RiskEvent' + hre: + $ref: '#/components/schemas/HRE' + fingerprint: + $ref: '#/components/schemas/Fingerprint' IdentificationScoredEvent: type: object description: 'Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header.' @@ -1738,9 +2034,47 @@ components: $ref: '#/components/schemas/SchemaVersion' created_at: $ref: '#/components/schemas/Rfc3339Timestamp' - description: When the event was built. Equal to `data.observed_at`. + description: When the event envelope was created, distinct from data.observed_at (scan time). data: $ref: '#/components/schemas/IdentificationScoredData' + event_id: + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. + site_id: + type: integer + minimum: 1 + description: Site scope when available; legacy domain-only accounts omit it. + allOf: + - if: + properties: + schema_version: + const: '2026-10-06' + required: + - schema_version + then: + required: + - event_id + properties: + data: + required: + - result_version + - scoring_version + - risk_events + - hre + type: object + additionalProperties: true + properties: + result_version: {} + scoring_version: {} + risk_events: {} + hre: {} + event_id: + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. + type: object + additionalProperties: true WebhookPingEvent: type: object description: Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys arrive sorted alphabetically and `created_at` has second precision. @@ -1758,6 +2092,9 @@ components: created_at: $ref: '#/components/schemas/Rfc3339Timestamp' description: When the ping was sent, with second precision. + event_id: + type: string + minLength: 1 examples: HistoryPage: summary: Five identifications diff --git a/src/shieldlabs/__init__.py b/src/shieldlabs/__init__.py index bf7876f..2d25b76 100644 --- a/src/shieldlabs/__init__.py +++ b/src/shieldlabs/__init__.py @@ -39,6 +39,7 @@ Identification, IdentificationSource, IpInfo, + RiskEvent, Signal, SignalName, TrafficSource, @@ -76,6 +77,7 @@ "QuotaExceededError", "RateLimitError", "RiskBand", + "RiskEvent", "ServerError", "ShieldLabs", "ShieldLabsError", diff --git a/src/shieldlabs/_models.py b/src/shieldlabs/_models.py index 74172d2..81b7de7 100644 --- a/src/shieldlabs/_models.py +++ b/src/shieldlabs/_models.py @@ -32,6 +32,7 @@ "Identification", "IdentificationSource", "IpInfo", + "RiskEvent", "Signal", "SignalName", "TrafficSource", @@ -174,21 +175,69 @@ class DetectionFlags: javascript_disabled: bool = False stun_not_checked: bool = False check_incomplete: bool = False + os_mismatch2: Optional[bool] = None + device_spoofing: Optional[bool] = None + latency_test: Optional[bool] = None + banned_ip: Optional[bool] = None @classmethod def from_dict(cls, data: Mapping[str, Any]) -> DetectionFlags: """Build from a webhook ``detection_flags`` object. A missing key is ``False``.""" data = _mapping(data) - return cls(**{key: bool(data.get(key, False)) for key in FLAG_KEYS}) + return cls( + **{key: bool(data.get(key, False)) for key in FLAG_KEYS}, + **{ + key: data[key] + for key in ("os_mismatch2", "device_spoofing", "latency_test", "banned_ip") + if isinstance(data.get(key), bool) + }, + ) def to_dict(self) -> dict[str, bool]: - return {key: getattr(self, key) for key in FLAG_KEYS} + return { + **{key: getattr(self, key) for key in FLAG_KEYS}, + **{ + key: getattr(self, key) + for key in ("os_mismatch2", "device_spoofing", "latency_test", "banned_ip") + if getattr(self, key) is not None + }, + } def active(self) -> tuple[str, ...]: """Names of the flags that are set, in wire order.""" return tuple(key for key in FLAG_KEYS if getattr(self, key)) +@dataclass(frozen=True) +class RiskEvent: + """Final scoring flag. Weight is catalog metadata; never sum it for Risk Score.""" + + code: str + detected: bool + weight: int + contribution: int + status: str + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> RiskEvent: + return cls( + code=as_str(data.get("code")), + detected=data.get("detected") is True, + weight=as_int(data.get("weight")), + contribution=as_int(data.get("contribution")), + status=as_str(data.get("status")), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "detected": self.detected, + "weight": self.weight, + "contribution": self.contribution, + "status": self.status, + } + + @dataclass(frozen=True) class Identification: """One identification (one run of the browser agent), normalized. @@ -218,6 +267,11 @@ class Identification: """When the identification was observed, as an aware UTC datetime. ``None`` only when the server value could not be parsed.""" source: IdentificationSource + result_version: Optional[str] = None + scoring_version: Optional[str] = None + risk_events: Optional[tuple[RiskEvent, ...]] = None + hre: Optional[Mapping[str, Any]] = None + fingerprint: Optional[Mapping[str, Any]] = None raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False) """The original webhook ``data`` object or History row, including fields the model omits.""" @@ -267,6 +321,17 @@ def from_webhook_data(cls, data: Mapping[str, Any]) -> Identification: detection_flags=DetectionFlags.from_dict(flags), observed_at=parse_rfc3339(data.get("observed_at")), source="webhook", + result_version=as_str(data["result_version"]) if "result_version" in data else None, + scoring_version=as_str(data["scoring_version"]) if "scoring_version" in data else None, + risk_events=tuple( + RiskEvent.from_dict(r) for r in data["risk_events"] if isinstance(r, Mapping) + ) + if isinstance(data.get("risk_events"), list) + else None, + hre=dict(data["hre"]) if isinstance(data.get("hre"), Mapping) else None, + fingerprint=dict(data["fingerprint"]) + if isinstance(data.get("fingerprint"), Mapping) + else None, raw=dict(data), ) @@ -388,6 +453,17 @@ def from_dict(cls, data: Mapping[str, Any]) -> Identification: if isinstance(observed_at, datetime) else parse_rfc3339(observed_at), source="history" if source == "history" else "webhook", + result_version=as_str(data["result_version"]) if "result_version" in data else None, + scoring_version=as_str(data["scoring_version"]) if "scoring_version" in data else None, + risk_events=tuple( + RiskEvent.from_dict(r) for r in data["risk_events"] if isinstance(r, Mapping) + ) + if isinstance(data.get("risk_events"), list) + else None, + hre=dict(data["hre"]) if isinstance(data.get("hre"), Mapping) else None, + fingerprint=dict(data["fingerprint"]) + if isinstance(data.get("fingerprint"), Mapping) + else None, raw=dict(data), ) @@ -413,6 +489,19 @@ def to_dict(self) -> dict[str, Any]: "detection_flags": self.detection_flags.to_dict(), "observed_at": format_timestamp(self.observed_at), "source": self.source, + **({"result_version": self.result_version} if self.result_version is not None else {}), + **( + {"scoring_version": self.scoring_version} + if self.scoring_version is not None + else {} + ), + **( + {"risk_events": [r.to_dict() for r in self.risk_events]} + if self.risk_events is not None + else {} + ), + **({"hre": dict(self.hre)} if self.hre is not None else {}), + **({"fingerprint": dict(self.fingerprint)} if self.fingerprint is not None else {}), } diff --git a/src/shieldlabs/webhooks.py b/src/shieldlabs/webhooks.py index cb6d2a8..c46d846 100644 --- a/src/shieldlabs/webhooks.py +++ b/src/shieldlabs/webhooks.py @@ -5,10 +5,10 @@ string exactly as shown in the analytics dashboard, ``whsec_`` prefix included, and the message is the raw request body. Always verify the raw bytes you received, before parsing them. -ShieldLabs sends one delivery per identification and endpoint, with a 1-second timeout and no -retries. Respond with a 2xx within 1 second and do slow work afterwards. Keep handlers -idempotent on ``data.request_id``: a future release retries deliveries, and a retry resends -identical bytes. Use the History API for guaranteed reads and for the latest state. +Version 2026-10-06 carries a signed event_id in the body and X-Shield-Event-Id header. +Failed deliveries are retried within a bounded window. Deduplicate by event_id, +store the event durably before returning 2xx, then process asynchronously. For old +bodies without event_id, fall back to data.request_id. History provides recovery/latest state. Example:: @@ -49,7 +49,7 @@ SIGNATURE_HEADER = "X-Shield-Signature" """Name of the header that carries the signature.""" -SCHEMA_VERSION = "2026-06-01" +SCHEMA_VERSION = "2026-10-06" """Webhook ``schema_version`` this SDK was built for. Other values are parsed with a warning.""" _SIGNATURE_PREFIX = "sha256=" @@ -76,6 +76,8 @@ class IdentificationScoredEvent: created_at: Optional[datetime] data: Identification raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False) + event_id: Optional[str] = None + site_id: Optional[int] = None @dataclass(frozen=True) @@ -86,6 +88,8 @@ class WebhookPingEvent: schema_version: str created_at: Optional[datetime] raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False) + event_id: Optional[str] = None + site_id: Optional[int] = None @dataclass(frozen=True) @@ -101,6 +105,8 @@ class UnknownWebhookEvent: created_at: Optional[datetime] data: Optional[Mapping[str, Any]] = None raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False) + event_id: Optional[str] = None + site_id: Optional[int] = None WebhookEvent = Union[IdentificationScoredEvent, WebhookPingEvent, UnknownWebhookEvent] @@ -205,7 +211,7 @@ def _parse_event(body: bytes) -> WebhookEvent: schema_version = envelope.get("schema_version") if not isinstance(schema_version, str): schema_version = "" - if schema_version != SCHEMA_VERSION: + if schema_version not in (SCHEMA_VERSION, "2026-06-01"): warnings.warn( f"Webhook schema_version {schema_version!r} is not {SCHEMA_VERSION!r}; " "parsing it anyway. Upgrade the shieldlabs package to get the latest fields.", @@ -223,6 +229,10 @@ def _parse_event(body: bytes) -> WebhookEvent: created_at=created_at, data=Identification.from_webhook_data(data), raw=envelope, + event_id=envelope.get("event_id") + if isinstance(envelope.get("event_id"), str) + else None, + site_id=envelope.get("site_id") if isinstance(envelope.get("site_id"), int) else None, ) if event_type == "webhook.ping": return WebhookPingEvent( @@ -230,6 +240,10 @@ def _parse_event(body: bytes) -> WebhookEvent: schema_version=schema_version, created_at=created_at, raw=envelope, + event_id=envelope.get("event_id") + if isinstance(envelope.get("event_id"), str) + else None, + site_id=envelope.get("site_id") if isinstance(envelope.get("site_id"), int) else None, ) return UnknownWebhookEvent( event_type=event_type, @@ -237,4 +251,6 @@ def _parse_event(body: bytes) -> WebhookEvent: created_at=created_at, data=data if isinstance(data, dict) else None, raw=envelope, + event_id=envelope.get("event_id") if isinstance(envelope.get("event_id"), str) else None, + site_id=envelope.get("site_id") if isinstance(envelope.get("site_id"), int) else None, ) diff --git a/tests/contracts/current.json b/tests/contracts/current.json new file mode 100644 index 0000000..4c6dcd6 --- /dev/null +++ b/tests/contracts/current.json @@ -0,0 +1,243 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": null, + "reason": "", + "devices": 1, + "min_devices": 4 + }, + "account_takeover": { + "status": "not_evaluated", + "level": null, + "reason": "no_history" + }, + "impossible_travel": { + "status": "not_evaluated", + "level": null, + "reason": "skipped" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "", + "devices": 1, + "min_devices": 4 + }, + "takeover": { + "level": "", + "reason": "no_history" + }, + "travel": { + "level": "", + "reason": "skipped" + } + } + } +} diff --git a/tests/test_contract_current.py b/tests/test_contract_current.py new file mode 100644 index 0000000..a62efe8 --- /dev/null +++ b/tests/test_contract_current.py @@ -0,0 +1,25 @@ +import hashlib +import hmac +from pathlib import Path + +from shieldlabs import webhooks + + +def test_current_core_fixture_preserves_identity_and_results(): + raw = (Path(__file__).parent / "contracts/current.json").read_bytes() + signature = "sha256=" + hmac.new(b"secret", raw, hashlib.sha256).hexdigest() + event = webhooks.construct_event(raw, signature, "secret") + assert event.event_id + assert event.site_id == 7 + assert isinstance(event, webhooks.IdentificationScoredEvent) + assert event.data.risk_events is not None + assert len(event.data.risk_events) == 19 + assert event.data.fingerprint is not None + assert event.data.fingerprint["hardware_id"] == "sample-hardware" + assert event.data.device_id != event.data.fingerprint["hardware_id"] + assert event.data.hre is not None + assert event.data.hre["account_takeover"]["reason"] == "no_history" + encoded = event.data.to_dict() + assert encoded["risk_events"][15]["weight"] == 0 + assert event.data.from_dict(encoded).to_dict() == encoded + assert not webhooks.verify_signature(raw + b" ", signature, "secret") diff --git a/tests/test_package.py b/tests/test_package.py index 114e282..961f6b3 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -87,5 +87,5 @@ def test_version_and_typing_marker() -> None: def test_webhooks_module_surface() -> None: assert shieldlabs.webhooks.SIGNATURE_HEADER == "X-Shield-Signature" - assert shieldlabs.webhooks.SCHEMA_VERSION == "2026-06-01" + assert shieldlabs.webhooks.SCHEMA_VERSION == "2026-10-06" assert shieldlabs.webhooks.IdentificationScoredEvent is shieldlabs.IdentificationScoredEvent diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 1406e85..61242a4 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -96,7 +96,7 @@ def test_scored_event_from_raw_bytes_matches_normalization() -> None: event = webhooks.construct_event(body, sign(SECRET, body), SECRET) assert isinstance(event, IdentificationScoredEvent) assert event.event_type == "identification.scored" - assert event.schema_version == webhooks.SCHEMA_VERSION + assert event.schema_version == "2026-06-01" assert event.created_at == datetime(2026, 9, 30, 12, 34, 57, 482913, tzinfo=timezone.utc) assert event.data.to_dict() == NORMALIZATION["webhook_scored"]["expected"] assert event.data.traffic_source.landing_url.endswith("utm_medium=cpc&gclid=abc123") From 11daeb27350377e83ab2a92b624ebaad8f879d79 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:49:14 +0300 Subject: [PATCH 2/4] docs: align API introduction with current delivery guarantees --- resources/shieldlabs-api.yaml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index b26b53a..03edf61 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/shieldlabs-api.yaml @@ -131,19 +131,25 @@ info: ## Webhooks - ShieldLabs sends one signed `POST` for each identification to every enabled endpoint of the - domain. A delivery has a 1-second timeout and is not retried today; a later release adds - retries that resend identical bytes. Answer 2xx within a second, process the event - asynchronously, make the handler idempotent on `data.request_id`, and use the History API for - guaranteed reads. A History row can be refined after its webhook was sent (its `ver` - increases); the webhook is not sent again. See the `identification.scored` and `webhook.ping` - entries for the signature algorithm. + ShieldLabs creates one logical signed event per identification and enabled endpoint. + Contract `2026-10-06` includes the identification score, final risk-event catalogue, + fp21 and three completed on-demand HRE results. AI bots/browser are planned only; + multiaccount is separate and all-time entity risks are not exported. + Delivery attempts have a 1-second HTTP timeout. Network errors, timeouts, 429 and 5xx + retry with backoff, up to 8 failed sends or a 15-minute retry age; other 4xx are terminal. + Worker/Redis waits can extend wall time. Retries preserve the exact signed body and + `event_id`; duplicate deliveries remain possible after an accepted request loses its response. + Verify the signature, durably store the event using its signed `event_id` (legacy fallback: + `data.request_id`), then answer 2xx within a second and process asynchronously. + Redirects are not followed. A later History refinement does not create a new webhook event. + Use the History API for the latest stored result. See the `identification.scored` and + `webhook.ping` entries for the signature algorithm. ## Compatibility Ignore fields you do not know, keep unknown values of string fields (such as new signal names or channels) instead of failing, and accept webhook `schema_version` values other than - `2026-06-01`. + `2026-10-06` (current) or `2026-06-01` (legacy). Start free at https://app.shieldlabs.ai. Guides: https://docs.shieldlabs.ai. contact: From c833e817e8a8380051214e2194728f4a49aa2ad5 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:47:50 +0300 Subject: [PATCH 3/4] fix(webhooks): parse simplified payload and preserve bot owners (python) --- resources/shieldlabs-api.yaml | 645 ++++++++++++++++++++++++++++++--- src/shieldlabs/_models.py | 48 ++- src/shieldlabs/webhooks.py | 4 +- tests/contracts/ai-bot.json | 91 +++++ tests/contracts/current.json | 164 +-------- tests/test_contract_current.py | 21 +- tests/test_package.py | 2 +- 7 files changed, 754 insertions(+), 221 deletions(-) create mode 100644 tests/contracts/ai-bot.json diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index 03edf61..9cbe011 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/shieldlabs-api.yaml @@ -466,6 +466,53 @@ paths: source: | curl "https://account.shieldlabs.ai/health" webhooks: + multi-account-changed: + post: + operationId: multiaccountChanged + tags: + - Webhooks + summary: A multi-account group changed + description: |- + Opt-in notification when a complete background detector publication detects, updates, or + resolves an exact group of accounts. Enable `multiaccount: true` on the webhook endpoint. + The default is false; existing identification.scored subscriptions are preserved. + + A repeated build with unchanged membership, level, and rules does not produce a new event. + A membership change produces resolved for the previous exact group and detected for the new + exact group. Resolved means the group is no longer supported by the current complete source + cut, including evidence expiry; it does not certify that its accounts are safe. + + Delivery is at least once, with no global ordering guarantee. Save event_id atomically with + your business operation. Within a site, publication epoch, and cluster_id, compare revision + as an integer and ignore older transitions. Verify HMAC-SHA256 over the raw received bytes; + X-Shield-Event-Id is a convenience header, and the signed body is authoritative. + + This event has no request_id or risk_score. Its source identifies the published group result; + it does not mutate an earlier identification.scored body or an already pinned Console view. + security: [] + parameters: + - $ref: '#/components/parameters/ShieldSignature' + - $ref: '#/components/parameters/ShieldEventId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MultiaccountChangedEvent' + examples: + detected: + $ref: '#/components/examples/MultiaccountDetected' + updated: + $ref: '#/components/examples/MultiaccountUpdated' + resolved: + $ref: '#/components/examples/MultiaccountResolved' + responses: + 2XX: + description: Durably accepted by the receiver. The response body is ignored. + 4XX: + description: Terminal delivery failure, except 429 which is retried. + 5XX: + description: Retried within the delivery attempt and age limits. identification.scored: post: operationId: identificationScored @@ -673,21 +720,6 @@ webhooks: stun_not_checked: false check_incomplete: false observed_at: '2026-10-06T00:00:00Z' - fingerprint: - outcome: new_record - record_id: sample-record - hardware_id: sample-hardware - rules_version: fp21-fixture - sharing: - level: high - devices: 4 - min_devices: 4 - takeover: - level: medium - reason: fixture - travel: - level: '' - reason: below_distance responses: 2XX: description: Delivery accepted. The response body is ignored. @@ -939,6 +971,144 @@ components: summary: Logical event identity value: 79e23184cd0b5aae43125e3b14910f13b107a54b schemas: + ClientIdentity: + type: object + additionalProperties: true + required: + - schema_version + - classification_revision + - classifier_version + - registry_revision + - availability + - observed_at + - decided_at + - claims + - verified + - assessments + - evidence + properties: + schema_version: + type: string + description: Open version identifier. Version 1 is currently emitted. + classification_revision: + type: integer + minimum: 1 + classifier_version: + type: string + registry_revision: + type: string + availability: + type: string + description: available or input_unavailable. Missing object means no stored identity data; it does not mean human. + observed_at: + type: string + format: date-time + decided_at: + type: string + format: date-time + claims: + type: array + items: + type: object + additionalProperties: true + required: + - profile_id + - provider_id + - provider_name + - agent_name + - client_kind + - purpose + - source + properties: + profile_id: + type: string + provider_id: + type: string + provider_name: + type: string + agent_name: + type: string + client_kind: + type: string + purpose: + type: string + source: + type: string + verified: + type: array + items: + type: object + additionalProperties: true + required: + - subject + - value_id + - evidence_ids + properties: + subject: + type: string + description: 'The proved attribute: provider, service or infrastructure. Proof does not extend to other attributes.' + value_id: + type: string + evidence_ids: + type: array + items: + type: string + assessments: + type: array + items: + type: object + additionalProperties: true + required: + - status + - reason + properties: + candidate_profile_id: + type: string + status: + type: string + reason: + type: string + evidence: + type: array + items: + type: object + additionalProperties: true + required: + - id + - method + - source_id + - source_revision + - checked_at + - evaluated_at + - covered_attributes + properties: + id: + type: string + method: + type: string + source_id: + type: string + source_revision: + type: string + checked_at: + type: string + format: date-time + evaluated_at: + type: string + format: date-time + covered_attributes: + type: array + items: + type: string + covered_components: + type: array + items: + type: string + request_binding: + type: string + replay_policy: + type: string + description: Stored ingest attribution, independent of risk score and browser automation. Claimed names are unverified. A verified provider does not verify the agent name, service or AI mode. Read proof scope from each attribution and its referenced evidence. Unknown string values and fields must be accepted. RequestId: type: string format: uuid @@ -1222,6 +1392,8 @@ components: - is_incognito - is_search_bot properties: + client_identity: + $ref: '#/components/schemas/ClientIdentity' request_id: $ref: '#/components/schemas/RequestId' session_id: @@ -1602,13 +1774,6 @@ components: type: string const: ok description: Always `ok` when the service answers. - SchemaVersion: - type: string - minLength: 1 - description: Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01. - examples: - - '2026-10-06' - - '2026-06-01' Rfc3339Timestamp: type: string format: date-time @@ -1617,6 +1782,187 @@ components: examples: - '2026-09-30T12:34:57.482913041Z' - '2026-09-30T12:34:56Z' + MultiaccountChangedData: + type: object + additionalProperties: false + required: + - kind + - domain + - action + - status + - detected + - level + - cluster_id + - revision + - previous_revision + - user_hids + - users_count + - members_complete + - evaluated_at + - rules_version + - source + - evidence_summary + properties: + kind: + const: multi_account + domain: + type: string + minLength: 1 + action: + type: string + enum: + - detected + - updated + - resolved + status: + const: evaluated + description: The result comes from a complete, verified detector source cut. Pending or unavailable work does not produce this event. + detected: + type: boolean + level: + type: + - string + - 'null' + enum: + - medium + - high + - null + cluster_id: + type: string + format: uuid + description: ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group. + revision: + type: string + pattern: ^[1-9][0-9]{0,19}$ + description: Publication version of this transition, as a decimal string to preserve UInt64 precision. + previous_revision: + type: + - string + - 'null' + pattern: ^(0|[1-9][0-9]{0,19})$ + description: Previous transition version while the group was active; null for a newly detected or reappearing group. + user_hids: + type: array + minItems: 3 + uniqueItems: true + description: Complete sorted group membership. Users from another site are never included. This list is not truncated. + items: + type: string + minLength: 1 + not: + enum: + - anonymous + - '-1' + - fail + - 00000000-0000-0000-0000-000000000000 + users_count: + type: integer + minimum: 3 + description: Equal to the length of user_hids. + members_complete: + const: true + evaluated_at: + $ref: '#/components/schemas/Rfc3339Timestamp' + rules_version: + type: string + minLength: 1 + source: + type: object + additionalProperties: false + required: + - publication_epoch + - publication_version + - coverage_id + properties: + publication_epoch: + const: multiaccount-v1 + publication_version: + type: string + pattern: ^[1-9][0-9]{0,19}$ + description: The exact immutable publication containing this result and event. + coverage_id: + type: string + pattern: ^[a-f0-9]{64}$ + description: Immutable binding to the trusted source completeness proof. + evidence_summary: + type: object + additionalProperties: false + required: + - basis_types + - rules + - detail_truncated + properties: + basis_types: + type: array + uniqueItems: true + items: + type: string + rules: + type: array + uniqueItems: true + items: + type: string + detail_truncated: + type: boolean + description: Whether the detector's underlying detailed evidence was truncated. Group membership remains complete. + allOf: + - if: + properties: + action: + const: resolved + required: + - action + then: + additionalProperties: true + properties: + detected: + const: false + level: + type: 'null' + else: + additionalProperties: true + properties: + detected: + const: true + level: + type: string + enum: + - medium + - high + MultiaccountChangedEvent: + type: object + description: An immutable opt-in notification of a complete multi-account group transition. + additionalProperties: false + required: + - event_id + - event_type + - schema_version + - site_id + - created_at + - data + properties: + event_id: + type: string + pattern: ^[a-f0-9]{40}$ + description: Stable logical event ID. Delivery retries preserve this ID and the signed body. + event_type: + const: hre.multi_account.changed + schema_version: + const: '2026-10-07' + site_id: + type: integer + minimum: 1 + created_at: + $ref: '#/components/schemas/Rfc3339Timestamp' + data: + $ref: '#/components/schemas/MultiaccountChangedData' + SchemaVersion: + type: string + minLength: 1 + description: Webhook contract version. Scored release 2026-10-06; multi-account group release 2026-10-07; parsers also accept legacy 2026-06-01. + examples: + - '2026-10-06' + - '2026-06-01' UserHid: type: - string @@ -1732,7 +2078,7 @@ components: for anti-detect browsers; - `port_scan_routed_via_proxy`: `proxy_routed_antidetect` carried forward from an earlier identification of the same device and IP; - - `browser_automation`: browser automation, for example a WebDriver-controlled browser; + - `browser_automation`: browser automation, for example a WebDriver-controlled browser (90 in task 204 scoring releases); - `javascript_disabled`: JavaScript or the browser APIs the checks need were unavailable; - `os_mismatch`: the operating system seen on the network differs from the one the browser reports; @@ -1758,7 +2104,7 @@ components: DetectionFlags: type: object description: |- - Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on + Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the extension flags depend on the scoring release. Task 204 adds optional ai_bot and ai_browser flags; older payloads omit them. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and @@ -1807,7 +2153,7 @@ components: description: The public IP has a record of abuse in IP intelligence. os_mismatch: type: boolean - description: The operating system seen on the network differs from the one the browser reports. + description: Either internal OS consistency check detected a mismatch. The public flag combines browser/network and TCP behaviour checks. os_not_detected: type: boolean description: The operating system could not be determined from the User-Agent or the network. @@ -1819,7 +2165,7 @@ components: description: An anti-detect browser was detected. browser_automation: type: boolean - description: Browser automation was detected, for example a WebDriver-controlled browser. + description: Browser automation was detected, for example a WebDriver-controlled browser. Catalogue weight is 90 for task 204 scoring releases; historical result versions may retain the earlier weight 60. ip_mismatch: type: boolean description: 'The public IP differs from the local IP found by the browser network check. Informational: it does not add to the score.' @@ -1841,14 +2187,22 @@ components: check_incomplete: type: boolean description: Part of the browser checks timed out, so the verdict rests on partial data. Informational. - os_mismatch2: - type: boolean device_spoofing: type: boolean latency_test: type: boolean banned_ip: type: boolean + ai_bot: + type: boolean + description: An accepted bot is classified as AI training or user-requested fetch. ChatGPT-User, Claude-User and Perplexity-User are AI bots, not AI browsers. Weight 0. The flag is optional on older payloads; its absence is not an evaluated negative result. Provider claims alone do not grant zero risk. + ai_browser: + type: boolean + description: A browser is identified by separate verified browser infrastructure. A generic browser User-Agent or a verified provider alone is insufficient. Weight 0. Optional on older payloads; absence is not an evaluated negative. + os_mismatch2: + type: boolean + deprecated: true + description: Legacy 2026-10-06 only; current payloads combine this check into os_mismatch. RiskEvent: type: object required: @@ -1860,6 +2214,7 @@ components: properties: code: type: string + description: Open catalogue of independent source signals. ai_bot and ai_browser carry weight 0; search_bot remains separate. browser_automation carries weight 90 in task 204 releases. UI Good bot and Bad bot groups are not risk event codes. Keep unknown codes and use the payload scoring_version/result_version for historical interpretation. detected: type: boolean description: Final scoring flag. false does not assert that every underlying probe completed. @@ -1903,6 +2258,11 @@ components: min_devices: type: integer minimum: 1 + cluster_id: + type: + - string + - 'null' + description: Authoritative cluster ID for this HRE result. Null when no cluster was published for the result, including old stored verdicts; never a device ID. Reused on retries. HRE: type: object required: @@ -1919,31 +2279,9 @@ components: impossible_travel: $ref: '#/components/schemas/HREResult' description: Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate. - Fingerprint: - type: object - required: - - outcome - - rules_version - properties: - outcome: - type: string - record_id: - type: string - hardware_id: - type: string - description: FP21 hardware identity, distinct from device_id. - rules_version: - type: string - sharing: - type: object - takeover: - type: object - travel: - type: object - description: FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. IdentificationScoredData: type: object - description: Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted. + description: Final identification. risk_score is this scan only. Version 2026-10-07 uses signals for score contributions, detection_flags for final decisions, and hre for account results. Older bodies remain accepted. required: - request_id - visitor_id @@ -1964,6 +2302,8 @@ components: - detection_flags - observed_at properties: + client_identity: + $ref: '#/components/schemas/ClientIdentity' request_id: $ref: '#/components/schemas/RequestId' visitor_id: @@ -2016,13 +2356,26 @@ components: type: string description: Core build source revision; core:unversioned on local builds. risk_events: + deprecated: true + description: Legacy 2026-10-06 only; absent from current scored events. type: array items: $ref: '#/components/schemas/RiskEvent' hre: $ref: '#/components/schemas/HRE' - fingerprint: - $ref: '#/components/schemas/Fingerprint' + search_bot_owner: + type: string + description: Owner label from accepted search-bot detection; omitted when unknown or inactive. + examples: + - Google + ai_bot_owner: + type: string + description: Provider company label from accepted AI-bot detection; omitted when unknown or inactive. + examples: + - OpenAI + ai_browser_owner: + type: string + description: Owner label from accepted AI-browser detection; omitted when unknown or inactive. IdentificationScoredEvent: type: object description: 'Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header.' @@ -2081,6 +2434,83 @@ components: description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. type: object additionalProperties: true + - if: + type: object + required: + - schema_version + properties: + schema_version: + const: '2026-10-07' + additionalProperties: true + then: + type: object + required: + - event_id + properties: + event_id: + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. + data: + type: object + required: + - result_version + - scoring_version + - hre + properties: + result_version: {} + scoring_version: {} + risk_events: {} + fingerprint: {} + detection_flags: + type: object + not: + type: object + required: + - os_mismatch2 + properties: + os_mismatch2: {} + additionalProperties: true + hre: + type: object + properties: + account_sharing: + type: object + required: + - cluster_id + properties: + cluster_id: {} + additionalProperties: true + account_takeover: + type: object + required: + - cluster_id + properties: + cluster_id: {} + additionalProperties: true + impossible_travel: + type: object + required: + - cluster_id + properties: + cluster_id: {} + additionalProperties: true + additionalProperties: true + not: + anyOf: + - type: object + required: + - risk_events + properties: + risk_events: {} + additionalProperties: true + - type: object + required: + - fingerprint + properties: + fingerprint: {} + additionalProperties: true + additionalProperties: true WebhookPingEvent: type: object description: Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys arrive sorted alphabetically and `created_at` has second precision. @@ -2494,6 +2924,115 @@ components: description: The only successful answer. value: status: ok + MultiaccountDetected: + summary: Multi-account group detected + description: Generated by the Core event marshaler for a synthetic three-account group. + value: + event_id: ad5efdbb113f50d1207c3a3b1d03e54ee60dfb9a + site_id: 319 + event_type: hre.multi_account.changed + schema_version: '2026-10-07' + created_at: '2026-10-07T12:01:04Z' + data: + kind: multi_account + domain: example.com + action: detected + status: evaluated + detected: true + level: medium + cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + revision: '7412' + previous_revision: null + user_hids: + - account_a + - account_b + - account_c + users_count: 3 + members_complete: true + evaluated_at: '2026-10-07T12:01:00Z' + rules_version: fp21-travel-pair-2026-10-06 + source: + publication_epoch: multiaccount-v1 + publication_version: '7412' + coverage_id: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + evidence_summary: + basis_types: + - lineage + rules: + - lineage_60m + detail_truncated: false + MultiaccountUpdated: + summary: Multi-account group updated + description: Generated by the Core event marshaler for a synthetic three-account group. + value: + event_id: 27225e88895f45c6f96562578308d3afea9d4ea2 + site_id: 319 + event_type: hre.multi_account.changed + schema_version: '2026-10-07' + created_at: '2026-10-07T12:01:04Z' + data: + kind: multi_account + domain: example.com + action: updated + status: evaluated + detected: true + level: high + cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + revision: '7414' + previous_revision: '7412' + user_hids: + - account_a + - account_b + - account_c + users_count: 3 + members_complete: true + evaluated_at: '2026-10-07T12:01:00Z' + rules_version: fp21-travel-pair-2026-10-06 + source: + publication_epoch: multiaccount-v1 + publication_version: '7414' + coverage_id: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + evidence_summary: + basis_types: + - lineage + rules: + - lineage_60m + detail_truncated: false + MultiaccountResolved: + summary: Multi-account group resolved + description: Generated by the Core event marshaler for a synthetic three-account group. + value: + event_id: 0e703af70ff450065df0457d71789d32df4de079 + site_id: 319 + event_type: hre.multi_account.changed + schema_version: '2026-10-07' + created_at: '2026-10-07T12:01:04Z' + data: + kind: multi_account + domain: example.com + action: resolved + status: evaluated + detected: false + level: null + cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + revision: '7415' + previous_revision: '7412' + user_hids: + - account_a + - account_b + - account_c + users_count: 3 + members_complete: true + evaluated_at: '2026-10-07T12:01:00Z' + rules_version: fp21-travel-pair-2026-10-06 + source: + publication_epoch: multiaccount-v1 + publication_version: '7415' + coverage_id: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + evidence_summary: + basis_types: [] + rules: [] + detail_truncated: false IdentificationScored: summary: Dangerous identification from a paid click description: Risk Score 80 from a proxy, a datacenter IP and an anti-detect browser, on a visit from a Google Ads click. diff --git a/src/shieldlabs/_models.py b/src/shieldlabs/_models.py index 81b7de7..f52c90c 100644 --- a/src/shieldlabs/_models.py +++ b/src/shieldlabs/_models.py @@ -175,6 +175,8 @@ class DetectionFlags: javascript_disabled: bool = False stun_not_checked: bool = False check_incomplete: bool = False + ai_bot: Optional[bool] = None + ai_browser: Optional[bool] = None os_mismatch2: Optional[bool] = None device_spoofing: Optional[bool] = None latency_test: Optional[bool] = None @@ -188,7 +190,14 @@ def from_dict(cls, data: Mapping[str, Any]) -> DetectionFlags: **{key: bool(data.get(key, False)) for key in FLAG_KEYS}, **{ key: data[key] - for key in ("os_mismatch2", "device_spoofing", "latency_test", "banned_ip") + for key in ( + "os_mismatch2", + "device_spoofing", + "latency_test", + "banned_ip", + "ai_bot", + "ai_browser", + ) if isinstance(data.get(key), bool) }, ) @@ -198,7 +207,14 @@ def to_dict(self) -> dict[str, bool]: **{key: getattr(self, key) for key in FLAG_KEYS}, **{ key: getattr(self, key) - for key in ("os_mismatch2", "device_spoofing", "latency_test", "banned_ip") + for key in ( + "os_mismatch2", + "device_spoofing", + "latency_test", + "banned_ip", + "ai_bot", + "ai_browser", + ) if getattr(self, key) is not None }, } @@ -269,6 +285,9 @@ class Identification: source: IdentificationSource result_version: Optional[str] = None scoring_version: Optional[str] = None + search_bot_owner: Optional[str] = None + ai_bot_owner: Optional[str] = None + ai_browser_owner: Optional[str] = None risk_events: Optional[tuple[RiskEvent, ...]] = None hre: Optional[Mapping[str, Any]] = None fingerprint: Optional[Mapping[str, Any]] = None @@ -321,6 +340,13 @@ def from_webhook_data(cls, data: Mapping[str, Any]) -> Identification: detection_flags=DetectionFlags.from_dict(flags), observed_at=parse_rfc3339(data.get("observed_at")), source="webhook", + search_bot_owner=as_str(data["search_bot_owner"]) + if "search_bot_owner" in data + else None, + ai_bot_owner=as_str(data["ai_bot_owner"]) if "ai_bot_owner" in data else None, + ai_browser_owner=as_str(data["ai_browser_owner"]) + if "ai_browser_owner" in data + else None, result_version=as_str(data["result_version"]) if "result_version" in data else None, scoring_version=as_str(data["scoring_version"]) if "scoring_version" in data else None, risk_events=tuple( @@ -453,6 +479,13 @@ def from_dict(cls, data: Mapping[str, Any]) -> Identification: if isinstance(observed_at, datetime) else parse_rfc3339(observed_at), source="history" if source == "history" else "webhook", + search_bot_owner=as_str(data["search_bot_owner"]) + if "search_bot_owner" in data + else None, + ai_bot_owner=as_str(data["ai_bot_owner"]) if "ai_bot_owner" in data else None, + ai_browser_owner=as_str(data["ai_browser_owner"]) + if "ai_browser_owner" in data + else None, result_version=as_str(data["result_version"]) if "result_version" in data else None, scoring_version=as_str(data["scoring_version"]) if "scoring_version" in data else None, risk_events=tuple( @@ -500,6 +533,17 @@ def to_dict(self) -> dict[str, Any]: if self.risk_events is not None else {} ), + **( + {"search_bot_owner": self.search_bot_owner} + if self.search_bot_owner is not None + else {} + ), + **({"ai_bot_owner": self.ai_bot_owner} if self.ai_bot_owner is not None else {}), + **( + {"ai_browser_owner": self.ai_browser_owner} + if self.ai_browser_owner is not None + else {} + ), **({"hre": dict(self.hre)} if self.hre is not None else {}), **({"fingerprint": dict(self.fingerprint)} if self.fingerprint is not None else {}), } diff --git a/src/shieldlabs/webhooks.py b/src/shieldlabs/webhooks.py index c46d846..cfa1cce 100644 --- a/src/shieldlabs/webhooks.py +++ b/src/shieldlabs/webhooks.py @@ -5,7 +5,7 @@ string exactly as shown in the analytics dashboard, ``whsec_`` prefix included, and the message is the raw request body. Always verify the raw bytes you received, before parsing them. -Version 2026-10-06 carries a signed event_id in the body and X-Shield-Event-Id header. +Version 2026-10-07 carries a signed event_id in the body and X-Shield-Event-Id header. Failed deliveries are retried within a bounded window. Deduplicate by event_id, store the event durably before returning 2xx, then process asynchronously. For old bodies without event_id, fall back to data.request_id. History provides recovery/latest state. @@ -49,7 +49,7 @@ SIGNATURE_HEADER = "X-Shield-Signature" """Name of the header that carries the signature.""" -SCHEMA_VERSION = "2026-10-06" +SCHEMA_VERSION = "2026-10-07" """Webhook ``schema_version`` this SDK was built for. Other values are parsed with a warning.""" _SIGNATURE_PREFIX = "sha256=" diff --git a/tests/contracts/ai-bot.json b/tests/contracts/ai-bot.json new file mode 100644 index 0000000..30b6bc0 --- /dev/null +++ b/tests/contracts/ai-bot.json @@ -0,0 +1,91 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-07", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "ai_bot_owner": "OpenAI", + "hre": { + "account_sharing": { + "cluster_id": null, + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + }, + "account_takeover": { + "cluster_id": null, + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + }, + "impossible_travel": { + "cluster_id": null, + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "ai_bot": true, + "ai_browser": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z" + } +} diff --git a/tests/contracts/current.json b/tests/contracts/current.json index 4c6dcd6..f2f77c1 100644 --- a/tests/contracts/current.json +++ b/tests/contracts/current.json @@ -2,149 +2,15 @@ "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", "site_id": 7, "event_type": "identification.scored", - "schema_version": "2026-10-06", + "schema_version": "2026-10-07", "created_at": "2026-10-06T00:00:02Z", "data": { "result_version": "2026-10-06T00:00:01Z", "scoring_version": "core:fixture", - "risk_events": [ - { - "code": "vpn", - "detected": false, - "weight": 30, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "proxy", - "detected": false, - "weight": 30, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "privacy_relay", - "detected": false, - "weight": 30, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "check_incomplete", - "detected": false, - "weight": 0, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "network_check_incomplete", - "detected": false, - "weight": 30, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "os_not_detected", - "detected": false, - "weight": 30, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "timezone_mismatch", - "detected": false, - "weight": 10, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "javascript_disabled", - "detected": false, - "weight": 90, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "os_mismatch", - "detected": false, - "weight": 60, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "os_mismatch2", - "detected": false, - "weight": 60, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "ip_mismatch", - "detected": false, - "weight": 45, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "latency_test", - "detected": false, - "weight": 60, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "anti_detect_browser", - "detected": false, - "weight": 60, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "device_spoofing", - "detected": false, - "weight": 90, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "banned_ip", - "detected": false, - "weight": 999, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "incognito", - "detected": true, - "weight": 0, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "search_bot", - "detected": false, - "weight": 0, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "browser_automation", - "detected": false, - "weight": 60, - "contribution": 0, - "status": "evaluated" - }, - { - "code": "tor", - "detected": false, - "weight": 99, - "contribution": 0, - "status": "evaluated" - } - ], "hre": { "rules_version": "fp21-fixture", "account_sharing": { + "cluster_id": null, "status": "evaluated", "level": null, "reason": "", @@ -152,11 +18,13 @@ "min_devices": 4 }, "account_takeover": { + "cluster_id": null, "status": "not_evaluated", "level": null, "reason": "no_history" }, "impossible_travel": { + "cluster_id": null, "status": "not_evaluated", "level": null, "reason": "skipped" @@ -195,7 +63,6 @@ "risk_score": 0, "signals": [], "detection_flags": { - "os_mismatch2": false, "device_spoofing": false, "latency_test": false, "banned_ip": false, @@ -214,30 +81,13 @@ "ip_mismatch": false, "incognito": true, "search_bot": false, + "ai_bot": false, + "ai_browser": false, "suspicious_paid_click": false, "javascript_disabled": false, "stun_not_checked": false, "check_incomplete": false }, - "observed_at": "2026-10-06T00:00:00Z", - "fingerprint": { - "outcome": "new_record", - "record_id": "sample-record", - "hardware_id": "sample-hardware", - "rules_version": "fp21-fixture", - "sharing": { - "level": "", - "devices": 1, - "min_devices": 4 - }, - "takeover": { - "level": "", - "reason": "no_history" - }, - "travel": { - "level": "", - "reason": "skipped" - } - } + "observed_at": "2026-10-06T00:00:00Z" } } diff --git a/tests/test_contract_current.py b/tests/test_contract_current.py index a62efe8..418a616 100644 --- a/tests/test_contract_current.py +++ b/tests/test_contract_current.py @@ -12,14 +12,23 @@ def test_current_core_fixture_preserves_identity_and_results(): assert event.event_id assert event.site_id == 7 assert isinstance(event, webhooks.IdentificationScoredEvent) - assert event.data.risk_events is not None - assert len(event.data.risk_events) == 19 - assert event.data.fingerprint is not None - assert event.data.fingerprint["hardware_id"] == "sample-hardware" - assert event.data.device_id != event.data.fingerprint["hardware_id"] + assert event.data.risk_events is None + assert event.data.fingerprint is None + assert event.data.detection_flags.os_mismatch2 is None assert event.data.hre is not None assert event.data.hre["account_takeover"]["reason"] == "no_history" encoded = event.data.to_dict() - assert encoded["risk_events"][15]["weight"] == 0 + assert "risk_events" not in encoded + assert encoded["hre"]["account_sharing"]["cluster_id"] is None assert event.data.from_dict(encoded).to_dict() == encoded assert not webhooks.verify_signature(raw + b" ", signature, "secret") + + +def test_accepted_ai_bot_owner_survives_normalization(): + raw = (Path(__file__).parent / "contracts/ai-bot.json").read_bytes() + signature = "sha256=" + hmac.new(b"secret", raw, hashlib.sha256).hexdigest() + event = webhooks.construct_event(raw, signature, "secret") + assert isinstance(event, webhooks.IdentificationScoredEvent) + assert event.data.ai_bot_owner == "OpenAI" + assert event.data.detection_flags.ai_bot is True + assert event.data.to_dict()["ai_bot_owner"] == "OpenAI" diff --git a/tests/test_package.py b/tests/test_package.py index 961f6b3..4e558f3 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -87,5 +87,5 @@ def test_version_and_typing_marker() -> None: def test_webhooks_module_surface() -> None: assert shieldlabs.webhooks.SIGNATURE_HEADER == "X-Shield-Signature" - assert shieldlabs.webhooks.SCHEMA_VERSION == "2026-10-06" + assert shieldlabs.webhooks.SCHEMA_VERSION == "2026-10-07" assert shieldlabs.webhooks.IdentificationScoredEvent is shieldlabs.IdentificationScoredEvent From 0ec99f7c4fb743098ef19c319ca7dac890375224 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:08:02 +0300 Subject: [PATCH 4/4] fix: regenerate API models for simplified webhook schema --- generated/.openapi-generator/FILES | 10 +- generated/shieldlabs_generated/__init__.py | 20 +- .../shieldlabs_generated/models/__init__.py | 10 +- .../models/client_identity.py | 156 +++++++++++++ ...y => client_identity_assessments_inner.py} | 45 ++-- .../models/client_identity_claims_inner.py | 114 ++++++++++ .../models/client_identity_evidence_inner.py | 121 ++++++++++ .../models/client_identity_verified_inner.py | 106 +++++++++ .../models/detection_flags.py | 18 +- .../models/history_row.py | 8 +- .../shieldlabs_generated/models/hre_result.py | 11 +- .../models/identification_scored_data.py | 24 +- .../models/identification_scored_event.py | 2 +- .../models/multiaccount_changed_data.py | 210 ++++++++++++++++++ ...tiaccount_changed_data_evidence_summary.py | 93 ++++++++ .../multiaccount_changed_data_source.py | 119 ++++++++++ .../models/multiaccount_changed_event.py | 148 ++++++++++++ .../shieldlabs_generated/models/risk_event.py | 2 +- .../shieldlabs_generated/models/signal.py | 2 +- .../models/webhook_ping_event.py | 2 +- 20 files changed, 1174 insertions(+), 47 deletions(-) create mode 100644 generated/shieldlabs_generated/models/client_identity.py rename generated/shieldlabs_generated/models/{fingerprint.py => client_identity_assessments_inner.py} (63%) create mode 100644 generated/shieldlabs_generated/models/client_identity_claims_inner.py create mode 100644 generated/shieldlabs_generated/models/client_identity_evidence_inner.py create mode 100644 generated/shieldlabs_generated/models/client_identity_verified_inner.py create mode 100644 generated/shieldlabs_generated/models/multiaccount_changed_data.py create mode 100644 generated/shieldlabs_generated/models/multiaccount_changed_data_evidence_summary.py create mode 100644 generated/shieldlabs_generated/models/multiaccount_changed_data_source.py create mode 100644 generated/shieldlabs_generated/models/multiaccount_changed_event.py diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index 34d3b18..a910d2f 100644 --- a/generated/.openapi-generator/FILES +++ b/generated/.openapi-generator/FILES @@ -8,10 +8,14 @@ shieldlabs_generated/api_response.py shieldlabs_generated/configuration.py shieldlabs_generated/exceptions.py shieldlabs_generated/models/__init__.py +shieldlabs_generated/models/client_identity.py +shieldlabs_generated/models/client_identity_assessments_inner.py +shieldlabs_generated/models/client_identity_claims_inner.py +shieldlabs_generated/models/client_identity_evidence_inner.py +shieldlabs_generated/models/client_identity_verified_inner.py shieldlabs_generated/models/detection_flags.py shieldlabs_generated/models/domain_profile.py shieldlabs_generated/models/error_body.py -shieldlabs_generated/models/fingerprint.py shieldlabs_generated/models/health_status.py shieldlabs_generated/models/history_page.py shieldlabs_generated/models/history_row.py @@ -21,6 +25,10 @@ shieldlabs_generated/models/identification_scored_data.py shieldlabs_generated/models/identification_scored_event.py shieldlabs_generated/models/ip_info.py shieldlabs_generated/models/legacy_snapshot.py +shieldlabs_generated/models/multiaccount_changed_data.py +shieldlabs_generated/models/multiaccount_changed_data_evidence_summary.py +shieldlabs_generated/models/multiaccount_changed_data_source.py +shieldlabs_generated/models/multiaccount_changed_event.py shieldlabs_generated/models/risk_event.py shieldlabs_generated/models/score_detail.py shieldlabs_generated/models/signal.py diff --git a/generated/shieldlabs_generated/__init__.py b/generated/shieldlabs_generated/__init__.py index 456aa65..644a075 100644 --- a/generated/shieldlabs_generated/__init__.py +++ b/generated/shieldlabs_generated/__init__.py @@ -31,10 +31,14 @@ "ApiKeyError", "ApiAttributeError", "ApiException", + "ClientIdentity", + "ClientIdentityAssessmentsInner", + "ClientIdentityClaimsInner", + "ClientIdentityEvidenceInner", + "ClientIdentityVerifiedInner", "DetectionFlags", "DomainProfile", "ErrorBody", - "Fingerprint", "HRE", "HREResult", "HealthStatus", @@ -44,6 +48,10 @@ "IdentificationScoredEvent", "IpInfo", "LegacySnapshot", + "MultiaccountChangedData", + "MultiaccountChangedDataEvidenceSummary", + "MultiaccountChangedDataSource", + "MultiaccountChangedEvent", "RiskEvent", "ScoreDetail", "Signal", @@ -68,10 +76,14 @@ from shieldlabs_generated.exceptions import ApiException as ApiException # import models into sdk package +from shieldlabs_generated.models.client_identity import ClientIdentity as ClientIdentity +from shieldlabs_generated.models.client_identity_assessments_inner import ClientIdentityAssessmentsInner as ClientIdentityAssessmentsInner +from shieldlabs_generated.models.client_identity_claims_inner import ClientIdentityClaimsInner as ClientIdentityClaimsInner +from shieldlabs_generated.models.client_identity_evidence_inner import ClientIdentityEvidenceInner as ClientIdentityEvidenceInner +from shieldlabs_generated.models.client_identity_verified_inner import ClientIdentityVerifiedInner as ClientIdentityVerifiedInner from shieldlabs_generated.models.detection_flags import DetectionFlags as DetectionFlags from shieldlabs_generated.models.domain_profile import DomainProfile as DomainProfile from shieldlabs_generated.models.error_body import ErrorBody as ErrorBody -from shieldlabs_generated.models.fingerprint import Fingerprint as Fingerprint from shieldlabs_generated.models.hre import HRE as HRE from shieldlabs_generated.models.hre_result import HREResult as HREResult from shieldlabs_generated.models.health_status import HealthStatus as HealthStatus @@ -81,6 +93,10 @@ from shieldlabs_generated.models.identification_scored_event import IdentificationScoredEvent as IdentificationScoredEvent from shieldlabs_generated.models.ip_info import IpInfo as IpInfo from shieldlabs_generated.models.legacy_snapshot import LegacySnapshot as LegacySnapshot +from shieldlabs_generated.models.multiaccount_changed_data import MultiaccountChangedData as MultiaccountChangedData +from shieldlabs_generated.models.multiaccount_changed_data_evidence_summary import MultiaccountChangedDataEvidenceSummary as MultiaccountChangedDataEvidenceSummary +from shieldlabs_generated.models.multiaccount_changed_data_source import MultiaccountChangedDataSource as MultiaccountChangedDataSource +from shieldlabs_generated.models.multiaccount_changed_event import MultiaccountChangedEvent as MultiaccountChangedEvent from shieldlabs_generated.models.risk_event import RiskEvent as RiskEvent from shieldlabs_generated.models.score_detail import ScoreDetail as ScoreDetail from shieldlabs_generated.models.signal import Signal as Signal diff --git a/generated/shieldlabs_generated/models/__init__.py b/generated/shieldlabs_generated/models/__init__.py index 2441e82..4253897 100644 --- a/generated/shieldlabs_generated/models/__init__.py +++ b/generated/shieldlabs_generated/models/__init__.py @@ -14,10 +14,14 @@ """ # noqa: E501 # import models into model package +from shieldlabs_generated.models.client_identity import ClientIdentity +from shieldlabs_generated.models.client_identity_assessments_inner import ClientIdentityAssessmentsInner +from shieldlabs_generated.models.client_identity_claims_inner import ClientIdentityClaimsInner +from shieldlabs_generated.models.client_identity_evidence_inner import ClientIdentityEvidenceInner +from shieldlabs_generated.models.client_identity_verified_inner import ClientIdentityVerifiedInner from shieldlabs_generated.models.detection_flags import DetectionFlags from shieldlabs_generated.models.domain_profile import DomainProfile from shieldlabs_generated.models.error_body import ErrorBody -from shieldlabs_generated.models.fingerprint import Fingerprint from shieldlabs_generated.models.hre import HRE from shieldlabs_generated.models.hre_result import HREResult from shieldlabs_generated.models.health_status import HealthStatus @@ -27,6 +31,10 @@ from shieldlabs_generated.models.identification_scored_event import IdentificationScoredEvent from shieldlabs_generated.models.ip_info import IpInfo from shieldlabs_generated.models.legacy_snapshot import LegacySnapshot +from shieldlabs_generated.models.multiaccount_changed_data import MultiaccountChangedData +from shieldlabs_generated.models.multiaccount_changed_data_evidence_summary import MultiaccountChangedDataEvidenceSummary +from shieldlabs_generated.models.multiaccount_changed_data_source import MultiaccountChangedDataSource +from shieldlabs_generated.models.multiaccount_changed_event import MultiaccountChangedEvent from shieldlabs_generated.models.risk_event import RiskEvent from shieldlabs_generated.models.score_detail import ScoreDetail from shieldlabs_generated.models.signal import Signal diff --git a/generated/shieldlabs_generated/models/client_identity.py b/generated/shieldlabs_generated/models/client_identity.py new file mode 100644 index 0000000..f69f292 --- /dev/null +++ b/generated/shieldlabs_generated/models/client_identity.py @@ -0,0 +1,156 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from shieldlabs_generated.models.client_identity_assessments_inner import ClientIdentityAssessmentsInner +from shieldlabs_generated.models.client_identity_claims_inner import ClientIdentityClaimsInner +from shieldlabs_generated.models.client_identity_evidence_inner import ClientIdentityEvidenceInner +from shieldlabs_generated.models.client_identity_verified_inner import ClientIdentityVerifiedInner +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class ClientIdentity(BaseModel): + """ + Stored ingest attribution, independent of risk score and browser automation. Claimed names are unverified. A verified provider does not verify the agent name, service or AI mode. Read proof scope from each attribution and its referenced evidence. Unknown string values and fields must be accepted. + """ # noqa: E501 + schema_version: StrictStr = Field(description="Open version identifier. Version 1 is currently emitted.") + classification_revision: Annotated[int, Field(strict=True, ge=1)] + classifier_version: StrictStr + registry_revision: StrictStr + availability: StrictStr = Field(description="available or input_unavailable. Missing object means no stored identity data; it does not mean human.") + observed_at: datetime + decided_at: datetime + claims: List[ClientIdentityClaimsInner] + verified: List[ClientIdentityVerifiedInner] + assessments: List[ClientIdentityAssessmentsInner] + evidence: List[ClientIdentityEvidenceInner] + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["schema_version", "classification_revision", "classifier_version", "registry_revision", "availability", "observed_at", "decided_at", "claims", "verified", "assessments", "evidence"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ClientIdentity from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in claims (list) + _items = [] + if self.claims: + for _item_claims in self.claims: + if _item_claims: + _items.append(_item_claims.to_dict()) + _dict['claims'] = _items + # override the default output from pydantic by calling `to_dict()` of each item in verified (list) + _items = [] + if self.verified: + for _item_verified in self.verified: + if _item_verified: + _items.append(_item_verified.to_dict()) + _dict['verified'] = _items + # override the default output from pydantic by calling `to_dict()` of each item in assessments (list) + _items = [] + if self.assessments: + for _item_assessments in self.assessments: + if _item_assessments: + _items.append(_item_assessments.to_dict()) + _dict['assessments'] = _items + # override the default output from pydantic by calling `to_dict()` of each item in evidence (list) + _items = [] + if self.evidence: + for _item_evidence in self.evidence: + if _item_evidence: + _items.append(_item_evidence.to_dict()) + _dict['evidence'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ClientIdentity from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "schema_version": obj.get("schema_version"), + "classification_revision": obj.get("classification_revision"), + "classifier_version": obj.get("classifier_version"), + "registry_revision": obj.get("registry_revision"), + "availability": obj.get("availability"), + "observed_at": obj.get("observed_at"), + "decided_at": obj.get("decided_at"), + "claims": [ClientIdentityClaimsInner.from_dict(_item) for _item in obj["claims"]] if obj.get("claims") is not None else None, + "verified": [ClientIdentityVerifiedInner.from_dict(_item) for _item in obj["verified"]] if obj.get("verified") is not None else None, + "assessments": [ClientIdentityAssessmentsInner.from_dict(_item) for _item in obj["assessments"]] if obj.get("assessments") is not None else None, + "evidence": [ClientIdentityEvidenceInner.from_dict(_item) for _item in obj["evidence"]] if obj.get("evidence") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/fingerprint.py b/generated/shieldlabs_generated/models/client_identity_assessments_inner.py similarity index 63% rename from generated/shieldlabs_generated/models/fingerprint.py rename to generated/shieldlabs_generated/models/client_identity_assessments_inner.py index b765df6..030f5e8 100644 --- a/generated/shieldlabs_generated/models/fingerprint.py +++ b/generated/shieldlabs_generated/models/client_identity_assessments_inner.py @@ -18,24 +18,21 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, StrictStr from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self from pydantic_core import to_jsonable_python -class Fingerprint(BaseModel): +class ClientIdentityAssessmentsInner(BaseModel): """ - FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. + ClientIdentityAssessmentsInner """ # noqa: E501 - outcome: StrictStr - record_id: Optional[StrictStr] = None - hardware_id: Optional[StrictStr] = Field(default=None, description="FP21 hardware identity, distinct from device_id.") - rules_version: StrictStr - sharing: Optional[Dict[str, Any]] = None - takeover: Optional[Dict[str, Any]] = None - travel: Optional[Dict[str, Any]] = None - __properties: ClassVar[List[str]] = ["outcome", "record_id", "hardware_id", "rules_version", "sharing", "takeover", "travel"] + candidate_profile_id: Optional[StrictStr] = None + status: StrictStr + reason: StrictStr + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["candidate_profile_id", "status", "reason"] model_config = ConfigDict( validate_by_name=True, @@ -55,7 +52,7 @@ def to_json(self) -> str: @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of Fingerprint from a JSON string""" + """Create an instance of ClientIdentityAssessmentsInner from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -67,8 +64,10 @@ def to_dict(self) -> Dict[str, Any]: * `None` is only added to the output dict for nullable fields that were set at model initialization. Other fields with value `None` are ignored. + * Fields in `self.additional_properties` are added to the output dict. """ excluded_fields: Set[str] = set([ + "additional_properties", ]) _dict = self.model_dump( @@ -76,11 +75,16 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of Fingerprint from a dict""" + """Create an instance of ClientIdentityAssessmentsInner from a dict""" if obj is None: return None @@ -88,14 +92,15 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "outcome": obj.get("outcome"), - "record_id": obj.get("record_id"), - "hardware_id": obj.get("hardware_id"), - "rules_version": obj.get("rules_version"), - "sharing": obj.get("sharing"), - "takeover": obj.get("takeover"), - "travel": obj.get("travel") + "candidate_profile_id": obj.get("candidate_profile_id"), + "status": obj.get("status"), + "reason": obj.get("reason") }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + return _obj diff --git a/generated/shieldlabs_generated/models/client_identity_claims_inner.py b/generated/shieldlabs_generated/models/client_identity_claims_inner.py new file mode 100644 index 0000000..a5d15e0 --- /dev/null +++ b/generated/shieldlabs_generated/models/client_identity_claims_inner.py @@ -0,0 +1,114 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class ClientIdentityClaimsInner(BaseModel): + """ + ClientIdentityClaimsInner + """ # noqa: E501 + profile_id: StrictStr + provider_id: StrictStr + provider_name: StrictStr + agent_name: StrictStr + client_kind: StrictStr + purpose: StrictStr + source: StrictStr + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["profile_id", "provider_id", "provider_name", "agent_name", "client_kind", "purpose", "source"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ClientIdentityClaimsInner from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ClientIdentityClaimsInner from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "profile_id": obj.get("profile_id"), + "provider_id": obj.get("provider_id"), + "provider_name": obj.get("provider_name"), + "agent_name": obj.get("agent_name"), + "client_kind": obj.get("client_kind"), + "purpose": obj.get("purpose"), + "source": obj.get("source") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/client_identity_evidence_inner.py b/generated/shieldlabs_generated/models/client_identity_evidence_inner.py new file mode 100644 index 0000000..d1b7d59 --- /dev/null +++ b/generated/shieldlabs_generated/models/client_identity_evidence_inner.py @@ -0,0 +1,121 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class ClientIdentityEvidenceInner(BaseModel): + """ + ClientIdentityEvidenceInner + """ # noqa: E501 + id: StrictStr + method: StrictStr + source_id: StrictStr + source_revision: StrictStr + checked_at: datetime + evaluated_at: datetime + covered_attributes: List[StrictStr] + covered_components: Optional[List[StrictStr]] = None + request_binding: Optional[StrictStr] = None + replay_policy: Optional[StrictStr] = None + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["id", "method", "source_id", "source_revision", "checked_at", "evaluated_at", "covered_attributes", "covered_components", "request_binding", "replay_policy"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ClientIdentityEvidenceInner from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ClientIdentityEvidenceInner from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "id": obj.get("id"), + "method": obj.get("method"), + "source_id": obj.get("source_id"), + "source_revision": obj.get("source_revision"), + "checked_at": obj.get("checked_at"), + "evaluated_at": obj.get("evaluated_at"), + "covered_attributes": obj.get("covered_attributes"), + "covered_components": obj.get("covered_components"), + "request_binding": obj.get("request_binding"), + "replay_policy": obj.get("replay_policy") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/client_identity_verified_inner.py b/generated/shieldlabs_generated/models/client_identity_verified_inner.py new file mode 100644 index 0000000..48a3eba --- /dev/null +++ b/generated/shieldlabs_generated/models/client_identity_verified_inner.py @@ -0,0 +1,106 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class ClientIdentityVerifiedInner(BaseModel): + """ + ClientIdentityVerifiedInner + """ # noqa: E501 + subject: StrictStr = Field(description="The proved attribute: provider, service or infrastructure. Proof does not extend to other attributes.") + value_id: StrictStr + evidence_ids: List[StrictStr] + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["subject", "value_id", "evidence_ids"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ClientIdentityVerifiedInner from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ClientIdentityVerifiedInner from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "subject": obj.get("subject"), + "value_id": obj.get("value_id"), + "evidence_ids": obj.get("evidence_ids") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/detection_flags.py b/generated/shieldlabs_generated/models/detection_flags.py index 7b1a1d4..6a3b228 100644 --- a/generated/shieldlabs_generated/models/detection_flags.py +++ b/generated/shieldlabs_generated/models/detection_flags.py @@ -26,7 +26,7 @@ class DetectionFlags(BaseModel): """ - Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and `javascript_disabled` are always `false`. + Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the extension flags depend on the scoring release. Task 204 adds optional ai_bot and ai_browser flags; older payloads omit them. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and `javascript_disabled` are always `false`. """ # noqa: E501 vpn: StrictBool = Field(description="A VPN was detected (scored `vpn` signal).") privacy_relay: StrictBool = Field(description="A privacy relay such as iCloud Private Relay was detected.") @@ -35,11 +35,11 @@ class DetectionFlags(BaseModel): proxy: StrictBool = Field(description="A proxy was detected.") datacenter_ip: StrictBool = Field(description="The public IP belongs to a datacenter or hosting range.") abuser: StrictBool = Field(description="The public IP has a record of abuse in IP intelligence.") - os_mismatch: StrictBool = Field(description="The operating system seen on the network differs from the one the browser reports.") + os_mismatch: StrictBool = Field(description="Either internal OS consistency check detected a mismatch. The public flag combines browser/network and TCP behaviour checks.") os_not_detected: StrictBool = Field(description="The operating system could not be determined from the User-Agent or the network.") timezone_mismatch: StrictBool = Field(description="The browser timezone differs from the timezone of the IP location.") anti_detect_browser: StrictBool = Field(description="An anti-detect browser was detected.") - browser_automation: StrictBool = Field(description="Browser automation was detected, for example a WebDriver-controlled browser.") + browser_automation: StrictBool = Field(description="Browser automation was detected, for example a WebDriver-controlled browser. Catalogue weight is 90 for task 204 scoring releases; historical result versions may retain the earlier weight 60.") ip_mismatch: StrictBool = Field(description="The public IP differs from the local IP found by the browser network check. Informational: it does not add to the score.") incognito: StrictBool = Field(description="The browser runs in a private window.") search_bot: StrictBool = Field(description="A search-engine crawler. Its Risk Score is always 0.") @@ -47,11 +47,13 @@ class DetectionFlags(BaseModel): javascript_disabled: StrictBool = Field(description="JavaScript, or the browser APIs the checks need, were unavailable.") stun_not_checked: StrictBool = Field(description="The browser network (STUN) check did not complete. Cleared again when a late network result arrives.") check_incomplete: StrictBool = Field(description="Part of the browser checks timed out, so the verdict rests on partial data. Informational.") - os_mismatch2: Optional[StrictBool] = None device_spoofing: Optional[StrictBool] = None latency_test: Optional[StrictBool] = None banned_ip: Optional[StrictBool] = None - __properties: ClassVar[List[str]] = ["vpn", "privacy_relay", "browser_vpn_proxy", "tor", "proxy", "datacenter_ip", "abuser", "os_mismatch", "os_not_detected", "timezone_mismatch", "anti_detect_browser", "browser_automation", "ip_mismatch", "incognito", "search_bot", "suspicious_paid_click", "javascript_disabled", "stun_not_checked", "check_incomplete", "os_mismatch2", "device_spoofing", "latency_test", "banned_ip"] + ai_bot: Optional[StrictBool] = Field(default=None, description="An accepted bot is classified as AI training or user-requested fetch. ChatGPT-User, Claude-User and Perplexity-User are AI bots, not AI browsers. Weight 0. The flag is optional on older payloads; its absence is not an evaluated negative result. Provider claims alone do not grant zero risk.") + ai_browser: Optional[StrictBool] = Field(default=None, description="A browser is identified by separate verified browser infrastructure. A generic browser User-Agent or a verified provider alone is insufficient. Weight 0. Optional on older payloads; absence is not an evaluated negative.") + os_mismatch2: Optional[StrictBool] = Field(default=None, description="Legacy 2026-10-06 only; current payloads combine this check into os_mismatch.") + __properties: ClassVar[List[str]] = ["vpn", "privacy_relay", "browser_vpn_proxy", "tor", "proxy", "datacenter_ip", "abuser", "os_mismatch", "os_not_detected", "timezone_mismatch", "anti_detect_browser", "browser_automation", "ip_mismatch", "incognito", "search_bot", "suspicious_paid_click", "javascript_disabled", "stun_not_checked", "check_incomplete", "device_spoofing", "latency_test", "banned_ip", "ai_bot", "ai_browser", "os_mismatch2"] model_config = ConfigDict( validate_by_name=True, @@ -123,10 +125,12 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "javascript_disabled": obj.get("javascript_disabled"), "stun_not_checked": obj.get("stun_not_checked"), "check_incomplete": obj.get("check_incomplete"), - "os_mismatch2": obj.get("os_mismatch2"), "device_spoofing": obj.get("device_spoofing"), "latency_test": obj.get("latency_test"), - "banned_ip": obj.get("banned_ip") + "banned_ip": obj.get("banned_ip"), + "ai_bot": obj.get("ai_bot"), + "ai_browser": obj.get("ai_browser"), + "os_mismatch2": obj.get("os_mismatch2") }) return _obj diff --git a/generated/shieldlabs_generated/models/history_row.py b/generated/shieldlabs_generated/models/history_row.py index 7312dd4..9ef8348 100644 --- a/generated/shieldlabs_generated/models/history_row.py +++ b/generated/shieldlabs_generated/models/history_row.py @@ -22,6 +22,7 @@ from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from uuid import UUID +from shieldlabs_generated.models.client_identity import ClientIdentity from typing import Optional, Set from typing_extensions import Self from pydantic_core import to_jsonable_python @@ -30,6 +31,7 @@ class HistoryRow(BaseModel): """ One identification as stored, in its latest version. It describes the same identification as a webhook `data` object, with different field names: | Webhook `data` | History row | |---|---| | `risk_score` | `score` | | `signals` | `score_details` (JSON-encoded string, zero weights included) | | `detection_flags` | the `is_*` columns and `check_incomplete` (each column names its flag) | | `detection_flags.browser_vpn_proxy` | derive it: `connection_type == \"browser_vpn_proxy\"` | | `domain` | `site_domain` when present, otherwise `domain` | | `public_ip` | `ip` (`0.0.0.0` instead of `\"\"`) and `country` | | `local_ip` | `webrtc_leak_ip` and `webrtc_leak_country` when `webrtc_leak_source` is set and not `none`, otherwise `web_rtc_ip` and `web_rtc_country` | | `traffic_source` | `traffic_channel`, `referrer_domain`, `entry_url`, `click_id_type`, `utm_*` (omitted when empty) | | `observed_at` (when scoring finished) | `created_at` (when the identification was made) | The `ip_mismatch` flag has no column. Rows also carry diagnostic network fields (TCP, MTU and STUN measurements) that are not part of the stable contract: ignore fields you do not know. """ # noqa: E501 + client_identity: Optional[ClientIdentity] = None request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") session_id: UUID = Field(description="One visit on one origin (UUID v4 created in the browser), shared by the open tabs of that origin. The next visit after the last tab closes gets a new session ID. The nil UUID appears on rate-limit marker rows.") cookie_id: UUID = Field(description="First-party browser identifier kept by the ShieldLabs agent (UUID v4). A missing or malformed value is stored as the nil UUID.") @@ -84,7 +86,7 @@ class HistoryRow(BaseModel): referrer_domain: Optional[StrictStr] = Field(default=None, description="Registrable domain of the referrer without `www.`; the crawler name (for example `GoogleBot`) for search-engine crawlers. Omitted when empty.") click_id_type: Optional[StrictStr] = Field(default=None, description="Ad click identifier type found in the landing URL. Omitted when empty.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["request_id", "session_id", "cookie_id", "domain", "site_domain", "user_hid", "device_id", "visitor_id", "ip", "os", "browser", "device_type", "country", "connection_type", "score", "score_details", "created_at", "ver", "web_rtc_ip", "web_rtc_country", "web_rtc_connection_type", "webrtc_leak_ip", "webrtc_leak_country", "webrtc_leak_connection_type", "webrtc_leak_source", "is_vpn", "is_tor", "is_proxy", "is_datacenter", "is_abuser", "is_privacy_relay", "is_stun_not_checked", "check_incomplete", "is_antidetect", "is_os_mismatch", "is_os_not_detected", "is_timezone_mismatch", "is_js_disabled", "is_browser_automation", "is_incognito", "is_search_bot", "is_suspicious_paid_click", "entry_url", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "traffic_channel", "traffic_channel_group", "traffic_reason", "referrer_domain", "click_id_type"] + __properties: ClassVar[List[str]] = ["client_identity", "request_id", "session_id", "cookie_id", "domain", "site_domain", "user_hid", "device_id", "visitor_id", "ip", "os", "browser", "device_type", "country", "connection_type", "score", "score_details", "created_at", "ver", "web_rtc_ip", "web_rtc_country", "web_rtc_connection_type", "webrtc_leak_ip", "webrtc_leak_country", "webrtc_leak_connection_type", "webrtc_leak_source", "is_vpn", "is_tor", "is_proxy", "is_datacenter", "is_abuser", "is_privacy_relay", "is_stun_not_checked", "check_incomplete", "is_antidetect", "is_os_mismatch", "is_os_not_detected", "is_timezone_mismatch", "is_js_disabled", "is_browser_automation", "is_incognito", "is_search_bot", "is_suspicious_paid_click", "entry_url", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "traffic_channel", "traffic_channel_group", "traffic_reason", "referrer_domain", "click_id_type"] @field_validator('created_at') def created_at_validate_regular_expression(cls, value): @@ -137,6 +139,9 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of client_identity + if self.client_identity: + _dict['client_identity'] = self.client_identity.to_dict() # puts key-value pairs in additional_properties in the top level if self.additional_properties is not None: for _key, _value in self.additional_properties.items(): @@ -154,6 +159,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ + "client_identity": ClientIdentity.from_dict(obj["client_identity"]) if obj.get("client_identity") is not None else None, "request_id": obj.get("request_id"), "session_id": obj.get("session_id"), "cookie_id": obj.get("cookie_id"), diff --git a/generated/shieldlabs_generated/models/hre_result.py b/generated/shieldlabs_generated/models/hre_result.py index 3f37f92..622b991 100644 --- a/generated/shieldlabs_generated/models/hre_result.py +++ b/generated/shieldlabs_generated/models/hre_result.py @@ -34,7 +34,8 @@ class HREResult(BaseModel): reason: StrictStr devices: Optional[Annotated[int, Field(strict=True, ge=0)]] = None min_devices: Optional[Annotated[int, Field(strict=True, ge=1)]] = None - __properties: ClassVar[List[str]] = ["status", "level", "reason", "devices", "min_devices"] + cluster_id: Optional[StrictStr] = Field(default=None, description="Authoritative cluster ID for this HRE result. Null when no cluster was published for the result, including old stored verdicts; never a device ID. Reused on retries.") + __properties: ClassVar[List[str]] = ["status", "level", "reason", "devices", "min_devices", "cluster_id"] model_config = ConfigDict( validate_by_name=True, @@ -80,6 +81,11 @@ def to_dict(self) -> Dict[str, Any]: if self.level is None and "level" in self.model_fields_set: _dict['level'] = None + # set to None if cluster_id (nullable) is None + # and model_fields_set contains the field + if self.cluster_id is None and "cluster_id" in self.model_fields_set: + _dict['cluster_id'] = None + return _dict @classmethod @@ -96,7 +102,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "level": obj.get("level"), "reason": obj.get("reason"), "devices": obj.get("devices"), - "min_devices": obj.get("min_devices") + "min_devices": obj.get("min_devices"), + "cluster_id": obj.get("cluster_id") }) return _obj diff --git a/generated/shieldlabs_generated/models/identification_scored_data.py b/generated/shieldlabs_generated/models/identification_scored_data.py index d20b64a..2b691c5 100644 --- a/generated/shieldlabs_generated/models/identification_scored_data.py +++ b/generated/shieldlabs_generated/models/identification_scored_data.py @@ -23,8 +23,8 @@ from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from uuid import UUID +from shieldlabs_generated.models.client_identity import ClientIdentity from shieldlabs_generated.models.detection_flags import DetectionFlags -from shieldlabs_generated.models.fingerprint import Fingerprint from shieldlabs_generated.models.hre import HRE from shieldlabs_generated.models.ip_info import IpInfo from shieldlabs_generated.models.risk_event import RiskEvent @@ -36,8 +36,9 @@ class IdentificationScoredData(BaseModel): """ - Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted. + Final identification. risk_score is this scan only. Version 2026-10-07 uses signals for score contributions, detection_flags for final decisions, and hre for account results. Older bodies remain accepted. """ # noqa: E501 + client_identity: Optional[ClientIdentity] = None request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") visitor_id: UUID = Field(description="Server-side visitor identifier (UUID v5). It is sticky to the device: a new cookie on a known device keeps the existing visitor ID, so clearing cookies usually does not change it. The nil UUID appears on identifications without usable device data, such as rate-limit marker rows.") device_id: UUID = Field(description="Server-side device identifier (UUID v5). It survives cleared cookies and private windows. The nil UUID `00000000-0000-0000-0000-000000000000` means that no usable device signals were collected (for example on rate-limit marker rows): never group identifications by it.") @@ -58,10 +59,12 @@ class IdentificationScoredData(BaseModel): observed_at: datetime = Field(description="Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits.") result_version: Optional[Annotated[str, Field(min_length=1, strict=True)]] = None scoring_version: Optional[StrictStr] = Field(default=None, description="Core build source revision; core:unversioned on local builds.") - risk_events: Optional[List[RiskEvent]] = None + risk_events: Optional[List[RiskEvent]] = Field(default=None, description="Legacy 2026-10-06 only; absent from current scored events.") hre: Optional[HRE] = None - fingerprint: Optional[Fingerprint] = None - __properties: ClassVar[List[str]] = ["request_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at", "result_version", "scoring_version", "risk_events", "hre", "fingerprint"] + search_bot_owner: Optional[StrictStr] = Field(default=None, description="Owner label from accepted search-bot detection; omitted when unknown or inactive.") + ai_bot_owner: Optional[StrictStr] = Field(default=None, description="Provider company label from accepted AI-bot detection; omitted when unknown or inactive.") + ai_browser_owner: Optional[StrictStr] = Field(default=None, description="Owner label from accepted AI-browser detection; omitted when unknown or inactive.") + __properties: ClassVar[List[str]] = ["client_identity", "request_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at", "result_version", "scoring_version", "risk_events", "hre", "search_bot_owner", "ai_bot_owner", "ai_browser_owner"] @field_validator('observed_at') def observed_at_validate_regular_expression(cls, value): @@ -112,6 +115,9 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of client_identity + if self.client_identity: + _dict['client_identity'] = self.client_identity.to_dict() # override the default output from pydantic by calling `to_dict()` of public_ip if self.public_ip: _dict['public_ip'] = self.public_ip.to_dict() @@ -141,9 +147,6 @@ def to_dict(self) -> Dict[str, Any]: # override the default output from pydantic by calling `to_dict()` of hre if self.hre: _dict['hre'] = self.hre.to_dict() - # override the default output from pydantic by calling `to_dict()` of fingerprint - if self.fingerprint: - _dict['fingerprint'] = self.fingerprint.to_dict() # set to None if user_hid (nullable) is None # and model_fields_set contains the field if self.user_hid is None and "user_hid" in self.model_fields_set: @@ -161,6 +164,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ + "client_identity": ClientIdentity.from_dict(obj["client_identity"]) if obj.get("client_identity") is not None else None, "request_id": obj.get("request_id"), "visitor_id": obj.get("visitor_id"), "device_id": obj.get("device_id"), @@ -183,7 +187,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "scoring_version": obj.get("scoring_version"), "risk_events": [RiskEvent.from_dict(_item) for _item in obj["risk_events"]] if obj.get("risk_events") is not None else None, "hre": HRE.from_dict(obj["hre"]) if obj.get("hre") is not None else None, - "fingerprint": Fingerprint.from_dict(obj["fingerprint"]) if obj.get("fingerprint") is not None else None + "search_bot_owner": obj.get("search_bot_owner"), + "ai_bot_owner": obj.get("ai_bot_owner"), + "ai_browser_owner": obj.get("ai_browser_owner") }) return _obj diff --git a/generated/shieldlabs_generated/models/identification_scored_event.py b/generated/shieldlabs_generated/models/identification_scored_event.py index be42015..d60b91e 100644 --- a/generated/shieldlabs_generated/models/identification_scored_event.py +++ b/generated/shieldlabs_generated/models/identification_scored_event.py @@ -32,7 +32,7 @@ class IdentificationScoredEvent(BaseModel): Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header. """ # noqa: E501 event_type: StrictStr = Field(description="Event type. Ignore events whose type you do not know instead of failing.") - schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.") + schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Scored release 2026-10-06; multi-account group release 2026-10-07; parsers also accept legacy 2026-06-01.") created_at: datetime = Field(description="RFC 3339 timestamp in UTC with up to 9 fractional digits (trailing zeros trimmed), for example `2026-09-30T12:34:57.482913041Z`. Parse it with a parser that accepts nanoseconds.") data: IdentificationScoredData event_id: Optional[Annotated[str, Field(min_length=1, strict=True)]] = Field(default=None, description="Logical site/request/final-result-version/type identity. Stable across retries and endpoints.") diff --git a/generated/shieldlabs_generated/models/multiaccount_changed_data.py b/generated/shieldlabs_generated/models/multiaccount_changed_data.py new file mode 100644 index 0000000..668f756 --- /dev/null +++ b/generated/shieldlabs_generated/models/multiaccount_changed_data.py @@ -0,0 +1,210 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from uuid import UUID +from shieldlabs_generated.models.multiaccount_changed_data_evidence_summary import MultiaccountChangedDataEvidenceSummary +from shieldlabs_generated.models.multiaccount_changed_data_source import MultiaccountChangedDataSource +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class MultiaccountChangedData(BaseModel): + """ + MultiaccountChangedData + """ # noqa: E501 + kind: Optional[Any] + domain: Annotated[str, Field(min_length=1, strict=True)] + action: StrictStr + status: Optional[Any] = Field(description="The result comes from a complete, verified detector source cut. Pending or unavailable work does not produce this event.") + detected: StrictBool + level: Optional[StrictStr] + cluster_id: UUID = Field(description="ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group.") + revision: Annotated[str, Field(strict=True)] = Field(description="Publication version of this transition, as a decimal string to preserve UInt64 precision.") + previous_revision: Annotated[str, Field(strict=True)] = Field(description="Previous transition version while the group was active; null for a newly detected or reappearing group.") + user_hids: Annotated[List[Annotated[str, Field(min_length=1, strict=True)]], Field(min_length=3)] = Field(description="Complete sorted group membership. Users from another site are never included. This list is not truncated.") + users_count: Annotated[int, Field(strict=True, ge=3)] = Field(description="Equal to the length of user_hids.") + members_complete: Optional[Any] + evaluated_at: datetime = Field(description="RFC 3339 timestamp in UTC with up to 9 fractional digits (trailing zeros trimmed), for example `2026-09-30T12:34:57.482913041Z`. Parse it with a parser that accepts nanoseconds.") + rules_version: Annotated[str, Field(min_length=1, strict=True)] + source: MultiaccountChangedDataSource + evidence_summary: MultiaccountChangedDataEvidenceSummary + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["kind", "domain", "action", "status", "detected", "level", "cluster_id", "revision", "previous_revision", "user_hids", "users_count", "members_complete", "evaluated_at", "rules_version", "source", "evidence_summary"] + + @field_validator('action') + def action_validate_enum(cls, value): + """Validates the enum""" + if value not in set(['detected', 'updated', 'resolved']): + raise ValueError("must be one of enum values ('detected', 'updated', 'resolved')") + return value + + @field_validator('level') + def level_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['medium', 'high']): + raise ValueError("must be one of enum values ('medium', 'high')") + return value + + @field_validator('revision') + def revision_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[1-9][0-9]{0,19}$", value): + raise ValueError(r"must validate the regular expression /^[1-9][0-9]{0,19}$/") + return value + + @field_validator('previous_revision') + def previous_revision_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^(0|[1-9][0-9]{0,19})$", value): + raise ValueError(r"must validate the regular expression /^(0|[1-9][0-9]{0,19})$/") + return value + + @field_validator('evaluated_at') + def evaluated_at_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$", value): + raise ValueError(r"must validate the regular expression /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$/") + return value + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MultiaccountChangedData from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of source + if self.source: + _dict['source'] = self.source.to_dict() + # override the default output from pydantic by calling `to_dict()` of evidence_summary + if self.evidence_summary: + _dict['evidence_summary'] = self.evidence_summary.to_dict() + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + # set to None if kind (nullable) is None + # and model_fields_set contains the field + if self.kind is None and "kind" in self.model_fields_set: + _dict['kind'] = None + + # set to None if status (nullable) is None + # and model_fields_set contains the field + if self.status is None and "status" in self.model_fields_set: + _dict['status'] = None + + # set to None if level (nullable) is None + # and model_fields_set contains the field + if self.level is None and "level" in self.model_fields_set: + _dict['level'] = None + + # set to None if members_complete (nullable) is None + # and model_fields_set contains the field + if self.members_complete is None and "members_complete" in self.model_fields_set: + _dict['members_complete'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MultiaccountChangedData from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "kind": obj.get("kind"), + "domain": obj.get("domain"), + "action": obj.get("action"), + "status": obj.get("status"), + "detected": obj.get("detected"), + "level": obj.get("level"), + "cluster_id": obj.get("cluster_id"), + "revision": obj.get("revision"), + "previous_revision": obj.get("previous_revision"), + "user_hids": obj.get("user_hids"), + "users_count": obj.get("users_count"), + "members_complete": obj.get("members_complete"), + "evaluated_at": obj.get("evaluated_at"), + "rules_version": obj.get("rules_version"), + "source": MultiaccountChangedDataSource.from_dict(obj["source"]) if obj.get("source") is not None else None, + "evidence_summary": MultiaccountChangedDataEvidenceSummary.from_dict(obj["evidence_summary"]) if obj.get("evidence_summary") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/multiaccount_changed_data_evidence_summary.py b/generated/shieldlabs_generated/models/multiaccount_changed_data_evidence_summary.py new file mode 100644 index 0000000..163d145 --- /dev/null +++ b/generated/shieldlabs_generated/models/multiaccount_changed_data_evidence_summary.py @@ -0,0 +1,93 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class MultiaccountChangedDataEvidenceSummary(BaseModel): + """ + MultiaccountChangedDataEvidenceSummary + """ # noqa: E501 + basis_types: List[StrictStr] + rules: List[StrictStr] + detail_truncated: StrictBool = Field(description="Whether the detector's underlying detailed evidence was truncated. Group membership remains complete.") + __properties: ClassVar[List[str]] = ["basis_types", "rules", "detail_truncated"] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MultiaccountChangedDataEvidenceSummary from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MultiaccountChangedDataEvidenceSummary from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "basis_types": obj.get("basis_types"), + "rules": obj.get("rules"), + "detail_truncated": obj.get("detail_truncated") + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/multiaccount_changed_data_source.py b/generated/shieldlabs_generated/models/multiaccount_changed_data_source.py new file mode 100644 index 0000000..7cfc63c --- /dev/null +++ b/generated/shieldlabs_generated/models/multiaccount_changed_data_source.py @@ -0,0 +1,119 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class MultiaccountChangedDataSource(BaseModel): + """ + MultiaccountChangedDataSource + """ # noqa: E501 + publication_epoch: Optional[Any] + publication_version: Annotated[str, Field(strict=True)] = Field(description="The exact immutable publication containing this result and event.") + coverage_id: Annotated[str, Field(strict=True)] = Field(description="Immutable binding to the trusted source completeness proof.") + __properties: ClassVar[List[str]] = ["publication_epoch", "publication_version", "coverage_id"] + + @field_validator('publication_version') + def publication_version_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[1-9][0-9]{0,19}$", value): + raise ValueError(r"must validate the regular expression /^[1-9][0-9]{0,19}$/") + return value + + @field_validator('coverage_id') + def coverage_id_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[a-f0-9]{64}$", value): + raise ValueError(r"must validate the regular expression /^[a-f0-9]{64}$/") + return value + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MultiaccountChangedDataSource from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if publication_epoch (nullable) is None + # and model_fields_set contains the field + if self.publication_epoch is None and "publication_epoch" in self.model_fields_set: + _dict['publication_epoch'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MultiaccountChangedDataSource from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "publication_epoch": obj.get("publication_epoch"), + "publication_version": obj.get("publication_version"), + "coverage_id": obj.get("coverage_id") + }) + return _obj + + diff --git a/generated/shieldlabs_generated/models/multiaccount_changed_event.py b/generated/shieldlabs_generated/models/multiaccount_changed_event.py new file mode 100644 index 0000000..a62e2ff --- /dev/null +++ b/generated/shieldlabs_generated/models/multiaccount_changed_event.py @@ -0,0 +1,148 @@ +# coding: utf-8 + +""" + ShieldLabs API + + Identification results and risk scoring for your backend. + + The version of the OpenAPI document: 1.0.1 + Contact: contact@shieldlabs.ai + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from shieldlabs_generated.models.multiaccount_changed_data import MultiaccountChangedData +from typing import Optional, Set +from typing_extensions import Self +from pydantic_core import to_jsonable_python + +class MultiaccountChangedEvent(BaseModel): + """ + An immutable opt-in notification of a complete multi-account group transition. + """ # noqa: E501 + event_id: Annotated[str, Field(strict=True)] = Field(description="Stable logical event ID. Delivery retries preserve this ID and the signed body.") + event_type: Optional[Any] + schema_version: Optional[Any] + site_id: Annotated[int, Field(strict=True, ge=1)] + created_at: datetime = Field(description="RFC 3339 timestamp in UTC with up to 9 fractional digits (trailing zeros trimmed), for example `2026-09-30T12:34:57.482913041Z`. Parse it with a parser that accepts nanoseconds.") + data: MultiaccountChangedData + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["event_id", "event_type", "schema_version", "site_id", "created_at", "data"] + + @field_validator('event_id') + def event_id_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[a-f0-9]{40}$", value): + raise ValueError(r"must validate the regular expression /^[a-f0-9]{40}$/") + return value + + @field_validator('created_at') + def created_at_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not isinstance(value, str): + value = str(value) + + if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$", value): + raise ValueError(r"must validate the regular expression /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$/") + return value + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return json.dumps(to_jsonable_python(self.to_dict())) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MultiaccountChangedEvent from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of data + if self.data: + _dict['data'] = self.data.to_dict() + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + # set to None if event_type (nullable) is None + # and model_fields_set contains the field + if self.event_type is None and "event_type" in self.model_fields_set: + _dict['event_type'] = None + + # set to None if schema_version (nullable) is None + # and model_fields_set contains the field + if self.schema_version is None and "schema_version" in self.model_fields_set: + _dict['schema_version'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MultiaccountChangedEvent from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "event_id": obj.get("event_id"), + "event_type": obj.get("event_type"), + "schema_version": obj.get("schema_version"), + "site_id": obj.get("site_id"), + "created_at": obj.get("created_at"), + "data": MultiaccountChangedData.from_dict(obj["data"]) if obj.get("data") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/generated/shieldlabs_generated/models/risk_event.py b/generated/shieldlabs_generated/models/risk_event.py index cd432f9..401d461 100644 --- a/generated/shieldlabs_generated/models/risk_event.py +++ b/generated/shieldlabs_generated/models/risk_event.py @@ -28,7 +28,7 @@ class RiskEvent(BaseModel): """ RiskEvent """ # noqa: E501 - code: StrictStr + code: StrictStr = Field(description="Open catalogue of independent source signals. ai_bot and ai_browser carry weight 0; search_bot remains separate. browser_automation carries weight 90 in task 204 releases. UI Good bot and Bad bot groups are not risk event codes. Keep unknown codes and use the payload scoring_version/result_version for historical interpretation.") detected: StrictBool = Field(description="Final scoring flag. false does not assert that every underlying probe completed.") weight: StrictInt = Field(description="Catalogue weight, not an additive score. Banned IP 999 is a marker.") contribution: StrictInt = Field(description="Matching score details, may contain corrections. Never recompute risk_score by summing.") diff --git a/generated/shieldlabs_generated/models/signal.py b/generated/shieldlabs_generated/models/signal.py index 38e490e..551a398 100644 --- a/generated/shieldlabs_generated/models/signal.py +++ b/generated/shieldlabs_generated/models/signal.py @@ -29,7 +29,7 @@ class Signal(BaseModel): """ One weighted risk signal behind the Risk Score. Only signals with a non-zero weight are listed, in scoring order. The same name can appear twice, and weights can be negative. """ # noqa: E501 - name: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Signal name. The set is open: new names can appear at any time, so keep unknown names and use them for display and logging only. Known names: - `tor`: the request came through Tor; - `vpn`: a VPN was detected; - `privacy_relay`: a privacy relay such as iCloud Private Relay; - `proxy`: a proxy was detected; - `datacenter_ip`: the IP belongs to a datacenter or hosting range; - `abuser`: the IP has a record of abuse; - `browser_vpn_proxy`: a VPN or proxy inside the browser; - `antidetect_browser`: an anti-detect browser (the matching flag is `anti_detect_browser`); - `proxy_routed_antidetect`: the network check was routed through a proxy in a way typical for anti-detect browsers; - `port_scan_routed_via_proxy`: `proxy_routed_antidetect` carried forward from an earlier identification of the same device and IP; - `browser_automation`: browser automation, for example a WebDriver-controlled browser; - `javascript_disabled`: JavaScript or the browser APIs the checks need were unavailable; - `os_mismatch`: the operating system seen on the network differs from the one the browser reports; - `os_not_detected`: the operating system could not be determined; - `timezone_mismatch`: the browser timezone differs from the IP location timezone; - `stun_not_checked`: the network (STUN) check did not complete; - `stun_late_correction`: a late network result arrived; negative weight that cancels `stun_not_checked`; - `rate_limited`: the rate-limit marker, weight 999. A verdict carried forward from an earlier identification of the same device and IP (for example `antidetect_browser`) keeps a name derived from the original signal and can carry a partial weight.") + name: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Signal name. The set is open: new names can appear at any time, so keep unknown names and use them for display and logging only. Known names: - `tor`: the request came through Tor; - `vpn`: a VPN was detected; - `privacy_relay`: a privacy relay such as iCloud Private Relay; - `proxy`: a proxy was detected; - `datacenter_ip`: the IP belongs to a datacenter or hosting range; - `abuser`: the IP has a record of abuse; - `browser_vpn_proxy`: a VPN or proxy inside the browser; - `antidetect_browser`: an anti-detect browser (the matching flag is `anti_detect_browser`); - `proxy_routed_antidetect`: the network check was routed through a proxy in a way typical for anti-detect browsers; - `port_scan_routed_via_proxy`: `proxy_routed_antidetect` carried forward from an earlier identification of the same device and IP; - `browser_automation`: browser automation, for example a WebDriver-controlled browser (90 in task 204 scoring releases); - `javascript_disabled`: JavaScript or the browser APIs the checks need were unavailable; - `os_mismatch`: the operating system seen on the network differs from the one the browser reports; - `os_not_detected`: the operating system could not be determined; - `timezone_mismatch`: the browser timezone differs from the IP location timezone; - `stun_not_checked`: the network (STUN) check did not complete; - `stun_late_correction`: a late network result arrived; negative weight that cancels `stun_not_checked`; - `rate_limited`: the rate-limit marker, weight 999. A verdict carried forward from an earlier identification of the same device and IP (for example `antidetect_browser`) keeps a name derived from the original signal and can carry a partial weight.") weight: StrictInt = Field(description="Points the signal contributed. Can be negative (`stun_late_correction` is -30) and is 999 for `rate_limited`. Weights can change between releases; never add them up yourself.") __properties: ClassVar[List[str]] = ["name", "weight"] diff --git a/generated/shieldlabs_generated/models/webhook_ping_event.py b/generated/shieldlabs_generated/models/webhook_ping_event.py index 45f432e..65569f2 100644 --- a/generated/shieldlabs_generated/models/webhook_ping_event.py +++ b/generated/shieldlabs_generated/models/webhook_ping_event.py @@ -31,7 +31,7 @@ class WebhookPingEvent(BaseModel): Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys arrive sorted alphabetically and `created_at` has second precision. """ # noqa: E501 event_type: StrictStr = Field(description="Event type.") - schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.") + schema_version: Annotated[str, Field(min_length=1, strict=True)] = Field(description="Webhook contract version. Scored release 2026-10-06; multi-account group release 2026-10-07; parsers also accept legacy 2026-06-01.") created_at: datetime = Field(description="When the ping was sent, with second precision.") event_id: Optional[Annotated[str, Field(min_length=1, strict=True)]] = None __properties: ClassVar[List[str]] = ["event_type", "schema_version", "created_at", "event_id"]