Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/0199-reliable-bulk-scanning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@cdot65/prisma-airs-sdk': patch
---

Make AIRS bulk scanning safer and easier to correlate. Every `Scanner` operation now accepts an
optional per-call `numRetries` override, while omitted options retain the global retry behavior.
`AISecSDKException` exposes HTTP-versus-network failure metadata, the transport status, and
normalized `Retry-After` guidance. Prompt detection explicitly types `source_code`, and the async
documentation now describes scan/report fan-out and `(scan_id, req_id)` / `(report_id, req_id)`
correlation. Existing calls and exception message formatting remain unchanged.
19 changes: 19 additions & 0 deletions docs-site/docs/about/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Release Notes

## v0.13.1

### Reliable SDK primitives for AIRS bulk scanning

- Every `Scanner` operation accepts an optional per-call `{ numRetries }` override. Omitting it
preserves the global `init()` setting; `0` means one total fetch attempt. This lets callers avoid
blind SDK retries on async POSTs while retaining bounded retries for polling GETs.
- `AISecSDKException` now exposes optional `failureKind`, `statusCode`, and `retryAfterMs` fields.
HTTP failures retain the real transport status, network failures remain
`CLIENT_SIDE_ERROR`-classified for compatibility, and valid header/body retry guidance is
normalized to milliseconds.
- `prompt_detected.source_code` is now an explicit optional boolean in the public schema/type.
- Async scan and report documentation now reflects live fan-out: one batch scan/report ID may
produce multiple unordered rows. Correlate with `(scan_id, req_id)` or `(report_id, req_id)`;
the SDK preserves server order and cardinality without sorting or deduplicating.

This patch does not claim exactly-once async submission. A network or 5xx failure after an async
POST remains an ambiguous outcome unless the server provides idempotency or reconciliation.

## v0.13.0

### New Features — Red Team Network Broker
Expand Down
33 changes: 31 additions & 2 deletions docs-site/docs/developer/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ description: Understand AISecSDKException, ErrorType values, response validation

# Error Handling

All SDK errors throw `AISecSDKException` with a typed `errorType` property.
All SDK errors throw `AISecSDKException` with a typed `errorType` property. Transport failures also
carry facts that callers can use without parsing the message:

| Property | Meaning |
| -------------- | ----------------------------------------------------------------------- |
| `failureKind` | `'http'` when AIRS returned a response; `'network'` when fetch rejected |
| `statusCode` | Actual HTTP response status; absent for network failures |
| `retryAfterMs` | Valid server retry guidance normalized to milliseconds |

Configuration and response-validation errors leave these transport fields undefined. The metadata
does not contain credentials, request payloads, URLs, authorization headers, or raw response bodies.

## Error Types

Expand All @@ -25,9 +35,18 @@ All SDK errors throw `AISecSDKException` with a typed `errorType` property.
import { AISecSDKException, ErrorType } from '@cdot65/prisma-airs-sdk';

