Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,59 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.2.0] - unreleased

### Added

- **On-demand scope API**, on both `Onyphe` and `AsyncOnyphe`: active scanning
of an IP, a CIDR or a domain, one target at a time or in bulk, plus result
retrieval. **Needs an On-demand subscription**; without it the call comes
back as a `PaymentRequiredError`.

```python
launch = api.ondemand_scope_ip("8.8.8.0/24", vulnscan=True, ports=[80, 443])
page = api.ondemand_scope_result(scan_id)
```

- `ondemand_scope_ip(ip, ...)` — `POST /ondemand/scope/ip/single`
- `ondemand_scope_domain(domain, ...)` — `POST /ondemand/scope/domain/single`
- `ondemand_scope_ip_bulk(ips, ...)` — `POST /dev/ondemand/scope/ip/bulk`
- `ondemand_scope_domain_bulk(domains, ...)` — `POST /dev/ondemand/scope/domain/bulk`
- `ondemand_scope_result(scan_id)` — `GET /ondemand/scope/result/{scan_id}`

The two bulk endpoints live under ONYPHE's `/dev/` prefix and may move or
change shape without notice. `/ondemand/scope/domain/single` is not
documented upstream either: it is deduced by analogy with `ip/single` and
isolated in a single constant.

The four launch methods share the optional `import_results`, `vulnscan`,
`urlscan`, `ports` and `maxscantime` arguments, and send a key only when one
is given. `import_results` is spelled out because `import` is a Python
keyword — **it publishes the results in the ONYPHE dataset, where every
ONYPHE user can see them.**

A launch returns the raw envelope: ONYPHE documents that a Scan ID comes
back, but not the field it comes back in, so nothing is parsed out of it.
No polling helper either, for now.

The four launches are never retried automatically — a POST replayed after a
429, a 5xx or a transport failure would start a second scan, burning credits
or earning an error 9 (`Scan is already running`). `send()` grew a
keyword-only `retry: bool = True` for that; result retrieval, a GET, keeps
the usual retry behaviour.

- `ScanInProgressError`, raised by `ondemand_scope_result` on ONYPHE error
codes 103 (`Scan ID is in progress`) and 111 (`Scan ID results are being
built`), whatever HTTP status carries them. It subclasses `APIError` and
carries `.scan_id`, so "retry later" is distinguishable from "failed". Every
other error code keeps the existing mapping.

- CLI: `pyonyphe ondemand ip|domain|ip-bulk|domain-bulk|result`, with
`--import/--no-import`, `--vulnscan/--no-vulnscan`,
`--urlscan/--no-urlscan`, `--ports` and `--maxscantime`. The two bulk
commands read one target per line from a file. `ondemand result` exits with
3 while the scan is still running.

## [3.1.0] - 2026-08-04

### Changed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ pyonyphe simple whois 8.8.8.8 --best
pyonyphe resolve example.com
pyonyphe bulk simple datascan ips.txt -o out.ndjson
pyonyphe alert list

# active scanning, On-demand subscription required
pyonyphe ondemand ip 8.8.8.0/24 --vulnscan --ports 80,443
pyonyphe ondemand result SCAN_ID
```

## Configuration
Expand Down
57 changes: 56 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,60 @@ Bulk Simple categories are the Simple ones minus `onionscan` and `onionshot`.
`source` accepts a `Path`, a path string, a raw string, an iterable of assets,
or bytes.

## On-demand scan

Active scanning, **On-demand subscription required** — every other API on this
page only reads data ONYPHE already collected.

| method | HTTP | endpoint |
| --- | --- | --- |
| `ondemand_scope_ip(ip, ...)` | POST | `/ondemand/scope/ip/single` |
| `ondemand_scope_domain(domain, ...)` | POST | `/ondemand/scope/domain/single` |
| `ondemand_scope_ip_bulk(ips, ...)` | POST | `/dev/ondemand/scope/ip/bulk` |
| `ondemand_scope_domain_bulk(domains, ...)` | POST | `/dev/ondemand/scope/domain/bulk` |
| `ondemand_scope_result(scan_id)` | GET | `/ondemand/scope/result/{scan_id}` |

`ip` is `X.Y.Z.K` or `X.Y.Z.K/24`. The two bulk methods take any iterable of
strings and join it with commas themselves.

The two bulk endpoints live under the `/dev/` prefix (`DEV_PREFIX`), which
ONYPHE serves from its development tree: they may move or change shape without
notice. `/ondemand/scope/domain/single` is **not** documented by ONYPHE
either — it is deduced by analogy with `ip/single`, and kept in a single
constant (`ONDEMAND_SCOPE_DOMAIN_PATH`) so that one edit fixes it if it turns
out to be wrong.

The four launch methods share these optional arguments, and send a key only
when you pass one:

| argument | sent as | value |
| --- | --- | --- |
| `import_results` | `import` | `"true"` / `"false"` |
| `vulnscan` | `vulnscan` | `"true"` / `"false"` |
| `urlscan` | `urlscan` | `"true"` / `"false"` |
| `ports` | `ports` | `[80, 443]` becomes `"80,443"` |
| `maxscantime` | `maxscantime` | an integer, in seconds |

`import_results` is named that way because `import` is a Python keyword.
**Setting it publishes the scan results in the ONYPHE dataset, where every
ONYPHE user can see them.**

A launch returns the raw `Response`: ONYPHE documents that it carries a Scan
ID but not under which field name, so nothing is parsed out of it. Read it
from `response.model_dump()` and feed it back to `ondemand_scope_result`.

The four launch methods are sent exactly once and never retried automatically,
on a 429, a 5xx or a transport failure alike: replaying the POST would start a
second scan. `ondemand_scope_result` is a GET and keeps the usual retry
behaviour.

`ondemand_scope_result` raises `ScanInProgressError` when ONYPHE answers with
error code 103 (`Scan ID is in progress`) or 111 (`Scan ID results are being
built`), whatever HTTP status carries it. That is not a failure: the same
`scan_id` is worth asking for again later. The exception carries `.scan_id`.
Every other error code goes through the usual mapping. There is no polling
helper yet.

## Alerts

| method | HTTP | endpoint |
Expand Down Expand Up @@ -87,11 +141,12 @@ OnypheError
├── PaymentRequiredError 402
├── NotFoundError 404
├── RateLimitError 429 (.retry_after)
├── ScanInProgressError ONYPHE error 103 / 111 (.scan_id)
└── ServerError 5xx
```

