Metadata-Version: 2.4
Name: verdettoqr-threat
Version: 0.1.2
Summary: Check links, domains and crypto addresses against Verdetto's listings and other sources, mostly with no request.
Author-email: Verdetto <support@verdettoqr.com>
License-Expression: MIT
Project-URL: Homepage, https://verdettoqr.com/api
Project-URL: Documentation, https://verdettoqr.com/api/docs
Project-URL: Source, https://gitlab.com/verdettoqr/verdettoqr-threat
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: fast
Requires-Dist: cryptography>=42; extra == "fast"
Dynamic: license-file

# Verdetto Threat API: Python SDK

Check links, domains and crypto addresses against Verdetto's listings and other sources. The signed lists sit on your machine, so most checks send nothing.

## Install

```sh
pip install verdettoqr-threat==0.1.2
```

## First check

```python
from verdettoqr_threat import Verdetto

v = Verdetto()
a = v.check("https://listed.test.verdettoqr.com/login")
print(a["status"])  # "listed"
```

No key and no setup. A test host is answered with no request; for other links, the first check downloads the lists, and a check after an hour refreshes them.

`not_listed` means no current listing matched, nothing more.

## Live checks

```python
v = Verdetto("vtk_open_...")  # or set VERDETTO_API_KEY
a = v.check_live("https://example.com/sign-in")
```

Get an Open key at [verdettoqr.com/api](https://verdettoqr.com/api#key). A timeout and retries are set for you.

## Answers

Every answer is a dict with `status`, `checked_at` and `matches`.

| `status` | Meaning |
|---|---|
| `listed` | One of our listings matched. |
| `listed_by` | Another source matched, ours did not. |
| `not_listed` | No source matched. |
| `snapshot_stale` | The lists are past their age and nothing was sent; see `code`. |
| `rate_limited` | Held to `retry_after_s`. |
| `error` | See `code`; `retry_after_s` when the API sent one. |

`is_listed(a)` is true for `listed` and `listed_by`. `route` is `local` or `live`; past the lists' age, `live_on_stale` asks the API.

A local match of ours carries `listing_id` in place of `category`; `with_evidence(a)` adds its category and evidence.

`history()` answers `ok` with `listings` in place of `matches`; `truncated` when more than 60 match.

## Errors

A lookup does not raise: an answer that is not a verdict carries `code`.

| `code` | What to do |
|---|---|
| `invalid_input` | Not a link, domain or address. Nothing was sent. |
| `network` | No answer came. Try again later. |
| `http_error` | The answer was not the API's. Try again later. |
| `rate_limited` | Wait `retry_after_s`. |
| `held` | An earlier 429 holds it. Wait `retry_after_s`. Nothing was sent. |
| `never_synced` | No lists yet. Call `sync()`. |
| `stale` | The lists are past their age. Call `sync()`, or set `live_on_stale`. |

Any other `code` is the API's, with `http_status`: see [Error codes](https://verdettoqr.com/api/docs/errors).

An API `code` comes with its `title` and `detail`, in your `language`; the SDK's own codes have neither.

A failed sync keeps the lists it holds and tries again later. `sync()` gives its `code`; `snapshot_stale` carries it.

| `code` | What to do |
|---|---|
| `fetch_failed` | The lists' host did not answer. It tries again. |
| `not_in_manifest` | A file is not in the signed manifest. It tries again. |
| `file_mismatch` | A file does not match its hash. It tries again. |
| `unparsable` | A manifest does not parse. It tries again. |
| `signature` | A manifest is not under the pinned key. Update the SDK. |
| `no_suffix_list` | The lists have no suffix list. It tries again. |
| `format` | A format this version cannot read. Update the SDK. |
| `expired` | The lists have expired. It tries again. |
| `older` | Older than the lists held. It tries again. |
| `error` | Another fault; `reason` says which. It tries again. |

`Retry-After`:

- A 429 is not sent again. Live calls wait its `Retry-After` and answer `rate_limited`, code `held`, until then. With the hint `batch`, single calls alone wait.
- A 5xx with the API's `code` comes back at once, with `retry_after_s`.
- A network fault, or a 5xx without a `code`, is sent again up to `max_retries` times. A `Retry-After` up to 30 s is waited out; a longer one comes back as `retry_after_s`.

## Methods

| Method | Does |
|---|---|
| `check(url)` | A link, from the lists. |
| `check_many(urls)` | Many links, in order. |
| `check_domain(domain)` | A domain, from the lists. |
| `check_address(address)` | A crypto address, from the lists. |
| `check_live(url)` | A link, from the API. |
| `check_live_many(urls)` | Many links, from the API, in batches. |
| `history(url_or_domain)` | Our listings and their dates, from the API. |
| `with_evidence(a)` | Our listings' evidence for an answer. |
| `record(listing_id)` | One listing's record. |
| `sync()` | Fetch the lists now. |
| `snapshot()` | The lists' version and age. |

## Options

```python
v = Verdetto("vtk_open_...", live_on_stale=True, timeout_s=5)
```

| Option | Default |
|---|---|
| `key` | `VERDETTO_API_KEY` |
| `live_on_stale` | `False` |
| `timeout_s` | `10` |
| `max_retries` | `2` |
| `max_age_s` | `10800` (3 h) |
| `auto_refresh` | `True` |
| `base` | `https://api.verdettoqr.com/` |
| `bundle_base` | `https://bundle.verdettoqr.com/` |
| `fetch` | `urllib` |
| `language` | none |
| `public_key`, `retry_base_s`, `own_full_max`, `now`, `sleep` | for tests and other signers |

`language`: a BCP 47 tag or a list, first preferred, sent to the API as `Accept-Language`: `language=["de-AT", "en"]`.

## What is sent

- The lists: GETs from the bundle host, with no key and no input: `list.json`, the suffix list, `api/list.json` and the files it names.
- `check`, `check_many`, `check_domain`, `check_address`: nothing. In a list past 50,000 of our listings, a hit reads one file named by 3 hex digits of its hash.
- With `live_on_stale`, a link or domain past the lists' age goes to the API.
- `record`, `with_evidence`: the record index once, then the record's file, from the bundle host.
- `check_live`, `check_live_many`, `history`: a POST to the API with the input and the key. An input it would refuse is not sent.
- Every request: `User-Agent: verdettoqr-threat-python/0.1.2`. The API's also: `Content-Type: application/json`, `Authorization: Bearer <key>`, and `Accept-Language` when `language` is set.
- Your HTTP stack adds its own, such as `Host`.

## Test hosts

`listed.test.verdettoqr.com` answers `listed`. `clear.test.verdettoqr.com` answers `not_listed`.

## Runs on

Python 3.11 and up. One client may be shared across threads. No dependencies; `pip install verdettoqr-threat[fast]` adds `cryptography` for a faster signature check.

## License

Code: MIT. Our listings: CC0 1.0. Other sources: their own terms, in the lists' manifest.
