diff --git a/.changeset/0198-network-broker-stats-schema-fix.md b/.changeset/0198-network-broker-stats-schema-fix.md new file mode 100644 index 0000000..0d3835e --- /dev/null +++ b/.changeset/0198-network-broker-stats-schema-fix.md @@ -0,0 +1,5 @@ +--- +'@cdot65/prisma-airs-sdk': patch +--- + +Fix the Network Broker `ChannelStats` schema field names to match the actual API (verified live). `getChannelStats()` previously modeled non-existent fields (`broker_server`, `registry`, `chart`, `image`, `online_channel_count`, `total_channel_count`) so every typed accessor returned `undefined`. They are now `network_channels_server_domain`, `docker_registry`, `helm_chart`, `docker_image`, `online_channels`, `total_channels`, plus `client_version`. `Channel` also gains the live-observed `added_by`, `last_online_at`, `connected_clients_count`, `outdated_clients_count`, and `features` fields. The Network Broker OpenAPI spec is now committed so preflight validates these schemas going forward. diff --git a/docs-site/docs/guides/red-team-api.md b/docs-site/docs/guides/red-team-api.md index f8a4909..31eb19e 100644 --- a/docs-site/docs/guides/red-team-api.md +++ b/docs-site/docs/guides/red-team-api.md @@ -496,7 +496,7 @@ const detail = await client.networkBroker.getChannel(channel.uuid!); await client.networkBroker.updateChannel(channel.uuid!, { description: 'Updated description' }); const stats = await client.networkBroker.getChannelStats(); -console.log(stats.online_channel_count, stats.total_channel_count); +console.log(stats.online_channels, stats.total_channels); ``` Channel statuses are `ONLINE`, `OFFLINE`, and `DRAFT` (see the `ChannelStatus` enum). diff --git a/docs-site/examples/red-team-network-broker.ts b/docs-site/examples/red-team-network-broker.ts index 4d767dd..b6daa8a 100644 --- a/docs-site/examples/red-team-network-broker.ts +++ b/docs-site/examples/red-team-network-broker.ts @@ -43,9 +43,9 @@ async function main() { // --- STATS --- console.log('\nGetting channel stats...'); const stats = await client.networkBroker.getChannelStats(); - console.log(' Broker server:', stats.broker_server); - console.log(' Online channels:', stats.online_channel_count); - console.log(' Total channels:', stats.total_channel_count); + console.log(' Broker server:', stats.network_channels_server_domain); + console.log(' Online channels:', stats.online_channels); + console.log(' Total channels:', stats.total_channels); } catch (error) { if (error instanceof AISecSDKException) { console.error('Error:', error.message); diff --git a/scripts/preflight/allowlist.ts b/scripts/preflight/allowlist.ts index cb41fdb..186f880 100644 --- a/scripts/preflight/allowlist.ts +++ b/scripts/preflight/allowlist.ts @@ -462,6 +462,47 @@ export const PREFLIGHT_ALLOWLIST: AllowlistEntry[] = [ kind: 'missing-required-field', reason: 'status is modeled as z.unknown(); cannot be marked required.', }, + + // ── ErrorResponse name collision ───────────────────────────────────────────── + // src/models/error-response.ts models the AIRS scan/gateway error envelope + // (status_code, error{}, retry_after). The Network Broker spec coincidentally defines an + // unrelated `ErrorResponse` component (code, message). Same name, different contract — the + // SDK schema is not meant to model the network-broker error shape. + { + schema: 'ErrorResponse', + pathSubstring: '$', + reason: + 'SDK ErrorResponse models the AIRS scan error envelope; the network-broker spec has an ' + + 'unrelated component of the same name (code/message). Coincidental collision.', + }, + + // ── Network Broker fields the live API returns but the OpenAPI omits ────────── + // Verified live on 2026-07-09; modeled so callers get typed access. + { + schema: 'ChannelStats', + pathSubstring: 'client_version', + kind: 'extra-field', + reason: 'API returns `client_version` on channel stats; not in upstream OpenAPI ChannelStats.', + }, + { + schema: 'Channel', + pathSubstring: 'connected_clients_count', + kind: 'extra-field', + reason: 'API returns `connected_clients_count` on channels; not in upstream OpenAPI Channel.', + }, + { + schema: 'Channel', + pathSubstring: 'outdated_clients_count', + kind: 'extra-field', + reason: 'API returns `outdated_clients_count` on channels; not in upstream OpenAPI Channel.', + }, + { + schema: 'Channel', + pathSubstring: 'features', + kind: 'extra-field', + reason: + 'API returns `features` (per-adapter capability flags) on channels; not in upstream OpenAPI Channel.', + }, ]; /** diff --git a/specs/AIRS-Red-Teaming-Network-Broker.yaml b/specs/AIRS-Red-Teaming-Network-Broker.yaml new file mode 100644 index 0000000..a60cb02 --- /dev/null +++ b/specs/AIRS-Red-Teaming-Network-Broker.yaml @@ -0,0 +1,366 @@ +openapi: 3.0.3 +info: + title: Prisma AIRS Red Teaming - Network Channel API + version: 1.0.0 + description: "OpenAPI spec for AIRT Network Channels \xA9 2025 Palo Alto Networks,\ + \ Inc This Open API spec file was created on April 14, 2026. \xA9 2026 Palo Alto\ + \ Networks, Inc. Palo Alto Networks is a registered trademark of Palo Alto Networks.\ + \ A list of our trademarks can be found at [https://www.paloaltonetworks.com/company/trademarks.html](https://www.paloaltonetworks.com/company/trademarks.html).\ + \ All other marks mentioned herein may be trademarks of their respective companies." +servers: +- url: https://api.sase.paloaltonetworks.com/ai-red-teaming/data-plane/network-broker +security: +- bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ChannelStatus: + type: string + enum: + - ONLINE + - OFFLINE + - DRAFT + ErrorResponse: + type: object + properties: + code: + type: string + description: Machine-readable error code + example: resource_not_found + message: + type: string + description: Human-readable error message + example: Channel not found + required: + - code + - message + CreateChannelRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 64 + example: first + description: + type: string + example: First One!! + required: + - name + UpdateChannelRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 64 + example: updated-name + description: + type: string + example: Updated description + Channel: + type: object + properties: + uuid: + type: string + format: uuid + example: 3f838d08-dcd9-11ef-bcba-3a6d5ddfc830 + created_at: + type: string + format: date-time + example: '2025-01-27T18:05:10.670578Z' + updated_at: + type: string + format: date-time + example: '2025-01-27T18:05:10.670578Z' + name: + type: string + minLength: 1 + maxLength: 64 + example: first + description: + type: string + example: First One!! + added_by: + type: string + format: uuid + example: 3f838d08-dcd9-11ef-bcba-3a6d5ddfc830 + status: + allOf: + - $ref: '#/components/schemas/ChannelStatus' + example: OFFLINE + last_online_at: + type: string + format: date-time + example: '2025-01-27T18:09:08.311825Z' + ChannelStats: + type: object + properties: + network_channels_server_domain: + type: string + example: localhost:8010/tunnels + docker_registry: + type: string + format: domain + example: registry-proxy.prod.ai-red-teaming.paloaltonetworks.com + helm_chart: + type: string + format: domain + example: charts/network-channels-client + docker_image: + type: string + format: domain + example: images/network-channels-client + online_channels: + type: integer + minimum: 0 + example: 0 + total_channels: + type: integer + minimum: 0 + example: 1 + responses: + BadRequest: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalError: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +ExternalTags: + Channels: + title: Channels + description: Operations for managing network channels (create, update, list, and + retrieve statistics). + tags: + - Channels +paths: + /v1/channels: + get: + summary: List Channels + description: Retrieve the channels details through this Application Programming + Interface endpoint. + operationId: listChannels + responses: + '200': + description: Successfully retrieved list of channels + content: + application/json: + schema: + type: object + properties: + pagination: + type: object + properties: + total_items: + type: integer + example: 10 + data: + type: array + items: + $ref: '#/components/schemas/Channel' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + parameters: + - in: query + name: status + required: false + schema: + type: array + items: + $ref: '#/components/schemas/ChannelStatus' + style: form + explode: true + description: Filter channels by status + - in: query + name: search + required: false + schema: + type: string + - in: query + name: include_all_if_empty + required: false + schema: + type: boolean + description: If there are no filtered channels, return all channels + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + default: 100 + description: Number of items to return + - in: query + name: skip + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: Number of items to skip + tags: + - Channels + security: + - bearerAuth: [] + post: + summary: Create Channel + description: Create the channels details through this Application Programming + Interface endpoint. + operationId: createChannel + responses: + '200': + description: Channel created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + parameters: [] + tags: + - Channels + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChannelRequest' + /v1/channels/stats: + get: + summary: Get Channel Stats + description: Retrieve the stats details through this Application Programming + Interface endpoint. + operationId: getChannelStats + responses: + '200': + description: Successfully retrieved channel statistics + content: + application/json: + schema: + $ref: '#/components/schemas/ChannelStats' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + parameters: [] + tags: + - Channels + security: + - bearerAuth: [] + /v1/channels/{channelId}: + get: + summary: Get Channel + description: Retrieve the {channelId} details through this Application Programming + Interface endpoint. + operationId: getChannel + responses: + '200': + description: Successfully retrieved channel + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + parameters: + - in: path + name: channelId + required: true + schema: + type: string + format: uuid + example: 6dd68894-d8e8-11ef-9690-3a6d5ddfc830 + tags: + - Channels + security: + - bearerAuth: [] + patch: + summary: Update Channel + description: Patch the {channelId} details through this Application Programming + Interface endpoint. + operationId: updateChannel + responses: + '200': + description: Channel updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + parameters: + - in: path + name: channelId + required: true + schema: + type: string + format: uuid + example: 6dd68894-d8e8-11ef-9690-3a6d5ddfc830 + tags: + - Channels + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateChannelRequest' diff --git a/src/models/red-team-network-broker.ts b/src/models/red-team-network-broker.ts index daf4d18..0dc00d7 100644 --- a/src/models/red-team-network-broker.ts +++ b/src/models/red-team-network-broker.ts @@ -46,8 +46,14 @@ export const ChannelSchema = z description: z.string().nullable().optional(), // Kept as a plain string (not the enum) so unknown upstream statuses never fail parsing. status: z.string().nullable().optional(), + added_by: z.string().nullable().optional(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), + last_online_at: z.string().nullable().optional(), + // Present on live responses (not in the base OpenAPI Channel schema). + connected_clients_count: z.number().int().nullable().optional(), + outdated_clients_count: z.number().int().nullable().optional(), + features: z.record(z.boolean()).nullable().optional(), }) .passthrough(); export type Channel = z.infer; @@ -70,12 +76,14 @@ export type ChannelListResponse = z.infer; /** Network broker infrastructure and channel-count stats. */ export const ChannelStatsSchema = z .object({ - broker_server: z.string().nullable().optional(), - registry: z.string().nullable().optional(), - chart: z.string().nullable().optional(), - image: z.string().nullable().optional(), - online_channel_count: z.number().int().nullable().optional(), - total_channel_count: z.number().int().nullable().optional(), + network_channels_server_domain: z.string().nullable().optional(), + docker_registry: z.string().nullable().optional(), + helm_chart: z.string().nullable().optional(), + docker_image: z.string().nullable().optional(), + online_channels: z.number().int().nullable().optional(), + total_channels: z.number().int().nullable().optional(), + // Present on live responses (not in the base OpenAPI ChannelStats schema). + client_version: z.string().nullable().optional(), }) .passthrough(); export type ChannelStats = z.infer; diff --git a/src/red-team/network-broker-client.ts b/src/red-team/network-broker-client.ts index 0b967f2..be76d07 100644 --- a/src/red-team/network-broker-client.ts +++ b/src/red-team/network-broker-client.ts @@ -118,7 +118,7 @@ export class RedTeamNetworkBrokerClient { * * const stats = await rt.networkBroker.getChannelStats(); * // stats => - * // { broker_server: 'broker.example.com', online_channel_count: 3, total_channel_count: 5 } + * // { network_channels_server_domain: 'broker.example.com', online_channels: 3, total_channels: 5, client_version: '1.4.0' } * ``` */ async getChannelStats(): Promise { diff --git a/test/models/red-team-network-broker.spec.ts b/test/models/red-team-network-broker.spec.ts index 84e8dc3..fdb8d87 100644 --- a/test/models/red-team-network-broker.spec.ts +++ b/test/models/red-team-network-broker.spec.ts @@ -73,16 +73,18 @@ describe('red-team network broker models', () => { it('ChannelStatsSchema parses stats and keeps extra fields', () => { const parsed = ChannelStatsSchema.parse({ - broker_server: 'broker.example.com', - registry: 'registry.example.com', - chart: 'chart-1', - image: 'image-1', - online_channel_count: 3, - total_channel_count: 5, + network_channels_server_domain: 'broker.example.com', + docker_registry: 'registry.example.com', + helm_chart: 'charts/network-client:1.0.0', + docker_image: 'images/network-client:1.0.0', + online_channels: 3, + total_channels: 5, + client_version: '1.4.0', future_field: true, }); - expect(parsed.online_channel_count).toBe(3); - expect(parsed.total_channel_count).toBe(5); + expect(parsed.online_channels).toBe(3); + expect(parsed.total_channels).toBe(5); + expect(parsed.client_version).toBe('1.4.0'); expect((parsed as Record).future_field).toBe(true); }); }); diff --git a/test/red-team/_fixtures.ts b/test/red-team/_fixtures.ts index 01c8282..8aa2f6f 100644 --- a/test/red-team/_fixtures.ts +++ b/test/red-team/_fixtures.ts @@ -340,12 +340,13 @@ export function channelListMock(items: unknown[] = []): Record export function channelStatsMock(overrides: Record = {}): Record { return { - broker_server: 'broker.example.com', - registry: 'registry.example.com', - chart: 'network-broker-1.0.0', - image: 'network-broker:1.0.0', - online_channel_count: 3, - total_channel_count: 5, + network_channels_server_domain: 'broker.example.com', + docker_registry: 'registry.example.com', + helm_chart: 'charts/network-client:1.0.0', + docker_image: 'images/network-client:1.0.0', + online_channels: 3, + total_channels: 5, + client_version: '1.4.0', ...overrides, }; } diff --git a/test/red-team/network-broker-client.spec.ts b/test/red-team/network-broker-client.spec.ts index 74c4351..9dab569 100644 --- a/test/red-team/network-broker-client.spec.ts +++ b/test/red-team/network-broker-client.spec.ts @@ -103,7 +103,7 @@ describe('RedTeamNetworkBrokerClient', () => { mockFetch(channelStatsMock()); const result = await client.getChannelStats(); - expect(result.total_channel_count).toBe(5); + expect(result.total_channels).toBe(5); const [url, init] = lastCall(); expect(url).toBe('https://nb.example.com/v1/channels/stats'); expect(init.method).toBe('GET');