## Constants

`SIMPLE_CATEGORIES`, `BEST_CATEGORIES`, `BULK_SIMPLE_CATEGORIES`,
`SUMMARY_KINDS`, `SEARCH_MAX_RESULTS` (10000), `DEFAULT_BASE_URL`,
`UNRATED_BASE_URL`.
`UNRATED_BASE_URL`, `SCAN_IN_PROGRESS_CODES` (103, 111), `DEV_PREFIX`.
46 changes: 46 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,52 @@ pyonyphe bulk simple whois ips.txt --best

One OQL query per line, all run against the given category. Griffin View only.

### `ondemand ip IP | ondemand domain DOMAIN`

Launch an active scan. **Needs an On-demand subscription.** Prints the raw
ONYPHE envelope, which carries the Scan ID to pass to `ondemand result`.

```bash
pyonyphe ondemand ip 8.8.8.8
pyonyphe ondemand ip 8.8.8.0/24 --vulnscan --ports 80,443 --maxscantime 120
pyonyphe ondemand domain example.com --urlscan
```

| option | meaning |
| --- | --- |
| `--import` / `--no-import` | import the results into the ONYPHE dataset |
| `--vulnscan` / `--no-vulnscan` | also run the vulnerability scan |
| `--urlscan` / `--no-urlscan` | also crawl the HTTP services found |
| `--ports 80,443` | ports to scan instead of the ONYPHE default |
| `--maxscantime 120` | scan budget, in seconds |

Each option is sent only when given: passing none of them leaves every
default to ONYPHE. `--import` **makes the results PUBLIC** — they are added to
the ONYPHE dataset, where every ONYPHE user can see them.

### `ondemand ip-bulk FILE | ondemand domain-bulk FILE`

Same options, one target per line in `FILE`, all scanned under a single Scan
ID. Both endpoints sit under the ONYPHE `/dev/` prefix and may move without
notice.

```bash
pyonyphe ondemand ip-bulk ips.txt --no-import
pyonyphe ondemand domain-bulk domains.txt
```

### `ondemand result SCAN_ID`

Fetch the results of a scan launched earlier.

```bash
pyonyphe ondemand result 0123456789abcdef
```

Exits with **3** when ONYPHE answers that the scan is still running or that
its results are being built (error codes 103 and 111) — retry later, nothing
failed. Other API errors still exit with 1.

### `alert list | add | del`

```bash
Expand Down
78 changes: 73 additions & 5 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,69 @@ api.bulk_simple("datascan", ["1.1.1.1", "8.8.8.8"])
api.bulk_summary("domain", domains_from_your_database)
```

## On-demand scan

Everything else on this page reads what ONYPHE already collected. The
On-demand scope API makes ONYPHE *scan* an asset for you, which needs an
**On-demand subscription** — without it the call comes back as a
`PaymentRequiredError`.

```python
from pyonyphe import Onyphe

with Onyphe() as api:
launch = api.ondemand_scope_ip("8.8.8.0/24", vulnscan=True, ports=[80, 443])
print(launch.model_dump()) # the Scan ID is in there
```

Then, with that Scan ID in hand:

```python
from pyonyphe import ScanInProgressError

try:
page = api.ondemand_scope_result(scan_id)
except ScanInProgressError as exc:
print(f"{exc.scan_id} is not done yet, try again later")
else:
for document in page:
print(document)
```