try {
await scanner.syncScan(profile, content);
await scanner.asyncScan(batch, { numRetries: 0 });
} catch (err) {
if (err instanceof AISecSDKException) {
if (err.failureKind === 'http' && err.statusCode === 429) {
console.error(`Rate limited; server delay is ${err.retryAfterMs ?? 'unspecified'}ms`);
return;
}
if (err.failureKind === 'network') {
console.error('Network failure; an async POST may have reached AIRS:', err.message);
return;
}

switch (err.errorType) {
case ErrorType.SERVER_SIDE_ERROR:
console.error('Server error — retry later:', err.message);
Expand Down Expand Up @@ -57,6 +76,16 @@ try {
The SDK automatically retries on transient errors:

- **Status codes**: 500, 502, 503, 504
- **Network failures**: Retried within the same retry budget
- **429**: Not retried automatically; valid `Retry-After` header guidance takes precedence over the
AIRS JSON `retry_after.interval` / `unit` fields and is exposed as `retryAfterMs`
- **Backoff**: Exponential with full jitter (`uniform [0, 2^attempt × 1000ms]`)
- **Max retries**: Configurable 0-5, default 5
- **Scanner per-call override**: Pass `{ numRetries }` to any Scanner operation; `0` means one total
fetch attempt, and an omitted value preserves the global setting
- **401/403 handling**: Management/Model Security/Red Team clients automatically refresh the OAuth token and retry once on 401 or 403 responses

For async POSTs, a terminal network error or 5xx response is an ambiguous outcome: AIRS may have
accepted the request. The SDK does not claim exactly-once submission and does not expose a generic
`retryable` flag because retry safety depends on the operation. Polling GETs are idempotent; async
submission is not known to be.
10 changes: 10 additions & 0 deletions docs-site/docs/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ init({
```

`numRetries` is supported by all SDK clients, accepts values from `0` to `5`, and defaults to `5`.
Every `Scanner` method also accepts a per-call override. This is useful for giving an async POST one
fetch attempt while retaining bounded retries for idempotent polling GETs:

```ts
const receipt = await scanner.asyncScan(batch, { numRetries: 0 });
const rows = await scanner.queryByScanIds([receipt.scan_id], { numRetries: 2 });
```

Omitting the per-call option uses the global value. `numRetries` counts retries after the initial
attempt, so `0` means one total attempt and `5` permits six total attempts.

## Management API

Expand Down
54 changes: 37 additions & 17 deletions docs-site/docs/guides/scan-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Key concepts:
| `Content` | A wrapper holding the text to scan (`prompt`, `response`, `context`, code, tool events). Validates size as you set it. |
| **Security profile** | The named ruleset (managed via the [Management API](management-api)) that decides which detections run and whether a hit means _allow_ or _block_. |
| **Verdict** | The result: `category` (`benign`/`malicious`) and `action` (`allow`/`block`). |
| **Sync vs async** | Sync gives an inline verdict in one call. Async accepts a batch, returns receipts, and you poll for results later. |
| **Sync vs async** | Sync gives an inline verdict in one call. Async accepts 1–5 request objects, returns one batch receipt, and you poll for result rows later. |

:::tip[Profiles live in the Management API]
The Scan API only _references_ a profile by name or ID — it never creates one. Define and tune profiles with the [Management API](management-api), then point scans at them.
Expand Down Expand Up @@ -113,32 +113,46 @@ if (result.action === 'block') {
console.log(result.category, result.scan_id, result.report_id);
```

`result` is a `ScanResponse`: `category` is `"benign"` or `"malicious"`; `action` is `"allow"` or `"block"`. Keep `scan_id` / `report_id` to fetch detail later.
`result` is a `ScanResponse`: `category` is `"benign"` or `"malicious"`; `action` is `"allow"` or `"block"`. Prompt detector flags are typed under `prompt_detected`, including the optional boolean `source_code`. Keep `scan_id` / `report_id` to fetch detail later.

### Scan many items off the hot path (asynchronous)

When you have a backlog (logs, batch evaluation, offline review), submit up to **5** items in one call, get a `scan_id` receipt back immediately, then poll for results once processing completes.
When you have a backlog (logs, batch evaluation, offline review), submit **1–5 request objects** in one call. AIRS returns one batch receipt. A batch `scan_id` can later produce several result rows, one per `req_id`, and those rows can arrive in any order.

```ts
const submitted = await scanner.asyncScan([
{
req_id: 1,
scan_req: {
ai_profile: { profile_name: 'my-profile' },
contents: [{ prompt: 'hello', response: 'world' }],
const submitted = await scanner.asyncScan(
[
{
req_id: 1,
scan_req: {
ai_profile: { profile_name: 'my-profile' },
contents: [{ prompt: 'hello', response: 'world' }],
},
},
},
// ... up to 5 objects
]);
{
req_id: 2,
scan_req: {
ai_profile: { profile_name: 'my-profile' },
contents: [{ prompt: 'second prompt' }],
},
},
// ... up to 5 objects, each with a distinct req_id
],
{ numRetries: 0 },
);

// submitted: AsyncScanResponse — { received, scan_id }
const results = await scanner.queryByScanIds([submitted.scan_id]);
for (const r of results) {
console.log(r.scan_id, r.status, r.result?.category);
const results = await scanner.queryByScanIds([submitted.scan_id], { numRetries: 2 });
for (const row of results) {
console.log(row.scan_id, row.req_id, row.status, row.result?.category);
}
```

`req_id` is your own correlation number so you can match each item back in the results.
`req_id` is your correlation number. Match scan results using the tuple `(scan_id, req_id)`—never array position or `scan_id` alone. The SDK returns every row in server order; it does not sort, deduplicate, or collapse rows that share a scan ID.

:::warning[Async submission is not exactly once]
`{ numRetries: 0 }` guarantees one SDK fetch attempt. It cannot prove whether AIRS accepted a request when the POST ends in a network error or 5xx response. Do not blindly resubmit an ambiguous async outcome unless your application has a safe reconciliation strategy. AIRS does not currently expose an SDK-level idempotency guarantee.
:::

### Pull the full threat report

Expand All @@ -147,12 +161,15 @@ for (const r of results) {
```ts
const reports = await scanner.queryByReportIds([result.report_id]);
for (const report of reports) {
console.log(report.report_id, report.req_id);
for (const det of report.detection_results ?? []) {
console.log(det.detection_service, det.verdict, det.action);
}
}
```

One report ID can also return multiple rows. Preserve every row and correlate it with `(report_id, req_id)`; do not store reports in a map keyed only by `report_id`.

## Get the most out of it

:::tip[Scan both directions]
Expand All @@ -171,7 +188,9 @@ These are **byte** limits (multibyte characters count for more than one). For ve
:::note[Batch and query caps are 5]
`asyncScan`, `queryByScanIds`, and `queryByReportIds` each accept **at most 5 items**. The SDK throws a client-side error before any network call if you exceed it — loop in batches of 5 for larger workloads.
:::
**Retry behavior** — every scan call retries automatically on transient server errors (`500`, `502`, `503`, `504`) with exponential backoff plus jitter. Tune the attempt count with `init({ numRetries })` (0–5, default 5). Set it to `0` only if you have your own retry layer; client-side `4xx` errors are never retried.
**Retry behavior** — every scan call retries automatically on network failures and transient server errors (`500`, `502`, `503`, `504`) with exponential backoff plus jitter. Tune the global count with `init({ numRetries })` (0–5, default 5), or pass `{ numRetries }` to an individual `syncScan`, `asyncScan`, `queryByScanIds`, or `queryByReportIds` call. The per-call value wins when provided; omitting it preserves the global setting. A value of `0` means one total fetch attempt, while `5` permits six attempts. HTTP 429 is not retried automatically.

For bulk scanning, use `numRetries: 0` on the async POST and deliberate retry policy in your application. Polling GETs are idempotent and can safely use a bounded override. On `AISecSDKException`, inspect `failureKind`, `statusCode`, and `retryAfterMs`; a definite 429 rejection differs from an ambiguous network/5xx async outcome.

**Sync vs async — pick deliberately:**

Expand Down Expand Up @@ -208,3 +227,4 @@ All types are Zod-validated and exported:
| `AiProfile` | Profile identifier (profile_name or profile_id) |
| `Content` | Scan content wrapper class |
| `Metadata` | Optional scan metadata (app_name, ai_model, etc.) |
| `ScanCallOptions` | Per-call Scanner retry override |
72 changes: 45 additions & 27 deletions docs-site/examples/async-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,56 @@ async function main() {
const scanner = new Scanner();

try {
const result = await scanner.asyncScan([
{
req_id: 1,
scan_req: {
ai_profile: { profile_name: profileName },
contents: [
{
prompt: 'Tell me about machine learning.',
response: 'Machine learning is a branch of AI...',
},
],
// Use zero SDK retries for an async POST. A network/5xx failure can be ambiguous: the server
// may have accepted the batch even though the client did not receive the receipt.
const receipt = await scanner.asyncScan(
[
{
req_id: 1,
scan_req: {
ai_profile: { profile_name: profileName },
contents: [
{
prompt: 'Tell me about machine learning.',
response: 'Machine learning is a branch of AI...',
},
],
},
},
},
{
req_id: 2,
scan_req: {
ai_profile: { profile_name: profileName },
contents: [
{
prompt: 'What are neural networks?',
response: 'Neural networks are computing systems...',
},
],
{
req_id: 2,
scan_req: {
ai_profile: { profile_name: profileName },
contents: [
{
prompt: 'What are neural networks?',
response: 'Neural networks are computing systems...',
},
],
},
},
},
]);
],
{ numRetries: 0 },
);

console.log('Received:', result.received);
console.log('Scan ID:', result.scan_id);
console.log('Received:', receipt.received);
console.log('Batch scan ID:', receipt.scan_id);

// Polling GETs are idempotent, so a bounded retry override is safe. A single batch scan ID can
// return multiple rows, and their order is not guaranteed.
const rows = await scanner.queryByScanIds([receipt.scan_id], { numRetries: 2 });
const byCorrelationId = new Map(rows.map((row) => [`${row.scan_id}:${row.req_id}`, row]));

for (const [correlationId, row] of byCorrelationId) {
console.log(correlationId, row.status, row.result?.action);
}
} catch (error) {
if (error instanceof AISecSDKException) {
console.error('Error:', error.message);
console.error('Error:', error.message, {
failureKind: error.failureKind,
statusCode: error.statusCode,
retryAfterMs: error.retryAfterMs,
});
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions docs-site/examples/query-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ async function main() {
// Query results by scan IDs (up to 5)
const results = await scanner.queryByScanIds(['550e8400-e29b-41d4-a716-446655440000']);

// One scan ID can produce multiple unordered rows. Never key only by scan_id or array index.
for (const r of results) {
console.log(`Scan ${r.scan_id}: status=${r.status}`);
console.log(`Scan ${r.scan_id}, request ${r.req_id}: status=${r.status}`);
if (r.result) {
console.log(` category=${r.result.category} action=${r.result.action}`);
}
Expand All @@ -19,8 +20,9 @@ async function main() {
// Query threat reports by report IDs (up to 5)
const reports = await scanner.queryByReportIds(['report-id-here']);

// Report IDs can fan out too; correlate each row with (report_id, req_id).
for (const report of reports) {
console.log(`Report ${report.report_id}:`);
console.log(`Report ${report.report_id}, request ${report.req_id}:`);
for (const det of report.detection_results ?? []) {
console.log(` ${det.detection_service}: ${det.verdict} -> ${det.action}`);
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cdot65/prisma-airs-sdk",
"version": "0.13.0",
"version": "0.13.1",
"description": "TypeScript SDK for Palo Alto Networks Prisma AIRS — scanning, management, model security, and red teaming APIs",
"author": "Calvin Remsburg <cremsburg.dev@gmail.com>",
"license": "MIT",
Expand Down
22 changes: 22 additions & 0 deletions scripts/preflight/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ export interface AllowlistEntry {
}

export const PREFLIGHT_ALLOWLIST: AllowlistEntry[] = [
// ── AIRS scan response fields observed live but missing from OpenAPI ──────────
// Verified live on 2026-07-17. The same nested prompt detector appears in the
// direct schema and through both scan response envelopes.
{
schema: 'PromptDetected',
pathSubstring: '$',
kind: 'extra-field',
reason: 'API returns prompt detector flag `source_code`; not in upstream OpenAPI.',
},
{
schema: 'ScanIdResult',
pathSubstring: '$.result.prompt_detected',
kind: 'extra-field',
reason: 'API returns nested prompt detector flag `source_code`; not in upstream OpenAPI.',
},
{
schema: 'ScanResponse',
pathSubstring: '$.prompt_detected',
kind: 'extra-field',
reason: 'API returns nested prompt detector flag `source_code`; not in upstream OpenAPI.',
},

// ── DataProtectionObject ─────────────────────────────────────────────────────
{
schema: 'DataProtectionObject',
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const MAX_NUMBER_OF_RETRIES = 5;
export const HTTP_FORCE_RETRY_STATUS_CODES = [500, 502, 503, 504];

// User-Agent (version injected at build time or read from package.json)
export const SDK_VERSION = '0.13.0';
export const SDK_VERSION = '0.13.1';
export const USER_AGENT = `PAN-AIRS/${SDK_VERSION}-typescript-sdk`;

// Management API defaults
Expand Down
Loading
Loading