diff --git a/.changeset/0199-reliable-bulk-scanning.md b/.changeset/0199-reliable-bulk-scanning.md new file mode 100644 index 0000000..b9d3fb6 --- /dev/null +++ b/.changeset/0199-reliable-bulk-scanning.md @@ -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. diff --git a/docs-site/docs/about/release-notes.md b/docs-site/docs/about/release-notes.md index 5ad12b6..d78d9b7 100644 --- a/docs-site/docs/about/release-notes.md +++ b/docs-site/docs/about/release-notes.md @@ -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 diff --git a/docs-site/docs/developer/error-handling.mdx b/docs-site/docs/developer/error-handling.mdx index 2959db8..1bbfaf1 100644 --- a/docs-site/docs/developer/error-handling.mdx +++ b/docs-site/docs/developer/error-handling.mdx @@ -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 @@ -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); @@ -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. diff --git a/docs-site/docs/getting-started/configuration.mdx b/docs-site/docs/getting-started/configuration.mdx index f6f488f..b4774eb 100644 --- a/docs-site/docs/getting-started/configuration.mdx +++ b/docs-site/docs/getting-started/configuration.mdx @@ -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 diff --git a/docs-site/docs/guides/scan-api.md b/docs-site/docs/guides/scan-api.md index e950eab..16f3045 100644 --- a/docs-site/docs/guides/scan-api.md +++ b/docs-site/docs/guides/scan-api.md @@ -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. @@ -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 @@ -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] @@ -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:** @@ -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 | diff --git a/docs-site/examples/async-scan.ts b/docs-site/examples/async-scan.ts index f284b33..4415674 100644 --- a/docs-site/examples/async-scan.ts +++ b/docs-site/examples/async-scan.ts @@ -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, + }); } } } diff --git a/docs-site/examples/query-results.ts b/docs-site/examples/query-results.ts index bf0815a..b1c27a0 100644 --- a/docs-site/examples/query-results.ts +++ b/docs-site/examples/query-results.ts @@ -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}`); } @@ -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}`); } diff --git a/package-lock.json b/package-lock.json index be8eca8..4104854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cdot65/prisma-airs-sdk", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cdot65/prisma-airs-sdk", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "zod": "^3.24.0" diff --git a/package.json b/package.json index d5771b7..75656ec 100644 --- a/package.json +++ b/package.json @@ -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 ", "license": "MIT", diff --git a/scripts/preflight/allowlist.ts b/scripts/preflight/allowlist.ts index e78f4de..153b7f4 100644 --- a/scripts/preflight/allowlist.ts +++ b/scripts/preflight/allowlist.ts @@ -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', diff --git a/src/constants.ts b/src/constants.ts index daa654c..1a17e0f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 diff --git a/src/errors.ts b/src/errors.ts index e8cbbd2..c1e40f2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -18,21 +18,38 @@ export enum ErrorType { RESPONSE_VALIDATION = 'AISEC_RESPONSE_VALIDATION', } +/** Transport-level facts attached to HTTP and network failures. */ +export interface AISecSDKExceptionMetadata { + /** Whether the request received an HTTP response or failed at the network boundary. */ + failureKind?: 'http' | 'network'; + /** HTTP response status, when a response was received. */ + statusCode?: number; + /** Server-provided retry delay normalized to milliseconds. */ + retryAfterMs?: number; +} + /** * Base exception for all AIRS SDK errors. * The `errorType` field classifies the error origin. */ export class AISecSDKException extends Error { public readonly errorType?: ErrorType; + declare public readonly failureKind?: 'http' | 'network'; + declare public readonly statusCode?: number; + declare public readonly retryAfterMs?: number; /** * @param message - Human-readable error description. * @param errorType - Classification of the error. + * @param metadata - Optional transport failure metadata. */ - constructor(message: string, errorType?: ErrorType) { + constructor(message: string, errorType?: ErrorType, metadata: AISecSDKExceptionMetadata = {}) { super(errorType ? `${errorType}:${message}` : message); this.name = 'AISecSDKException'; this.errorType = errorType; + if (metadata.failureKind !== undefined) this.failureKind = metadata.failureKind; + if (metadata.statusCode !== undefined) this.statusCode = metadata.statusCode; + if (metadata.retryAfterMs !== undefined) this.retryAfterMs = metadata.retryAfterMs; if (Error.captureStackTrace) { Error.captureStackTrace(this, AISecSDKException); } diff --git a/src/http-retry.ts b/src/http-retry.ts index 47eb38a..582cc13 100644 --- a/src/http-retry.ts +++ b/src/http-retry.ts @@ -63,6 +63,63 @@ export function extractErrorMessage(body: string, status: number): string { } } +/** @internal Normalize a Retry-After delta-seconds or HTTP-date header to milliseconds. */ +export function parseRetryAfterHeader(value: string | null): number | undefined { + if (value === null) return undefined; + const normalized = value.trim(); + if (/^\d+$/.test(normalized)) { + const milliseconds = Number(normalized) * 1_000; + return Number.isFinite(milliseconds) ? milliseconds : undefined; + } + const weekday = '(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)'; + const longWeekday = '(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)'; + const month = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)'; + const httpDatePatterns = [ + new RegExp(`^${weekday}, \\d{2} ${month} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$`), + new RegExp(`^${longWeekday}, \\d{2}-${month}-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$`), + new RegExp(`^${weekday} ${month} [ \\d]\\d \\d{2}:\\d{2}:\\d{2} \\d{4}$`), + ]; + if (!httpDatePatterns.some((pattern) => pattern.test(normalized))) return undefined; + const timestamp = Date.parse(normalized); + if (!Number.isFinite(timestamp)) return undefined; + return Math.max(0, timestamp - Date.now()); +} + +/** @internal Normalize AIRS JSON retry guidance to milliseconds. */ +export function parseRetryAfterBody(body: string): number | undefined { + try { + const parsed = JSON.parse(body) as { retry_after?: { interval?: unknown; unit?: unknown } }; + const interval = parsed.retry_after?.interval; + const unit = parsed.retry_after?.unit; + if (typeof interval !== 'number' || !Number.isFinite(interval) || interval < 0) + return undefined; + if (typeof unit !== 'string') return undefined; + const unitMultipliers: Record = { + ms: 1, + msec: 1, + msecs: 1, + millisecond: 1, + milliseconds: 1, + s: 1_000, + sec: 1_000, + secs: 1_000, + second: 1_000, + seconds: 1_000, + m: 60_000, + min: 60_000, + mins: 60_000, + minute: 60_000, + minutes: 60_000, + }; + const multiplier = unitMultipliers[unit.toLowerCase()]; + if (multiplier === undefined) return undefined; + const milliseconds = interval * multiplier; + return Number.isFinite(milliseconds) ? milliseconds : undefined; + } catch { + return undefined; + } +} + /** @internal Options for {@link executeWithRetry}. */ export interface RetryOptions { /** Maximum number of retry attempts. */ @@ -98,6 +155,7 @@ export async function executeWithRetry(opts: RetryOptions): Promise { throw new AISecSDKException( lastError.message ?? 'Network error', ErrorType.CLIENT_SIDE_ERROR, + { failureKind: 'network' }, ); } @@ -121,11 +179,18 @@ export async function executeWithRetry(opts: RetryOptions): Promise { // Non-retryable error const errorText = await response.text(); const errorMessage = extractErrorMessage(errorText, response.status); - throw new AISecSDKException(errorMessage, classifyErrorType(response.status)); + const retryAfterHeader = response.headers?.get?.('Retry-After') ?? null; + const headerRetryAfterMs = parseRetryAfterHeader(retryAfterHeader); + throw new AISecSDKException(errorMessage, classifyErrorType(response.status), { + failureKind: 'http', + statusCode: response.status, + retryAfterMs: headerRetryAfterMs ?? parseRetryAfterBody(errorText), + }); } throw new AISecSDKException( lastError?.message ?? 'Max retries exceeded', ErrorType.CLIENT_SIDE_ERROR, + lastError ? { failureKind: 'network' } : {}, ); } diff --git a/src/index.ts b/src/index.ts index 69f1de4..7f9b0f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ // Public API surface export { init, globalConfiguration, type InitOptions } from './configuration.js'; -export { Scanner, Content, type SyncScanOptions, type ContentOptions } from './scan/index.js'; -export { AISecSDKException, ErrorType } from './errors.js'; +export { + Scanner, + Content, + type ScanCallOptions, + type SyncScanOptions, + type ContentOptions, +} from './scan/index.js'; +export { AISecSDKException, ErrorType, type AISecSDKExceptionMetadata } from './errors.js'; export * from './models/index.js'; export * from './constants.js'; export * from './management/index.js'; diff --git a/src/models/prompt-detected.ts b/src/models/prompt-detected.ts index 4fa3f60..ea22554 100644 --- a/src/models/prompt-detected.ts +++ b/src/models/prompt-detected.ts @@ -18,6 +18,7 @@ export const PromptDetectedSchema = z injection: z.boolean().optional(), toxic_content: z.boolean().optional(), malicious_code: z.boolean().optional(), + source_code: z.boolean().optional(), agent: z.boolean().optional(), topic_violation: z.boolean().optional(), }) diff --git a/src/scan/index.ts b/src/scan/index.ts index c2d2965..e37cf9b 100644 --- a/src/scan/index.ts +++ b/src/scan/index.ts @@ -1,2 +1,2 @@ -export { Scanner, type SyncScanOptions } from './scanner.js'; +export { Scanner, type ScanCallOptions, type SyncScanOptions } from './scanner.js'; export { Content, type ContentOptions } from './content.js'; diff --git a/src/scan/scanner.ts b/src/scan/scanner.ts index 738c0f4..6ceb7ca 100644 --- a/src/scan/scanner.ts +++ b/src/scan/scanner.ts @@ -7,6 +7,7 @@ import { MAX_NUMBER_OF_SCAN_IDS, MAX_NUMBER_OF_REPORT_IDS, MAX_NUMBER_OF_BATCH_SCAN_OBJECTS, + MAX_NUMBER_OF_RETRIES, MAX_TRANSACTION_ID_STR_LENGTH, MAX_SESSION_ID_STR_LENGTH, } from '../constants.js'; @@ -28,8 +29,30 @@ import { ScanIdResultSchema, type ScanIdResult } from '../models/scan-id-result. import { ThreatScanReportSchema, type ThreatScanReport } from '../models/threat-report.js'; import { Content } from './content.js'; +function resolveNumRetries(opts: ScanCallOptions): number { + const numRetries = opts.numRetries ?? globalConfiguration.numRetries; + if ( + !Number.isFinite(numRetries) || + !Number.isInteger(numRetries) || + numRetries < 0 || + numRetries > MAX_NUMBER_OF_RETRIES + ) { + throw new AISecSDKException( + `numRetries must be a finite integer between 0 and ${MAX_NUMBER_OF_RETRIES}`, + ErrorType.USER_REQUEST_PAYLOAD_ERROR, + ); + } + return numRetries; +} + +/** Transport options shared by all {@link Scanner} operations. */ +export interface ScanCallOptions { + /** Per-call retry override (0–5). Omit to use the global configuration. */ + numRetries?: number; +} + /** Optional parameters for {@link Scanner.syncScan}. */ -export interface SyncScanOptions { +export interface SyncScanOptions extends ScanCallOptions { /** Transaction ID for tracing. Max 100 characters. */ trId?: string; /** Session ID for grouping related scans. Max 100 characters. */ @@ -60,7 +83,7 @@ export class Scanner { * Perform a synchronous content scan. * @param aiProfile - AI security profile to scan against. * @param content - Content to scan. - * @param opts - Optional transaction/session IDs and metadata. + * @param opts - Optional transaction/session IDs, metadata, and per-call retry override. * @returns Scan response with verdict, action, and detection details. * @example * ```ts @@ -83,6 +106,7 @@ export class Scanner { content: Content, opts: SyncScanOptions = {}, ): Promise { + const numRetries = resolveNumRetries(opts); if (opts.trId && opts.trId.length > MAX_TRANSACTION_ID_STR_LENGTH) { throw new AISecSDKException( `trId exceeds max length of ${MAX_TRANSACTION_ID_STR_LENGTH}`, @@ -111,34 +135,56 @@ export class Scanner { body, responseSchema: ScanResponseSchema, auth: this.buildAuth(), - numRetries: globalConfiguration.numRetries, + numRetries, }); } /** - * Submit content for asynchronous scanning. - * @param scanObjects - Array of scan objects (1–5 items). - * @returns Response containing scan IDs for later querying. + * Submit one batch of content for asynchronous scanning. + * + * The call accepts 1–5 request objects and returns one batch receipt. A batch `scan_id` can fan + * out to several unordered result rows. Correlate each row by `(scan_id, req_id)`, never array + * position or `scan_id` alone. The SDK preserves the server's row order and cardinality. + * + * Setting `numRetries: 0` guarantees one SDK fetch attempt, but cannot guarantee exactly-once + * server submission after an ambiguous network or 5xx failure. + * @param scanObjects - Array of scan objects (1–5 items), each with its own `req_id`. + * @param opts - Optional per-call retry override. + * @returns One batch receipt containing the shared scan ID for later querying. * @example * ```ts * import { init, Scanner } from '@cdot65/prisma-airs-sdk'; * init(); * const scanner = new Scanner(); * - * const result = await scanner.asyncScan([ - * { - * req_id: 1, - * scan_req: { - * ai_profile: { profile_name: 'my-profile' }, - * contents: [{ prompt: 'Tell me about machine learning.' }], + * const receipt = await scanner.asyncScan( + * [ + * { + * req_id: 1, + * scan_req: { + * ai_profile: { profile_name: 'my-profile' }, + * contents: [{ prompt: 'Tell me about machine learning.' }], + * }, * }, - * }, - * ]); - * // result => + * { + * req_id: 2, + * scan_req: { + * ai_profile: { profile_name: 'my-profile' }, + * contents: [{ prompt: 'What are neural networks?' }], + * }, + * }, + * ], + * { numRetries: 0 }, + * ); + * // receipt => * // { received: '2024-01-01T00:00:00Z', scan_id: '550e...' } * ``` */ - async asyncScan(scanObjects: AsyncScanObject[]): Promise { + async asyncScan( + scanObjects: AsyncScanObject[], + opts: ScanCallOptions = {}, + ): Promise { + const numRetries = resolveNumRetries(opts); if (scanObjects.length < 1) { throw new AISecSDKException( 'At least 1 scan object is required', @@ -159,14 +205,18 @@ export class Scanner { body: scanObjects, responseSchema: AsyncScanResponseSchema, auth: this.buildAuth(), - numRetries: globalConfiguration.numRetries, + numRetries, }); } /** * Query scan results by scan IDs. + * + * One scan ID can return several unordered rows. Correlate them using `(scan_id, req_id)`. The + * SDK does not sort, deduplicate, or collapse the response. * @param scanIds - Array of scan UUIDs (1–5 items). - * @returns Array of scan results with status and response data. + * @param opts - Optional per-call retry override. + * @returns Every scan-result row in server order, with status and response data. * @example * ```ts * import { init, Scanner } from '@cdot65/prisma-airs-sdk'; @@ -176,12 +226,14 @@ export class Scanner { * const results = await scanner.queryByScanIds([ * '550e8400-e29b-41d4-a716-446655440000', * ]); - * // results => - * // [{ scan_id: '550e8400-e29b-41d4-a716-446655440000', status: 'complete', - * // result: { category: 'benign', action: 'allow', ... } }] + * // A single scan ID may produce multiple rows, for example req_id 2 then req_id 1. + * for (const row of results) { + * console.log(`${row.scan_id}:${row.req_id}`, row.status, row.result?.action); + * } * ``` */ - async queryByScanIds(scanIds: string[]): Promise { + async queryByScanIds(scanIds: string[], opts: ScanCallOptions = {}): Promise { + const numRetries = resolveNumRetries(opts); if (scanIds.length < 1) { throw new AISecSDKException( 'At least 1 scan_id is required', @@ -207,14 +259,18 @@ export class Scanner { params: { scan_ids: scanIds.join(',') }, responseSchema: z.array(ScanIdResultSchema), auth: this.buildAuth(), - numRetries: globalConfiguration.numRetries, + numRetries, }); } /** * Query detailed threat reports by report IDs. + * + * One report ID can return several rows. Correlate them using `(report_id, req_id)`. The SDK + * preserves server order and cardinality without sorting or deduplicating. * @param reportIds - Array of report IDs (1–5 items). - * @returns Array of threat scan reports with detection details. + * @param opts - Optional per-call retry override. + * @returns Every report row in server order, with detection details. * @example * ```ts * import { init, Scanner } from '@cdot65/prisma-airs-sdk'; @@ -222,12 +278,16 @@ export class Scanner { * const scanner = new Scanner(); * * const reports = await scanner.queryByReportIds(['R000...']); - * // reports => - * // [{ report_id: 'R000...', scan_id: '550e...', - * // detection_results: [{ detection_service: 'pi', verdict: 'benign', action: 'allow' }] }] + * for (const report of reports) { + * console.log(`${report.report_id}:${report.req_id}`, report.detection_results); + * } * ``` */ - async queryByReportIds(reportIds: string[]): Promise { + async queryByReportIds( + reportIds: string[], + opts: ScanCallOptions = {}, + ): Promise { + const numRetries = resolveNumRetries(opts); if (reportIds.length < 1) { throw new AISecSDKException( 'At least 1 report_id is required', @@ -248,7 +308,7 @@ export class Scanner { params: { report_ids: reportIds.join(',') }, responseSchema: z.array(ThreatScanReportSchema), auth: this.buildAuth(), - numRetries: globalConfiguration.numRetries, + numRetries, }); } } diff --git a/test/errors.spec.ts b/test/errors.spec.ts index 3bc3761..3f447e2 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -15,6 +15,32 @@ describe('AISecSDKException', () => { expect(err.errorType).toBe(ErrorType.MISSING_VARIABLE); }); + it('exposes structured failure metadata without changing the legacy constructor', () => { + const legacy = new AISecSDKException('missing key', ErrorType.MISSING_VARIABLE); + const structured = new AISecSDKException('rate limited', ErrorType.CLIENT_SIDE_ERROR, { + failureKind: 'http', + statusCode: 429, + retryAfterMs: 2_000, + }); + + expect(legacy).toMatchObject({ + name: 'AISecSDKException', + message: `${ErrorType.MISSING_VARIABLE}:missing key`, + errorType: ErrorType.MISSING_VARIABLE, + }); + expect(legacy.failureKind).toBeUndefined(); + expect(legacy.statusCode).toBeUndefined(); + expect(legacy.retryAfterMs).toBeUndefined(); + expect(Object.keys(legacy)).toEqual(['errorType', 'name']); + expect(structured).toMatchObject({ + message: `${ErrorType.CLIENT_SIDE_ERROR}:rate limited`, + errorType: ErrorType.CLIENT_SIDE_ERROR, + failureKind: 'http', + statusCode: 429, + retryAfterMs: 2_000, + }); + }); + it('is instanceof Error', () => { const err = new AISecSDKException('test'); expect(err).toBeInstanceOf(Error); diff --git a/test/models/models.spec.ts b/test/models/models.spec.ts index 95fa4e3..3b5583a 100644 --- a/test/models/models.spec.ts +++ b/test/models/models.spec.ts @@ -5,6 +5,7 @@ import { AsyncScanResponseSchema, ScanIdResultSchema, ThreatScanReportSchema, + PromptDetectedSchema, MetadataSchema, ToolEventSchema, } from '../../src/models/index.js'; @@ -66,6 +67,23 @@ describe('ScanResponseSchema', () => { expect(result.success).toBe(true); }); + it('exposes source_code as an optional boolean in runtime and static types', () => { + expect(PromptDetectedSchema.shape.source_code).toBeDefined(); + const result = ScanResponseSchema.parse({ + report_id: 'r1', + scan_id: 's1', + category: 'malicious', + action: 'block', + timeout: false, + error: false, + errors: [], + prompt_detected: { source_code: true }, + }); + + const sourceCode: boolean | undefined = result.prompt_detected?.source_code; + expect(sourceCode).toBe(true); + }); + it('parses timeout, error, and errors fields', () => { const result = ScanResponseSchema.safeParse({ report_id: 'r1', diff --git a/test/preflight/allowlist.spec.ts b/test/preflight/allowlist.spec.ts index 2b9486e..aa3bf6e 100644 --- a/test/preflight/allowlist.spec.ts +++ b/test/preflight/allowlist.spec.ts @@ -47,6 +47,21 @@ describe('isAllowlisted', () => { ).toBe(false); }); + it.each([ + ['PromptDetected', '$'], + ['ScanIdResult', '$.result.prompt_detected'], + ['ScanResponse', '$.prompt_detected'], + ])('acknowledges live-observed source_code drift for %s', (schemaName, path) => { + expect( + isAllowlisted({ + ...baseFinding, + schemaName, + path, + kind: 'extra-field', + }), + ).toBe(true); + }); + it('every entry has a non-empty reason', () => { for (const entry of PREFLIGHT_ALLOWLIST) { expect(entry.reason.length).toBeGreaterThan(0); diff --git a/test/scan/scanner.spec.ts b/test/scan/scanner.spec.ts index e2fbcbf..598900e 100644 --- a/test/scan/scanner.spec.ts +++ b/test/scan/scanner.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Scanner } from '../../src/scan/scanner.js'; import { Content } from '../../src/scan/content.js'; import { globalConfiguration, init } from '../../src/configuration.js'; -import { AISecSDKException } from '../../src/errors.js'; +import { AISecSDKException, ErrorType } from '../../src/errors.js'; import type { AiProfile } from '../../src/models/ai-profile.js'; function mockFetch(data: unknown, status = 200) { @@ -24,6 +24,7 @@ describe('Scanner', () => { }); afterEach(() => { + vi.restoreAllMocks(); globalThis.fetch = originalFetch; globalConfiguration.reset(); }); @@ -43,7 +44,7 @@ describe('Scanner', () => { const scanner = new Scanner(); const content = new Content({ prompt: 'hello' }); - const result = await scanner.syncScan(profile, content); + const result = await scanner.syncScan(profile, content, { numRetries: 0 }); expect(result.scan_id).toBe('s1'); expect(result.category).toBe('benign'); @@ -131,6 +132,234 @@ describe('Scanner', () => { })); await expect(scanner.asyncScan(objects)).rejects.toThrow(AISecSDKException); }); + + it('exposes Retry-After metadata from an HTTP 429', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'rate limited' }), { + status: 429, + headers: { 'Retry-After': '2' }, + }), + ); + + const scanner = new Scanner(); + const submission = scanner.asyncScan([ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]); + + await expect(submission).rejects.toMatchObject({ + errorType: ErrorType.CLIENT_SIDE_ERROR, + failureKind: 'http', + statusCode: 429, + retryAfterMs: 2_000, + }); + }); + + it('normalizes AIRS JSON retry guidance when the header is absent', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + message: 'rate limited', + retry_after: { interval: 3, unit: 'seconds' }, + }), + { status: 429 }, + ), + ); + + const scanner = new Scanner(); + const submission = scanner.asyncScan([ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]); + + await expect(submission).rejects.toMatchObject({ retryAfterMs: 3_000 }); + }); + + it('prefers valid header retry guidance and falls back to the body for invalid headers', async () => { + const responses = [ + new Response( + JSON.stringify({ + message: 'rate limited', + retry_after: { interval: 9, unit: 'seconds' }, + }), + { status: 429, headers: { 'Retry-After': '2' } }, + ), + new Response( + JSON.stringify({ + message: 'rate limited', + retry_after: { interval: 3, unit: 'minutes' }, + }), + { status: 429, headers: { 'Retry-After': 'not-a-delay' } }, + ), + new Response( + JSON.stringify({ + message: 'rate limited', + retry_after: { interval: 4, unit: 'seconds' }, + }), + { status: 429, headers: { 'Retry-After': '-1' } }, + ), + ]; + globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(responses.shift())); + const scanner = new Scanner(); + const objects = [ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]; + + await expect(scanner.asyncScan(objects)).rejects.toMatchObject({ retryAfterMs: 2_000 }); + await expect(scanner.asyncScan(objects)).rejects.toMatchObject({ retryAfterMs: 180_000 }); + await expect(scanner.asyncScan(objects)).rejects.toMatchObject({ retryAfterMs: 4_000 }); + }); + + it('normalizes HTTP dates and supported body units without fabricating invalid delays', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-17T12:00:00Z')); + const cases = [ + { header: 'Fri, 17 Jul 2026 12:00:05 GMT', body: undefined, expected: 5_000 }, + { header: 'Fri, 17 Jul 2026 11:59:55 GMT', body: undefined, expected: 0 }, + { body: { interval: 25, unit: 'MS' }, expected: 25 }, + { body: { interval: 2, unit: 'min' }, expected: 120_000 }, + { body: { interval: -1, unit: 'seconds' }, expected: undefined }, + { body: { interval: 1, unit: 'fortnights' }, expected: undefined }, + ]; + const responses = cases.map( + ({ header, body }) => + new Response(JSON.stringify({ message: 'rate limited', retry_after: body }), { + status: 429, + headers: header ? { 'Retry-After': header } : undefined, + }), + ); + globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(responses.shift())); + const scanner = new Scanner(); + const objects = [ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]; + + for (const { expected } of cases) { + const error = await scanner.asyncScan(objects).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(AISecSDKException); + expect((error as AISecSDKException).retryAfterMs).toBe(expected); + } + }); + + it('distinguishes a network failure from an HTTP failure', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('connection reset')); + const scanner = new Scanner(); + + const submission = scanner.asyncScan([ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]); + + const error = await submission.catch((caught: unknown) => caught); + expect(error).toMatchObject({ + errorType: ErrorType.CLIENT_SIDE_ERROR, + failureKind: 'network', + }); + expect((error as AISecSDKException).statusCode).toBeUndefined(); + expect((error as AISecSDKException).retryAfterMs).toBeUndefined(); + }); + + it('retains the transport status and server-side classification for a terminal 503', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'unavailable', status_code: 429 }), { + status: 503, + }), + ); + const scanner = new Scanner(); + + const submission = scanner.asyncScan([ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]); + + await expect(submission).rejects.toMatchObject({ + errorType: ErrorType.SERVER_SIDE_ERROR, + failureKind: 'http', + statusCode: 503, + }); + }); + + it('allows async submission to override five global retries with zero retries', async () => { + globalConfiguration.reset(); + init({ apiKey: 'test-key', numRetries: 5 }); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('connection reset')); + vi.spyOn(Math, 'random').mockReturnValue(0); + const scanner = new Scanner(); + + await expect( + scanner.asyncScan( + [ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ], + { numRetries: 0 }, + ), + ).rejects.toBeInstanceOf(AISecSDKException); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, 6])( + 'rejects invalid per-call retry count %s before fetch', + async (numRetries) => { + globalThis.fetch = vi.fn(); + const scanner = new Scanner(); + + await expect( + scanner.asyncScan( + [ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ], + { numRetries }, + ), + ).rejects.toMatchObject({ errorType: ErrorType.USER_REQUEST_PAYLOAD_ERROR }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }, + ); + + it('keeps using the global retry count when per-call options are omitted', async () => { + globalConfiguration.reset(); + init({ apiKey: 'test-key', numRetries: 1 }); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'unavailable' }), { status: 503 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ received: 'now', scan_id: 'shared-scan' }), { + status: 200, + }), + ); + vi.spyOn(Math, 'random').mockReturnValue(0); + + const result = await new Scanner().asyncScan([ + { + req_id: 1, + scan_req: { ai_profile: profile, contents: [{ prompt: 'test' }] }, + }, + ]); + + expect(result.scan_id).toBe('shared-scan'); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); }); describe('queryByScanIds', () => { @@ -158,6 +387,38 @@ describe('Scanner', () => { const scanner = new Scanner(); await expect(scanner.queryByScanIds(['not-a-uuid'])).rejects.toThrow(AISecSDKException); }); + + it('allows a query to override zero global retries with one retry', async () => { + const unavailable = new Response(JSON.stringify({ message: 'unavailable' }), { status: 503 }); + const success = new Response( + JSON.stringify([{ scan_id: '550e8400-e29b-41d4-a716-446655440000', status: 'complete' }]), + { status: 200 }, + ); + globalThis.fetch = vi.fn().mockResolvedValueOnce(unavailable).mockResolvedValueOnce(success); + vi.spyOn(Math, 'random').mockReturnValue(0); + const scanner = new Scanner(); + + const result = await scanner.queryByScanIds(['550e8400-e29b-41d4-a716-446655440000'], { + numRetries: 1, + }); + + expect(result).toHaveLength(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('preserves every unordered row when one scan ID fans out by req_id', async () => { + const response = [3, 4, 0, 1, 2].map((req_id) => ({ + scan_id: '550e8400-e29b-41d4-a716-446655440000', + req_id, + status: 'complete', + })); + mockFetch(response); + + const results = await new Scanner().queryByScanIds(['550e8400-e29b-41d4-a716-446655440000']); + + expect(results).toEqual(response); + expect(results.map((row) => row.req_id)).toEqual([3, 4, 0, 1, 2]); + }); }); describe('queryByReportIds', () => { @@ -166,7 +427,7 @@ describe('Scanner', () => { mockFetch(response); const scanner = new Scanner(); - const result = await scanner.queryByReportIds(['r1']); + const result = await scanner.queryByReportIds(['r1'], { numRetries: 0 }); expect(result).toHaveLength(1); }); @@ -180,5 +441,24 @@ describe('Scanner', () => { const ids = Array.from({ length: 6 }, () => 'r1'); await expect(scanner.queryByReportIds(ids)).rejects.toThrow(AISecSDKException); }); + + it('preserves every row when one report ID fans out by req_id', async () => { + const response = [2, 0, 1].map((req_id) => ({ + report_id: 'shared-report', + scan_id: 'shared-scan', + req_id, + marker: `row-${req_id}`, + })); + mockFetch(response); + + const reports = await new Scanner().queryByReportIds(['shared-report']); + + expect(reports).toEqual(response); + expect(reports.map((row) => [row.report_id, row.req_id])).toEqual([ + ['shared-report', 2], + ['shared-report', 0], + ['shared-report', 1], + ]); + }); }); });