ONYPHE states that a launch returns a Scan ID, but does not document the field
it travels in, so the client returns the envelope as it came and parses
nothing out of it. There is no polling helper yet either: retry
`ondemand_scope_result` yourself.

Four launch methods, two of them taking a list of targets and joining it for
you:

```python
api.ondemand_scope_ip("8.8.8.8") # or "8.8.8.0/24"
api.ondemand_scope_domain("example.com")
api.ondemand_scope_ip_bulk(["1.1.1.1", "8.8.8.8", "10.0.0.0/24"])
api.ondemand_scope_domain_bulk(["a.tld", "b.tld"])
```

The two `_bulk` ones are served under the `/dev/` prefix, ONYPHE's development
tree: they can move without notice.

All four take the same optional arguments, and send nothing at all for the ones
you leave out:

| argument | sent as | meaning |
| --- | --- | --- |
| `import_results` | `import` | import the results into the ONYPHE dataset |
| `vulnscan` | `vulnscan` | also run the vulnerability scan |
| `urlscan` | `urlscan` | also crawl the HTTP services found |
| `ports` | `ports` | `[80, 443]` is sent as `"80,443"` |
| `maxscantime` | `maxscantime` | scan budget, in seconds |

`import_results` spells out what `import` cannot: the name is a Python
keyword. **Importing makes the results PUBLIC** — they land in the ONYPHE
dataset and every ONYPHE user can see them. Leave it alone unless that is what
you want.

## Alerts

```python
Expand Down Expand Up @@ -141,21 +204,26 @@ Every exception derives from `OnypheError`:
| `PaymentRequiredError` | 402 — credits exhausted, or API not in your license |
| `NotFoundError` | 404 |
| `RateLimitError` | 429, with `.retry_after` when ONYPHE says so |
| `ScanInProgressError` | an On-demand scan has no results yet, with `.scan_id` |
| `ServerError` | 5xx |

`AuthenticationError`, `PaymentRequiredError`, `NotFoundError`,
`RateLimitError` and `ServerError` all subclass `APIError`, which carries
`.status_code` and the decoded `.payload`.
`RateLimitError`, `ScanInProgressError` and `ServerError` all subclass
`APIError`, which carries `.status_code` and the decoded `.payload`.

`ScanInProgressError` is the odd one: it reports ONYPHE error code 103 or 111
on an On-demand scan, which means "not ready", not "failed". Catch it to retry
rather than to give up.

429 and 5xx are retried automatically (`max_retries`, exponential backoff,
honouring `Retry-After`); the exception only surfaces once the retries are
exhausted.

## Endpoints not wrapped yet

The Ondemand APIv3 (`scope`, `resolver`) and the beta ASD APIv1 are not
wrapped. Reach them with the escape hatch, which handles auth, retries and
error mapping like any other call:
The Ondemand `resolver` endpoints and the beta ASD APIv1 are not wrapped
(`ondemand/scope` is, see above). Reach them with the escape hatch, which
handles auth, retries and error mapping like any other call:

```python
api.request("GET", "some/new/endpoint", params={"q": "..."})
Expand Down
2 changes: 2 additions & 0 deletions src/pyonyphe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ParamError,
PaymentRequiredError,
RateLimitError,
ScanInProgressError,
ServerError,
TransportError,
)
Expand Down Expand Up @@ -58,6 +59,7 @@
"PaymentRequiredError",
"RateLimitError",
"Response",
"ScanInProgressError",
"ServerError",
"Settings",
"TransportError",
Expand Down
24 changes: 24 additions & 0 deletions src/pyonyphe/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import base64
import json as jsonlib
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

Expand All @@ -13,11 +14,13 @@
from ._specs import Spec
from .config import Settings, load_settings
from .errors import (
SCAN_IN_PROGRESS_CODES,
APIError,
AuthenticationError,
NotFoundError,
PaymentRequiredError,
RateLimitError,
ScanInProgressError,
ServerError,
)
from .models import Response
Expand Down Expand Up @@ -154,6 +157,27 @@ def raise_for_status(self, response: httpx.Response, payload: dict[str, Any]) ->
raise ServerError(message or "onyphe server error", status_code=status, payload=payload)
raise APIError(message or "unknown error", status_code=status, payload=payload)

def raise_for_scan(
self,
scan_id: str,
payload: Mapping[str, Any],
*,
status_code: int | None = None,
) -> None:
"""Tell a pending On-demand scan apart from a genuine failure.

:param payload: decoded body, successful or not
:raises ScanInProgressError: when ONYPHE reported code 103 or 111
"""
code = payload.get("error")
if isinstance(code, int) and code in SCAN_IN_PROGRESS_CODES:
raise ScanInProgressError(
str(payload.get("text") or "scan in progress"),
scan_id=scan_id,
status_code=status_code,
payload=dict(payload),
)

def to_response(self, response: httpx.Response) -> Response:
"""Validate a successful JSON body into a :class:`Response`."""
payload = self._decode(response)
Expand Down
Loading
Loading