From ef102fbec86d9f9ce4defa81dbcfc21e9bd6642b Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:41:06 -0500 Subject: [PATCH 01/27] chore: bump @cdot65/prisma-airs-sdk to ^0.9.0 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2859913..f3a876f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.4", - "@cdot65/prisma-airs-sdk": "^0.8.3", + "@cdot65/prisma-airs-sdk": "^0.9.0", "@inquirer/prompts": "^8.3.0", "@langchain/anthropic": "^1.3.25", "@langchain/aws": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77049c4..3651b49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.14.4 version: 0.14.4(zod@3.25.76) '@cdot65/prisma-airs-sdk': - specifier: ^0.8.3 - version: 0.8.3 + specifier: ^0.9.0 + version: 0.9.0 '@inquirer/prompts': specifier: ^8.3.0 version: 8.3.0(@types/node@22.19.13) @@ -325,8 +325,8 @@ packages: cpu: [x64] os: [win32] - '@cdot65/prisma-airs-sdk@0.8.3': - resolution: {integrity: sha512-q6iNeaG/sdFBj7PguOk98TraS/YXfUmafFvYCIxhJ5fESt/Jjc1FACTZtCb6h0dJ+xdwRHLZRLSHv+4bk773jg==} + '@cdot65/prisma-airs-sdk@0.9.0': + resolution: {integrity: sha512-P1helNQ7qQ/LR6UVc3tb1UDk2d8sV647H7dxvQ/yZP+6Mpt8vYEtDDanTVSzpSYBlwn7Cbep4Xf+ByhaHsptHg==} engines: {node: '>=18'} '@cfworker/json-schema@4.1.1': @@ -2680,7 +2680,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.5': optional: true - '@cdot65/prisma-airs-sdk@0.8.3': + '@cdot65/prisma-airs-sdk@0.9.0': dependencies: zod: 3.25.76 From 7b50e93f1ede99c0a4d7a2fc1e94824f027b684a Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:42:44 -0500 Subject: [PATCH 02/27] feat(config): add PANW_DLP_ENDPOINT to schema and loader --- src/config/loader.ts | 1 + src/config/schema.ts | 1 + tests/unit/airs/dlp/client-wiring.spec.ts | 46 +++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 tests/unit/airs/dlp/client-wiring.spec.ts diff --git a/src/config/loader.ts b/src/config/loader.ts index 4188273..3935964 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -25,6 +25,7 @@ function fromEnv(): Record { mgmtTsgId: env.PANW_MGMT_TSG_ID, mgmtEndpoint: env.PANW_MGMT_ENDPOINT, mgmtTokenEndpoint: env.PANW_MGMT_TOKEN_ENDPOINT, + dlpEndpoint: env.PANW_DLP_ENDPOINT, scanConcurrency: env.SCAN_CONCURRENCY, dataDir: env.DATA_DIR, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 7ecc1d4..38a3a25 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -39,6 +39,7 @@ export const ConfigSchema = z.object({ mgmtTsgId: z.string().optional(), mgmtEndpoint: z.string().optional(), mgmtTokenEndpoint: z.string().optional(), + dlpEndpoint: z.string().optional(), // Tuning scanConcurrency: z.coerce.number().int().min(1).max(20).default(5), diff --git a/tests/unit/airs/dlp/client-wiring.spec.ts b/tests/unit/airs/dlp/client-wiring.spec.ts new file mode 100644 index 0000000..7f5869d --- /dev/null +++ b/tests/unit/airs/dlp/client-wiring.spec.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockMgmtCtor = vi.fn(); +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + ManagementClient: vi.fn().mockImplementation((opts) => { + mockMgmtCtor(opts); + return { + dlp: { dataFilteringProfiles: {}, dataPatterns: {}, dataProfiles: {}, dictionaries: {} }, + }; + }), +})); + +beforeEach(() => { + mockMgmtCtor.mockReset(); + delete process.env.PANW_DLP_ENDPOINT; +}); + +describe('PANW_DLP_ENDPOINT wiring', () => { + it('passes dlpEndpoint to ManagementClient when set', async () => { + process.env.PANW_DLP_ENDPOINT = 'https://example.com'; + const { loadConfig } = await import('../../../../src/config/loader.js'); + const { SdkManagementService } = await import('../../../../src/airs/management.js'); + const cfg = await loadConfig(); + new SdkManagementService({ dlpEndpoint: cfg.dlpEndpoint }); + expect(mockMgmtCtor).toHaveBeenCalledWith( + expect.objectContaining({ dlpEndpoint: 'https://example.com' }), + ); + }); + + it('treats empty string as unset (falls back to SDK default)', async () => { + process.env.PANW_DLP_ENDPOINT = ''; + const { loadConfig } = await import('../../../../src/config/loader.js'); + const { SdkManagementService } = await import('../../../../src/airs/management.js'); + const cfg = await loadConfig(); + new SdkManagementService({ dlpEndpoint: cfg.dlpEndpoint }); + expect(mockMgmtCtor).toHaveBeenCalledWith(expect.objectContaining({ dlpEndpoint: undefined })); + }); + + it('omits dlpEndpoint when env unset', async () => { + const { loadConfig } = await import('../../../../src/config/loader.js'); + const { SdkManagementService } = await import('../../../../src/airs/management.js'); + const cfg = await loadConfig(); + new SdkManagementService({ dlpEndpoint: cfg.dlpEndpoint }); + expect(mockMgmtCtor).toHaveBeenCalledWith(expect.objectContaining({ dlpEndpoint: undefined })); + }); +}); From 5fdc8a6f93fa78a9bee26be239fe67e7aed40815 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:45:54 -0500 Subject: [PATCH 03/27] refactor(airs): add getOrCreateManagementClient factory for shared token cache --- src/airs/management.ts | 20 +++++++++++++++++++- tests/unit/airs/dlp/client-wiring.spec.ts | 4 +++- tests/unit/airs/management.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/airs/management.ts b/src/airs/management.ts index f7b9542..1682f7a 100644 --- a/src/airs/management.ts +++ b/src/airs/management.ts @@ -30,7 +30,7 @@ export class SdkManagementService implements ManagementService { private client: ManagementClient; constructor(opts?: ManagementClientOptions) { - this.client = new ManagementClient(opts); + this.client = getOrCreateManagementClient(opts); } async createTopic(request: CreateCustomTopicRequest): Promise { @@ -415,3 +415,21 @@ export class SdkManagementService implements ManagementService { }; } } + +let _sharedClient: ManagementClient | undefined; + +/** + * Get or lazily construct the shared ManagementClient. + * Opts are only used on the first call; subsequent calls return the cached client. + */ +export function getOrCreateManagementClient(opts?: ManagementClientOptions): ManagementClient { + if (!_sharedClient) { + _sharedClient = new ManagementClient(opts); + } + return _sharedClient; +} + +/** Test-only: reset the cached client. */ +export function _resetManagementClient(): void { + _sharedClient = undefined; +} diff --git a/tests/unit/airs/dlp/client-wiring.spec.ts b/tests/unit/airs/dlp/client-wiring.spec.ts index 7f5869d..7364e0e 100644 --- a/tests/unit/airs/dlp/client-wiring.spec.ts +++ b/tests/unit/airs/dlp/client-wiring.spec.ts @@ -10,9 +10,11 @@ vi.mock('@cdot65/prisma-airs-sdk', () => ({ }), })); -beforeEach(() => { +beforeEach(async () => { mockMgmtCtor.mockReset(); delete process.env.PANW_DLP_ENDPOINT; + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); }); describe('PANW_DLP_ENDPOINT wiring', () => { diff --git a/tests/unit/airs/management.spec.ts b/tests/unit/airs/management.spec.ts index cc9e7fb..30c44d1 100644 --- a/tests/unit/airs/management.spec.ts +++ b/tests/unit/airs/management.spec.ts @@ -1200,3 +1200,23 @@ describe('SdkManagementService', () => { }); }); }); + +describe('getOrCreateManagementClient', () => { + it('returns the same client across calls', async () => { + const { getOrCreateManagementClient, _resetManagementClient } = await import( + '../../../src/airs/management.js' + ); + _resetManagementClient(); + const a = getOrCreateManagementClient({ + clientId: 'a', + clientSecret: 'b', + tsgId: 'c', + }); + const b = getOrCreateManagementClient({ + clientId: 'a', + clientSecret: 'b', + tsgId: 'c', + }); + expect(a).toBe(b); + }); +}); From d2ace0dd121f96bf44d4dafcad99b384ad91df18 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:49:43 -0500 Subject: [PATCH 04/27] feat(cli): add buildMergePatch util for --set/--clear DLP patches --- src/cli/commands/dlp/patch.ts | 48 ++++++++++++++++++++++++++++++++ tests/unit/cli/dlp-patch.spec.ts | 45 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/cli/commands/dlp/patch.ts create mode 100644 tests/unit/cli/dlp-patch.spec.ts diff --git a/src/cli/commands/dlp/patch.ts b/src/cli/commands/dlp/patch.ts new file mode 100644 index 0000000..48e08cd --- /dev/null +++ b/src/cli/commands/dlp/patch.ts @@ -0,0 +1,48 @@ +export interface BuildMergePatchOpts { + set?: string[]; + clear?: string[]; +} + +/** Build a JSON Merge Patch object from --set/--clear CLI flags. Pure. */ +export function buildMergePatch(opts: BuildMergePatchOpts): Record { + const out: Record = {}; + + for (const entry of opts.set ?? []) { + const eq = entry.indexOf('='); + if (eq < 1) throw new Error(`--set expected key=value, got: ${entry}`); + const key = entry.slice(0, eq); + const raw = entry.slice(eq + 1); + if (key.includes('.')) { + throw new Error(`--set ${key}: use --body-file for nested fields`); + } + if (raw === 'null') { + throw new Error(`--set ${key}=null: to clear a field, use --clear ${key}`); + } + out[key] = coerceValue(raw); + } + + for (const key of opts.clear ?? []) { + if (key.includes('.')) { + throw new Error(`--clear ${key}: use --body-file for nested fields`); + } + out[key] = null; + } + + return out; +} + +function coerceValue(raw: string): unknown { + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw !== '' && !Number.isNaN(Number(raw)) && /^-?\d+(\.\d+)?$/.test(raw)) { + return Number(raw); + } + if (raw.startsWith('{') || raw.startsWith('[') || raw.startsWith('"')) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} diff --git a/tests/unit/cli/dlp-patch.spec.ts b/tests/unit/cli/dlp-patch.spec.ts new file mode 100644 index 0000000..2b9351c --- /dev/null +++ b/tests/unit/cli/dlp-patch.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildMergePatch } from '../../../src/cli/commands/dlp/patch.js'; + +describe('buildMergePatch', () => { + it('sets string scalars', () => { + expect(buildMergePatch({ set: ['name=foo'] })).toEqual({ name: 'foo' }); + }); + it('coerces numbers', () => { + expect(buildMergePatch({ set: ['count=5'] })).toEqual({ count: 5 }); + }); + it('coerces booleans', () => { + expect(buildMergePatch({ set: ['enabled=true'] })).toEqual({ enabled: true }); + }); + it('parses JSON arrays', () => { + expect(buildMergePatch({ set: ['tags=["a","b"]'] })).toEqual({ tags: ['a', 'b'] }); + }); + it('parses JSON objects', () => { + expect(buildMergePatch({ set: ['config={"a":1,"b":true}'], clear: [] })).toEqual({ + config: { a: 1, b: true }, + }); + }); + it('allows literal string "null" via quoted JSON', () => { + expect(buildMergePatch({ set: ['name="null"'] })).toEqual({ name: 'null' }); + }); + it('clears fields via --clear', () => { + expect(buildMergePatch({ clear: ['description'] })).toEqual({ description: null }); + }); + it('combines set and clear', () => { + expect(buildMergePatch({ set: ['a=1'], clear: ['b'] })).toEqual({ a: 1, b: null }); + }); + it('rejects --set key=null literal', () => { + expect(() => buildMergePatch({ set: ['name=null'] })).toThrow(/to clear a field, use --clear/); + }); + it('rejects dotted keys', () => { + expect(() => buildMergePatch({ set: ['nested.field=x'] })).toThrow( + 'use --body-file for nested fields', + ); + }); + it('rejects malformed --set entries', () => { + expect(() => buildMergePatch({ set: ['malformed'] })).toThrow('expected key=value'); + }); + it('returns empty object when no inputs', () => { + expect(buildMergePatch({})).toEqual({}); + }); +}); From 94a1281804e8bf679255ea79c9ee1f9299307917 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:54:45 -0500 Subject: [PATCH 05/27] feat(cli): add parseBody util for --body/--body-file DLP inputs --- src/cli/commands/dlp/patch.ts | 31 +++++++++++++++++++++++++++++++ tests/unit/cli/dlp-patch.spec.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/dlp/patch.ts b/src/cli/commands/dlp/patch.ts index 48e08cd..1096823 100644 --- a/src/cli/commands/dlp/patch.ts +++ b/src/cli/commands/dlp/patch.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; + export interface BuildMergePatchOpts { set?: string[]; clear?: string[]; @@ -46,3 +49,31 @@ function coerceValue(raw: string): unknown { } return raw; } + +export interface ParseBodyOpts { + body?: string; + bodyFile?: string; + stdin?: Readable; +} + +/** Read --body or --body-file and JSON.parse. Returns undefined if neither supplied. */ +export async function parseBody(opts: ParseBodyOpts): Promise { + let raw: string | undefined; + if (opts.bodyFile) { + raw = await readFile(opts.bodyFile, 'utf-8'); + } else if (opts.body === '-') { + const chunks: Buffer[] = []; + for await (const chunk of opts.stdin ?? process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + raw = Buffer.concat(chunks).toString('utf-8'); + } else if (opts.body !== undefined) { + raw = opts.body; + } + if (raw === undefined) return undefined; + try { + return JSON.parse(raw); + } catch (e) { + throw new Error(`invalid JSON in body: ${(e as Error).message}`); + } +} diff --git a/tests/unit/cli/dlp-patch.spec.ts b/tests/unit/cli/dlp-patch.spec.ts index 2b9351c..0652814 100644 --- a/tests/unit/cli/dlp-patch.spec.ts +++ b/tests/unit/cli/dlp-patch.spec.ts @@ -1,5 +1,9 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; -import { buildMergePatch } from '../../../src/cli/commands/dlp/patch.js'; +import { buildMergePatch, parseBody } from '../../../src/cli/commands/dlp/patch.js'; describe('buildMergePatch', () => { it('sets string scalars', () => { @@ -43,3 +47,28 @@ describe('buildMergePatch', () => { expect(buildMergePatch({})).toEqual({}); }); }); + +describe('parseBody', () => { + it('reads JSON from a file path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dlp-')); + const file = join(dir, 'body.json'); + await writeFile(file, '{"name":"foo"}'); + expect(await parseBody({ bodyFile: file })).toEqual({ name: 'foo' }); + await rm(dir, { recursive: true }); + }); + + it('reads JSON from stdin when body === "-"', async () => { + const stdin = Readable.from(['{"x":1}']); + expect(await parseBody({ body: '-', stdin })).toEqual({ x: 1 }); + }); + + it('throws on malformed JSON', async () => { + await expect(parseBody({ body: '-', stdin: Readable.from(['not json']) })).rejects.toThrow( + /invalid JSON/i, + ); + }); + + it('returns undefined when neither flag set', async () => { + expect(await parseBody({})).toBeUndefined(); + }); +}); From 1e0c6990726f9db52cc71f4b01503244b6b79a54 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 12:58:00 -0500 Subject: [PATCH 06/27] feat(airs/dlp): add service-interface types and barrel --- src/airs/dlp/index.ts | 5 +++ src/airs/dlp/types.ts | 102 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/airs/dlp/index.ts create mode 100644 src/airs/dlp/types.ts diff --git a/src/airs/dlp/index.ts b/src/airs/dlp/index.ts new file mode 100644 index 0000000..c52eefb --- /dev/null +++ b/src/airs/dlp/index.ts @@ -0,0 +1,5 @@ +export * from './data-filtering-profiles.js'; +export * from './data-patterns.js'; +export * from './data-profiles.js'; +export * from './dictionaries.js'; +export * from './types.js'; diff --git a/src/airs/dlp/types.ts b/src/airs/dlp/types.ts new file mode 100644 index 0000000..96dee5b --- /dev/null +++ b/src/airs/dlp/types.ts @@ -0,0 +1,102 @@ +export type { + AdvancedDataProfileRequest, + DataFilteringProfileListParams, + DataFilteringProfileRequest, + DataFilteringProfileResponse, + DataPatternListParams, + DataPatternPatchRequest, + DataPatternRequest, + DataPatternResponse, + DataProfileListParams, + DataProfilePatchRequest, + DataProfileResponse, + DetectionRule, + DictionaryFileInput, + DictionaryGetParams, + DictionaryListParams, + DictionaryPatchRequest, + DictionaryRequest, + DictionaryResponse, + DictionaryUploadParams, + PageDataFilteringProfileResponse, + PageDataPatternResponse, + PageDataProfileResponse, + PageDictionaryResponse, +} from '@cdot65/prisma-airs-sdk'; + +export interface DataFilteringProfilesService { + list( + params?: import('@cdot65/prisma-airs-sdk').DataFilteringProfileListParams, + ): Promise; + get(id: string): Promise; + replace( + id: string, + body: import('@cdot65/prisma-airs-sdk').DataFilteringProfileRequest, + ): Promise; +} + +export interface DataPatternsService { + list( + params?: import('@cdot65/prisma-airs-sdk').DataPatternListParams, + ): Promise; + create( + body: import('@cdot65/prisma-airs-sdk').DataPatternRequest, + ): Promise; + get(id: string): Promise; + replace( + id: string, + body: import('@cdot65/prisma-airs-sdk').DataPatternRequest, + ): Promise; + patch( + id: string, + body: import('@cdot65/prisma-airs-sdk').DataPatternPatchRequest, + ): Promise; + delete(id: string): Promise; +} + +export interface DataProfilesService { + list( + params?: import('@cdot65/prisma-airs-sdk').DataProfileListParams, + ): Promise; + create( + body: import('@cdot65/prisma-airs-sdk').AdvancedDataProfileRequest, + ): Promise; + get(id: string): Promise; + replace( + id: string, + body: import('@cdot65/prisma-airs-sdk').AdvancedDataProfileRequest, + ): Promise; + patch( + id: string, + body: import('@cdot65/prisma-airs-sdk').DataProfilePatchRequest, + ): Promise; +} + +/** Sentinel result for replace() when API returns 204 No Content. */ +export interface DictionaryReplaceFallback { + kind: 'fallback'; + id: string; +} + +export interface DictionariesService { + list( + params?: import('@cdot65/prisma-airs-sdk').DictionaryListParams, + ): Promise; + create( + params: import('@cdot65/prisma-airs-sdk').DictionaryUploadParams, + ): Promise; + get( + id: string, + params?: import('@cdot65/prisma-airs-sdk').DictionaryGetParams, + ): Promise; + /** Returns the response on 200, re-gets on 204; returns fallback sentinel if re-get fails. */ + replace( + id: string, + params: import('@cdot65/prisma-airs-sdk').DictionaryUploadParams, + ): Promise; + patch( + id: string, + body: import('@cdot65/prisma-airs-sdk').DictionaryPatchRequest, + ): Promise; + delete(id: string): Promise; +} From 32b5f322d73cfd23d85fb9e4de9c64811ba2bf5a Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:00:27 -0500 Subject: [PATCH 07/27] feat(airs/dlp): add SdkDataFilteringProfilesService --- src/airs/dlp/data-filtering-profiles.ts | 32 ++++++++ .../airs/dlp/data-filtering-profiles.spec.ts | 76 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/airs/dlp/data-filtering-profiles.ts create mode 100644 tests/unit/airs/dlp/data-filtering-profiles.spec.ts diff --git a/src/airs/dlp/data-filtering-profiles.ts b/src/airs/dlp/data-filtering-profiles.ts new file mode 100644 index 0000000..009166e --- /dev/null +++ b/src/airs/dlp/data-filtering-profiles.ts @@ -0,0 +1,32 @@ +import type { + DataFilteringProfileListParams, + DataFilteringProfileRequest, + DataFilteringProfileResponse, + ManagementClientOptions, + PageDataFilteringProfileResponse, +} from '@cdot65/prisma-airs-sdk'; +import { getOrCreateManagementClient } from '../management.js'; +import type { DataFilteringProfilesService } from './types.js'; + +export class SdkDataFilteringProfilesService implements DataFilteringProfilesService { + private readonly client; + + constructor(opts?: ManagementClientOptions) { + this.client = getOrCreateManagementClient(opts).dlp.dataFilteringProfiles; + } + + async list(params?: DataFilteringProfileListParams): Promise { + return this.client.list(params); + } + + async get(id: string): Promise { + return this.client.get(id); + } + + async replace( + id: string, + body: DataFilteringProfileRequest, + ): Promise { + return this.client.replace(id, body); + } +} diff --git a/tests/unit/airs/dlp/data-filtering-profiles.spec.ts b/tests/unit/airs/dlp/data-filtering-profiles.spec.ts new file mode 100644 index 0000000..4e3b488 --- /dev/null +++ b/tests/unit/airs/dlp/data-filtering-profiles.spec.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockList = vi.fn(); +const mockGet = vi.fn(); +const mockReplace = vi.fn(); + +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + ManagementClient: vi.fn().mockImplementation(() => ({ + dlp: { + dataFilteringProfiles: { list: mockList, get: mockGet, replace: mockReplace }, + }, + })), +})); + +beforeEach(() => { + mockList.mockReset(); + mockGet.mockReset(); + mockReplace.mockReset(); +}); + +describe('SdkDataFilteringProfilesService', () => { + it('list passes page/size/sort through', async () => { + mockList.mockResolvedValue({ content: [], totalElements: 0 }); + const { SdkDataFilteringProfilesService } = await import( + '../../../../src/airs/dlp/data-filtering-profiles.js' + ); + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const svc = new SdkDataFilteringProfilesService(); + await svc.list({ page: 1, size: 25, sort: ['name,asc', 'createdAt,desc'] }); + expect(mockList).toHaveBeenCalledWith({ + page: 1, + size: 25, + sort: ['name,asc', 'createdAt,desc'], + }); + }); + + it('list handles empty Page envelope', async () => { + mockList.mockResolvedValue({ content: [], totalElements: 0, pageable: { pageNumber: 0 } }); + const { SdkDataFilteringProfilesService } = await import( + '../../../../src/airs/dlp/data-filtering-profiles.js' + ); + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const svc = new SdkDataFilteringProfilesService(); + const r = await svc.list(); + expect(r.totalElements).toBe(0); + }); + + it('get round-trips id', async () => { + mockGet.mockResolvedValue({ id: 'abc', name: 'x' }); + const { SdkDataFilteringProfilesService } = await import( + '../../../../src/airs/dlp/data-filtering-profiles.js' + ); + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const svc = new SdkDataFilteringProfilesService(); + const r = await svc.get('abc'); + expect(mockGet).toHaveBeenCalledWith('abc'); + expect(r.id).toBe('abc'); + }); + + it('replace passes body through', async () => { + mockReplace.mockResolvedValue({ id: 'abc' }); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + const body = { name: 'p' } as any; + const { SdkDataFilteringProfilesService } = await import( + '../../../../src/airs/dlp/data-filtering-profiles.js' + ); + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const svc = new SdkDataFilteringProfilesService(); + await svc.replace('abc', body); + expect(mockReplace).toHaveBeenCalledWith('abc', body); + }); +}); From aab923cab9671b500e6b16a25c65bb869fb93e9d Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:02:37 -0500 Subject: [PATCH 08/27] feat(airs/dlp): add SdkDataPatternsService (full CRUD + soft-delete) --- src/airs/dlp/data-patterns.ts | 42 +++++++++++++ tests/unit/airs/dlp/data-patterns.spec.ts | 73 +++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/airs/dlp/data-patterns.ts create mode 100644 tests/unit/airs/dlp/data-patterns.spec.ts diff --git a/src/airs/dlp/data-patterns.ts b/src/airs/dlp/data-patterns.ts new file mode 100644 index 0000000..8df8360 --- /dev/null +++ b/src/airs/dlp/data-patterns.ts @@ -0,0 +1,42 @@ +import type { + DataPatternListParams, + DataPatternPatchRequest, + DataPatternRequest, + DataPatternResponse, + ManagementClientOptions, + PageDataPatternResponse, +} from '@cdot65/prisma-airs-sdk'; +import { getOrCreateManagementClient } from '../management.js'; +import type { DataPatternsService } from './types.js'; + +export class SdkDataPatternsService implements DataPatternsService { + private readonly client; + + constructor(opts?: ManagementClientOptions) { + this.client = getOrCreateManagementClient(opts).dlp.dataPatterns; + } + + async list(params?: DataPatternListParams): Promise { + return this.client.list(params); + } + + async create(body: DataPatternRequest): Promise { + return this.client.create(body); + } + + async get(id: string): Promise { + return this.client.get(id); + } + + async replace(id: string, body: DataPatternRequest): Promise { + return this.client.replace(id, body); + } + + async patch(id: string, body: DataPatternPatchRequest): Promise { + return this.client.patch(id, body); + } + + async delete(id: string): Promise { + await this.client.delete(id); + } +} diff --git a/tests/unit/airs/dlp/data-patterns.spec.ts b/tests/unit/airs/dlp/data-patterns.spec.ts new file mode 100644 index 0000000..6902c75 --- /dev/null +++ b/tests/unit/airs/dlp/data-patterns.spec.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = { + list: vi.fn(), + create: vi.fn(), + get: vi.fn(), + replace: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +}; + +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + ManagementClient: vi.fn().mockImplementation(() => ({ + dlp: { dataPatterns: mocks }, + })), +})); + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +async function freshService() { + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const { SdkDataPatternsService } = await import('../../../../src/airs/dlp/data-patterns.js'); + return new SdkDataPatternsService(); +} + +describe('SdkDataPatternsService', () => { + it('list passes params', async () => { + mocks.list.mockResolvedValue({ content: [], totalElements: 0 }); + const svc = await freshService(); + await svc.list({ page: 0, size: 50 }); + expect(mocks.list).toHaveBeenCalledWith({ page: 0, size: 50 }); + }); + + it('create passes body', async () => { + mocks.create.mockResolvedValue({ id: 'p1' }); + const svc = await freshService(); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + await svc.create({ name: 'p', detection_method: { type: 'regex', regex: '.*' } } as any); + expect(mocks.create).toHaveBeenCalled(); + }); + + it('get by id', async () => { + mocks.get.mockResolvedValue({ id: 'p1' }); + const svc = await freshService(); + expect((await svc.get('p1')).id).toBe('p1'); + }); + + it('replace by id+body', async () => { + mocks.replace.mockResolvedValue({ id: 'p1' }); + const svc = await freshService(); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + await svc.replace('p1', { name: 'p' } as any); + expect(mocks.replace).toHaveBeenCalledWith('p1', { name: 'p' }); + }); + + it('patch passes raw merge-patch body', async () => { + mocks.patch.mockResolvedValue({ id: 'p1' }); + const svc = await freshService(); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + await svc.patch('p1', { name: 'new', description: null } as any); + expect(mocks.patch).toHaveBeenCalledWith('p1', { name: 'new', description: null }); + }); + + it('delete returns void', async () => { + mocks.delete.mockResolvedValue(undefined); + const svc = await freshService(); + await expect(svc.delete('p1')).resolves.toBeUndefined(); + expect(mocks.delete).toHaveBeenCalledWith('p1'); + }); +}); From a567b6fbb0575d83073eac5cb308a209ccbc2ae8 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:04:55 -0500 Subject: [PATCH 09/27] feat(airs/dlp): add SdkDataProfilesService (no delete; patch profile_status='deleted') --- src/airs/dlp/data-profiles.ts | 38 ++++++++++ tests/unit/airs/dlp/data-profiles.spec.ts | 85 +++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/airs/dlp/data-profiles.ts create mode 100644 tests/unit/airs/dlp/data-profiles.spec.ts diff --git a/src/airs/dlp/data-profiles.ts b/src/airs/dlp/data-profiles.ts new file mode 100644 index 0000000..f4779ba --- /dev/null +++ b/src/airs/dlp/data-profiles.ts @@ -0,0 +1,38 @@ +import type { + AdvancedDataProfileRequest, + DataProfileListParams, + DataProfilePatchRequest, + DataProfileResponse, + ManagementClientOptions, + PageDataProfileResponse, +} from '@cdot65/prisma-airs-sdk'; +import { getOrCreateManagementClient } from '../management.js'; +import type { DataProfilesService } from './types.js'; + +export class SdkDataProfilesService implements DataProfilesService { + private readonly client; + + constructor(opts?: ManagementClientOptions) { + this.client = getOrCreateManagementClient(opts).dlp.dataProfiles; + } + + async list(params?: DataProfileListParams): Promise { + return this.client.list(params); + } + + async create(body: AdvancedDataProfileRequest): Promise { + return this.client.create(body); + } + + async get(id: string): Promise { + return this.client.get(id); + } + + async replace(id: string, body: AdvancedDataProfileRequest): Promise { + return this.client.replace(id, body); + } + + async patch(id: string, body: DataProfilePatchRequest): Promise { + return this.client.patch(id, body); + } +} diff --git a/tests/unit/airs/dlp/data-profiles.spec.ts b/tests/unit/airs/dlp/data-profiles.spec.ts new file mode 100644 index 0000000..e3690c8 --- /dev/null +++ b/tests/unit/airs/dlp/data-profiles.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = { + list: vi.fn(), + create: vi.fn(), + get: vi.fn(), + replace: vi.fn(), + patch: vi.fn(), +}; + +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + ManagementClient: vi.fn().mockImplementation(() => ({ + dlp: { dataProfiles: mocks }, + })), +})); + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +async function freshService() { + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const { SdkDataProfilesService } = await import('../../../../src/airs/dlp/data-profiles.js'); + return new SdkDataProfilesService(); +} + +describe('SdkDataProfilesService', () => { + it('list passes params', async () => { + mocks.list.mockResolvedValue({ content: [], totalElements: 0 }); + await (await freshService()).list({ page: 0 }); + expect(mocks.list).toHaveBeenCalledWith({ page: 0 }); + }); + + it('create basic DetectionRule variant', async () => { + mocks.create.mockResolvedValue({ id: 'dp1' }); + await (await freshService()).create({ + name: 'p', + profile_type: 'custom', + detection_rules: [{ type: 'basic', data_pattern_id: 'x', occurrence: { min: 1 } }], + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + } as any); + expect(mocks.create).toHaveBeenCalled(); + }); + + it('create expression_tree variant', async () => { + mocks.create.mockResolvedValue({ id: 'dp1' }); + await (await freshService()).create({ + name: 'p', + profile_type: 'custom', + detection_rules: [{ type: 'expression_tree', expression: { operator: 'and', operands: [] } }], + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + } as any); + expect(mocks.create).toHaveBeenCalled(); + }); + + it('create multi_profile variant', async () => { + mocks.create.mockResolvedValue({ id: 'dp1' }); + await (await freshService()).create({ + name: 'p', + profile_type: 'custom', + detection_rules: [{ type: 'multi_profile', profile_ids: ['a', 'b'] }], + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + } as any); + expect(mocks.create).toHaveBeenCalled(); + }); + + it('get/replace/patch round-trip', async () => { + mocks.get.mockResolvedValue({ id: 'dp1' }); + mocks.replace.mockResolvedValue({ id: 'dp1' }); + mocks.patch.mockResolvedValue({ id: 'dp1' }); + const svc = await freshService(); + expect((await svc.get('dp1')).id).toBe('dp1'); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + await svc.replace('dp1', { name: 'p' } as any); + expect(mocks.replace).toHaveBeenCalledWith('dp1', { name: 'p' }); + await svc.patch('dp1', { + profile_status: 'deleted', + name: 'p', + profile_type: 'custom', + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + } as any); + expect(mocks.patch).toHaveBeenCalled(); + }); +}); From 4c430fbdca8bbd5c677613a4a13b6ffd795e1d31 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:07:34 -0500 Subject: [PATCH 10/27] feat(airs/dlp): add SdkDictionariesService (multipart, 200/204 replace fallback) --- src/airs/dlp/dictionaries.ts | 52 ++++++++++++ tests/unit/airs/dlp/dictionaries.spec.ts | 101 +++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/airs/dlp/dictionaries.ts create mode 100644 tests/unit/airs/dlp/dictionaries.spec.ts diff --git a/src/airs/dlp/dictionaries.ts b/src/airs/dlp/dictionaries.ts new file mode 100644 index 0000000..421b28c --- /dev/null +++ b/src/airs/dlp/dictionaries.ts @@ -0,0 +1,52 @@ +import type { + DictionaryGetParams, + DictionaryListParams, + DictionaryPatchRequest, + DictionaryResponse, + DictionaryUploadParams, + ManagementClientOptions, + PageDictionaryResponse, +} from '@cdot65/prisma-airs-sdk'; +import { getOrCreateManagementClient } from '../management.js'; +import type { DictionariesService, DictionaryReplaceFallback } from './types.js'; + +export class SdkDictionariesService implements DictionariesService { + private readonly client; + + constructor(opts?: ManagementClientOptions) { + this.client = getOrCreateManagementClient(opts).dlp.dictionaries; + } + + async list(params?: DictionaryListParams): Promise { + return this.client.list(params); + } + + async create(params: DictionaryUploadParams): Promise { + return this.client.create(params); + } + + async get(id: string, params?: DictionaryGetParams): Promise { + return this.client.get(id, params); + } + + async replace( + id: string, + params: DictionaryUploadParams, + ): Promise { + const r = await this.client.replace(id, params); + if (r !== undefined) return r; + try { + return await this.client.get(id); + } catch { + return { kind: 'fallback', id }; + } + } + + async patch(id: string, body: DictionaryPatchRequest): Promise { + return this.client.patch(id, body); + } + + async delete(id: string): Promise { + await this.client.delete(id); + } +} diff --git a/tests/unit/airs/dlp/dictionaries.spec.ts b/tests/unit/airs/dlp/dictionaries.spec.ts new file mode 100644 index 0000000..0353439 --- /dev/null +++ b/tests/unit/airs/dlp/dictionaries.spec.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = { + list: vi.fn(), + create: vi.fn(), + get: vi.fn(), + replace: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +}; + +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + ManagementClient: vi.fn().mockImplementation(() => ({ + dlp: { dictionaries: mocks }, + })), +})); + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +async function freshService() { + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); + const { SdkDictionariesService } = await import('../../../../src/airs/dlp/dictionaries.js'); + return new SdkDictionariesService(); +} + +describe('SdkDictionariesService', () => { + it('list passes keywords flag', async () => { + mocks.list.mockResolvedValue({ content: [], totalElements: 0 }); + await (await freshService()).list({ page: 0, keywords: true }); + expect(mocks.list).toHaveBeenCalledWith({ page: 0, keywords: true }); + }); + + it('create passes metadata + file + includeKeywords through', async () => { + mocks.create.mockResolvedValue({ id: 'd1' }); + const buf = Buffer.from('word1\nword2\n'); + await (await freshService()).create({ + // biome-ignore lint/suspicious/noExplicitAny: test fixture metadata shape not exercised + metadata: { name: 'd', region: 'us', category: 'misc', original_file_name: 'd.txt' } as any, + file: buf, + includeKeywords: true, + }); + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ file: buf, includeKeywords: true }), + ); + }); + + it('get with --include-keywords', async () => { + mocks.get.mockResolvedValue({ id: 'd1' }); + await (await freshService()).get('d1', { includeKeywords: true }); + expect(mocks.get).toHaveBeenCalledWith('d1', { includeKeywords: true }); + }); + + it('replace returns 200 body verbatim', async () => { + mocks.replace.mockResolvedValue({ id: 'd1', name: 'x' }); + const r = await (await freshService()).replace('d1', { + // biome-ignore lint/suspicious/noExplicitAny: test fixture metadata shape not exercised + metadata: { name: 'x', region: 'us', category: 'misc', original_file_name: 'd.txt' } as any, + file: Buffer.from('a'), + }); + expect(r).toEqual({ id: 'd1', name: 'x' }); + }); + + it('replace 204 → re-gets and returns get result', async () => { + mocks.replace.mockResolvedValue(undefined); + mocks.get.mockResolvedValue({ id: 'd1', name: 'after-204' }); + const r = await (await freshService()).replace('d1', { + // biome-ignore lint/suspicious/noExplicitAny: test fixture metadata shape not exercised + metadata: { name: 'x', region: 'us', category: 'misc', original_file_name: 'd.txt' } as any, + file: Buffer.from('a'), + }); + expect(mocks.get).toHaveBeenCalledWith('d1'); + expect(r).toEqual({ id: 'd1', name: 'after-204' }); + }); + + it('replace 204 + re-get failure → returns fallback sentinel', async () => { + mocks.replace.mockResolvedValue(undefined); + mocks.get.mockRejectedValue(new Error('transient 503')); + const r = await (await freshService()).replace('d1', { + // biome-ignore lint/suspicious/noExplicitAny: test fixture metadata shape not exercised + metadata: { name: 'x', region: 'us', category: 'misc', original_file_name: 'd.txt' } as any, + file: Buffer.from('a'), + }); + expect(r).toEqual({ kind: 'fallback', id: 'd1' }); + }); + + it('patch passes body', async () => { + mocks.patch.mockResolvedValue({ id: 'd1' }); + // biome-ignore lint/suspicious/noExplicitAny: test fixture body shape not exercised + await (await freshService()).patch('d1', { description: 'new' } as any); + expect(mocks.patch).toHaveBeenCalledWith('d1', { description: 'new' }); + }); + + it('delete returns void', async () => { + mocks.delete.mockResolvedValue(undefined); + await expect((await freshService()).delete('d1')).resolves.toBeUndefined(); + expect(mocks.delete).toHaveBeenCalledWith('d1'); + }); +}); From 361aa3786c09da4c9a668815ec02c6122829cd55 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:12:29 -0500 Subject: [PATCH 11/27] feat(cli/renderer): add dlpFilteringProfiles + dlpPatterns renderer namespaces --- src/cli/renderer/dlp.ts | 58 +++++++++++++++++++++++++++++ src/cli/renderer/index.ts | 1 + tests/unit/cli/dlp-renderer.spec.ts | 44 ++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/cli/renderer/dlp.ts create mode 100644 tests/unit/cli/dlp-renderer.spec.ts diff --git a/src/cli/renderer/dlp.ts b/src/cli/renderer/dlp.ts new file mode 100644 index 0000000..af73f14 --- /dev/null +++ b/src/cli/renderer/dlp.ts @@ -0,0 +1,58 @@ +import { dump as yamlDump } from 'js-yaml'; +import type { OutputFormat } from './common.js'; + +// biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads +function emit(payload: any, fmt: OutputFormat): void { + switch (fmt) { + case 'json': + console.log(JSON.stringify(payload, null, 2)); + return; + case 'yaml': + console.log(yamlDump(payload)); + return; + default: + console.log(typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); + } +} + +export const dlpFilteringProfiles = { + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderList(page: any, fmt: OutputFormat) { + emit(page, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderGet(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderReplaced(item: any, fmt: OutputFormat) { + if (fmt === 'pretty') console.log(` replaced ${item.id}`); + else emit(item, fmt); + }, +}; + +export const dlpPatterns = { + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderList(page: any, fmt: OutputFormat) { + emit(page, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderCreated(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderGet(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderReplaced(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderPatched(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + renderArchived(id: string) { + console.log(` archived ${id}`); + }, +}; diff --git a/src/cli/renderer/index.ts b/src/cli/renderer/index.ts index 8bf44f3..f1155be 100644 --- a/src/cli/renderer/index.ts +++ b/src/cli/renderer/index.ts @@ -1,6 +1,7 @@ export * from './audit.js'; export * from './backup.js'; export * from './common.js'; +export * from './dlp.js'; export * from './eval.js'; export * from './modelsecurity.js'; export * from './redteam.js'; diff --git a/tests/unit/cli/dlp-renderer.spec.ts b/tests/unit/cli/dlp-renderer.spec.ts new file mode 100644 index 0000000..0816b33 --- /dev/null +++ b/tests/unit/cli/dlp-renderer.spec.ts @@ -0,0 +1,44 @@ +// biome-ignore-all lint/suspicious/noExplicitAny: test payloads use arbitrary SDK shapes +import { describe, expect, it, vi } from 'vitest'; +import { dlpFilteringProfiles, dlpPatterns } from '../../../src/cli/renderer/dlp.js'; + +describe('dlpFilteringProfiles renderer', () => { + it('renderList json emits content + totalElements', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpFilteringProfiles.renderList( + { content: [{ id: 'a', name: 'p' }], totalElements: 1, pageable: { pageNumber: 0 } } as any, + 'json', + ); + expect(spy.mock.calls[0]?.[0]).toContain('"totalElements": 1'); + spy.mockRestore(); + }); + it('renderGet json round-trips', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpFilteringProfiles.renderGet({ id: 'a' } as any, 'json'); + expect(spy.mock.calls[0]?.[0]).toContain('"id": "a"'); + spy.mockRestore(); + }); + it('renderReplaced emits confirmation', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpFilteringProfiles.renderReplaced({ id: 'a' } as any, 'pretty'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('replaced')); + spy.mockRestore(); + }); +}); + +describe('dlpPatterns renderer', () => { + it('renderArchived prints "archived "', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpPatterns.renderArchived('p1'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('archived p1')); + spy.mockRestore(); + }); + it('renderCreated/Patched/Replaced emit json on demand', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpPatterns.renderCreated({ id: 'p1' } as any, 'json'); + dlpPatterns.renderPatched({ id: 'p1', name: 'x' } as any, 'json'); + dlpPatterns.renderReplaced({ id: 'p1' } as any, 'json'); + expect(spy.mock.calls.every((c) => String(c[0]).includes('"id": "p1"'))).toBe(true); + spy.mockRestore(); + }); +}); From 6daa387b0a6f06831b4a5cec44e18ee6e6c350ff Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:14:03 -0500 Subject: [PATCH 12/27] feat(cli/renderer): add dlpProfiles + dlpDictionaries (incl. 204 fallback) --- src/cli/renderer/dlp.ts | 52 +++++++++++++++++++++++++++++ tests/unit/cli/dlp-renderer.spec.ts | 32 +++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/cli/renderer/dlp.ts b/src/cli/renderer/dlp.ts index af73f14..ee7cb39 100644 --- a/src/cli/renderer/dlp.ts +++ b/src/cli/renderer/dlp.ts @@ -56,3 +56,55 @@ export const dlpPatterns = { console.log(` archived ${id}`); }, }; + +export const dlpProfiles = { + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderList(page: any, fmt: OutputFormat) { + emit(page, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderCreated(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderGet(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderReplaced(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderPatched(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, +}; + +export const dlpDictionaries = { + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderList(page: any, fmt: OutputFormat) { + emit(page, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderCreated(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderGet(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderReplaced(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads + renderPatched(item: any, fmt: OutputFormat) { + emit(item, fmt); + }, + renderReplaced204Fallback(id: string) { + console.log(` replaced ${id} (state not echoed by region)`); + }, + renderDeleted(id: string) { + console.log(` deleted ${id}`); + }, +}; diff --git a/tests/unit/cli/dlp-renderer.spec.ts b/tests/unit/cli/dlp-renderer.spec.ts index 0816b33..362f33f 100644 --- a/tests/unit/cli/dlp-renderer.spec.ts +++ b/tests/unit/cli/dlp-renderer.spec.ts @@ -1,6 +1,11 @@ // biome-ignore-all lint/suspicious/noExplicitAny: test payloads use arbitrary SDK shapes import { describe, expect, it, vi } from 'vitest'; -import { dlpFilteringProfiles, dlpPatterns } from '../../../src/cli/renderer/dlp.js'; +import { + dlpDictionaries, + dlpFilteringProfiles, + dlpPatterns, + dlpProfiles, +} from '../../../src/cli/renderer/dlp.js'; describe('dlpFilteringProfiles renderer', () => { it('renderList json emits content + totalElements', () => { @@ -42,3 +47,28 @@ describe('dlpPatterns renderer', () => { spy.mockRestore(); }); }); + +describe('dlpProfiles renderer', () => { + it('renderList json', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpProfiles.renderList({ content: [{ id: 'dp' }], totalElements: 1 } as any, 'json'); + expect(spy.mock.calls[0]?.[0]).toContain('"id": "dp"'); + spy.mockRestore(); + }); +}); + +describe('dlpDictionaries renderer', () => { + it('renderDeleted', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpDictionaries.renderDeleted('d1'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('deleted d1')); + spy.mockRestore(); + }); + it('renderReplaced204Fallback', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + dlpDictionaries.renderReplaced204Fallback('d1'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('replaced d1')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('state not echoed')); + spy.mockRestore(); + }); +}); From 91f82494e51e01cbb85af1fcf247a35524575757 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:15:08 -0500 Subject: [PATCH 13/27] feat(cli): add dlp filtering-profiles list/get/replace commands --- src/cli/commands/dlp/filtering-profiles.ts | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/cli/commands/dlp/filtering-profiles.ts diff --git a/src/cli/commands/dlp/filtering-profiles.ts b/src/cli/commands/dlp/filtering-profiles.ts new file mode 100644 index 0000000..f60c8b4 --- /dev/null +++ b/src/cli/commands/dlp/filtering-profiles.ts @@ -0,0 +1,71 @@ +import type { Command } from 'commander'; +import { SdkDataFilteringProfilesService } from '../../../airs/dlp/data-filtering-profiles.js'; +import { dlpFilteringProfiles, type OutputFormat, renderError } from '../../renderer/index.js'; +import { parseBody } from './patch.js'; + +function listFlags(cmd: T): T { + return cmd + .option('--page ', 'Zero-indexed page number', (v) => Number.parseInt(v, 10)) + .option('--size ', 'Page size', (v) => Number.parseInt(v, 10)) + .option('--sort ', 'Sort criteria (repeatable)', (v, prev: string[] = []) => [ + ...prev, + v, + ]) + .option('--output ', 'Output format', 'pretty'); +} + +export function register(dlp: Command): void { + const group = dlp + .command('filtering-profiles') + .description( + 'DLP data filtering profiles. Read + full-replace only. ' + + 'Create, patch, and delete are not exposed by the DLP API.', + ); + + listFlags(group.command('list').description('List filtering profiles')).action(async (opts) => { + try { + const svc = new SdkDataFilteringProfilesService(); + const r = await svc.list({ page: opts.page, size: opts.size, sort: opts.sort }); + dlpFilteringProfiles.renderList(r, opts.output as OutputFormat); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('get ') + .description('Get a filtering profile by id') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const svc = new SdkDataFilteringProfilesService(); + dlpFilteringProfiles.renderGet(await svc.get(id), opts.output as OutputFormat); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('replace ') + .description('Full-replace a filtering profile (PUT)') + .option('--body ', 'JSON body (or "-" for stdin)') + .option('--body-file ', 'Path to JSON body file') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const body = await parseBody({ body: opts.body, bodyFile: opts.bodyFile }); + if (!body) throw new Error('--body or --body-file is required'); + const svc = new SdkDataFilteringProfilesService(); + dlpFilteringProfiles.renderReplaced( + // biome-ignore lint/suspicious/noExplicitAny: parseBody returns unknown, cast for replace() + await svc.replace(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); +} From dde73e5362c1a0beef81aadef90c9a087fcfd6a6 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:16:01 -0500 Subject: [PATCH 14/27] feat(cli): add dlp patterns CRUD commands --- src/cli/commands/dlp/patterns.ts | 138 +++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/cli/commands/dlp/patterns.ts diff --git a/src/cli/commands/dlp/patterns.ts b/src/cli/commands/dlp/patterns.ts new file mode 100644 index 0000000..f90181c --- /dev/null +++ b/src/cli/commands/dlp/patterns.ts @@ -0,0 +1,138 @@ +import type { Command } from 'commander'; +import { SdkDataPatternsService } from '../../../airs/dlp/data-patterns.js'; +import { dlpPatterns, type OutputFormat, renderError } from '../../renderer/index.js'; +import { buildMergePatch, parseBody } from './patch.js'; + +function listFlags(cmd: T): T { + return cmd + .option('--page ', 'Zero-indexed page number', (v) => Number.parseInt(v, 10)) + .option('--size ', 'Page size', (v) => Number.parseInt(v, 10)) + .option('--sort ', 'Sort criteria (repeatable)', (v, prev: string[] = []) => [ + ...prev, + v, + ]) + .option('--output ', 'Output format', 'pretty'); +} + +export function register(dlp: Command): void { + const group = dlp.command('patterns').description('DLP data patterns (full CRUD)'); + + listFlags(group.command('list').description('List data patterns')).action(async (opts) => { + try { + const svc = new SdkDataPatternsService(); + dlpPatterns.renderList( + await svc.list({ page: opts.page, size: opts.size, sort: opts.sort }), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('create') + .description('Create a data pattern') + .option('--body ', 'JSON body (or "-" for stdin)') + .option('--body-file ', 'Path to JSON body file') + .option('--output ', 'Output format', 'pretty') + .action(async (opts) => { + try { + const body = await parseBody({ body: opts.body, bodyFile: opts.bodyFile }); + if (!body) throw new Error('--body or --body-file is required'); + const svc = new SdkDataPatternsService(); + dlpPatterns.renderCreated( + // biome-ignore lint/suspicious/noExplicitAny: parseBody returns unknown, cast for create() + await svc.create(body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('get ') + .description('Get a data pattern by id') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + dlpPatterns.renderGet( + await new SdkDataPatternsService().get(id), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('replace ') + .description('Full-replace a data pattern (PUT)') + .option('--body ', 'JSON body (or "-" for stdin)') + .option('--body-file ', 'Path to JSON body file') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const body = await parseBody({ body: opts.body, bodyFile: opts.bodyFile }); + if (!body) throw new Error('--body or --body-file is required'); + dlpPatterns.renderReplaced( + // biome-ignore lint/suspicious/noExplicitAny: parseBody returns unknown, cast for replace() + await new SdkDataPatternsService().replace(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('patch ') + .description( + 'JSON Merge Patch. Use --body-file for nested fields. ' + + '--set/--clear coerce values: numbers/booleans/JSON literals. ' + + 'To force a string, quote: --set count=\'\\"5\\"\'.', + ) + .option('--body-file ', 'JSON merge-patch body file') + .option('--set ', 'Set scalar field (repeatable)', (v, p: string[] = []) => [...p, v]) + .option( + '--clear ', + 'Clear field via merge-patch null (repeatable)', + (v, p: string[] = []) => [...p, v], + ) + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + if (opts.bodyFile && (opts.set || opts.clear)) { + throw new Error('--body-file is mutually exclusive with --set/--clear'); + } + const body = opts.bodyFile + ? await parseBody({ bodyFile: opts.bodyFile }) + : buildMergePatch({ set: opts.set, clear: opts.clear }); + dlpPatterns.renderPatched( + // biome-ignore lint/suspicious/noExplicitAny: buildMergePatch returns Record, cast for patch() + await new SdkDataPatternsService().patch(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('delete ') + .description('Soft-delete (archive) a data pattern') + .action(async (id) => { + try { + await new SdkDataPatternsService().delete(id); + dlpPatterns.renderArchived(id); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} From d341eff6ab6c860e3285832c9b68716a27da6744 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:16:43 -0500 Subject: [PATCH 15/27] feat(cli): add dlp profiles commands + stub delete UX sugar --- src/cli/commands/dlp/profiles.ts | 145 +++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/cli/commands/dlp/profiles.ts diff --git a/src/cli/commands/dlp/profiles.ts b/src/cli/commands/dlp/profiles.ts new file mode 100644 index 0000000..297be61 --- /dev/null +++ b/src/cli/commands/dlp/profiles.ts @@ -0,0 +1,145 @@ +import type { Command } from 'commander'; +import { SdkDataProfilesService } from '../../../airs/dlp/data-profiles.js'; +import { dlpProfiles, type OutputFormat, renderError } from '../../renderer/index.js'; +import { buildMergePatch, parseBody } from './patch.js'; + +function listFlags(cmd: T): T { + return cmd + .option('--page ', 'Zero-indexed page number', (v) => Number.parseInt(v, 10)) + .option('--size ', 'Page size', (v) => Number.parseInt(v, 10)) + .option('--sort ', 'Sort criteria (repeatable)', (v, prev: string[] = []) => [ + ...prev, + v, + ]) + .option('--output ', 'Output format', 'pretty'); +} + +export function register(dlp: Command): void { + const group = dlp + .command('profiles') + .description( + 'DLP data profiles. DELETE is not exposed by the DLP API. To remove a profile, patch with profile_status: "deleted".', + ); + + listFlags(group.command('list').description('List data profiles')).action(async (opts) => { + try { + const svc = new SdkDataProfilesService(); + dlpProfiles.renderList( + await svc.list({ page: opts.page, size: opts.size, sort: opts.sort }), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('create') + .description('Create a data profile') + .option('--body ', 'JSON body (or "-" for stdin)') + .option('--body-file ', 'Path to JSON body file') + .option('--output ', 'Output format', 'pretty') + .action(async (opts) => { + try { + const body = await parseBody({ body: opts.body, bodyFile: opts.bodyFile }); + if (!body) throw new Error('--body or --body-file is required'); + const svc = new SdkDataProfilesService(); + dlpProfiles.renderCreated( + // biome-ignore lint/suspicious/noExplicitAny: parseBody returns unknown, cast for create() + await svc.create(body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('get ') + .description('Get a data profile by id') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + dlpProfiles.renderGet( + await new SdkDataProfilesService().get(id), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('replace ') + .description('Full-replace a data profile (PUT)') + .option('--body ', 'JSON body (or "-" for stdin)') + .option('--body-file ', 'Path to JSON body file') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const body = await parseBody({ body: opts.body, bodyFile: opts.bodyFile }); + if (!body) throw new Error('--body or --body-file is required'); + dlpProfiles.renderReplaced( + // biome-ignore lint/suspicious/noExplicitAny: parseBody returns unknown, cast for replace() + await new SdkDataProfilesService().replace(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('patch ') + .description( + 'JSON Merge Patch. body must include name + profile_type. Use --body-file for nested fields. ' + + '--set/--clear coerce values: numbers/booleans/JSON literals. ' + + 'To force a string, quote: --set count=\'\\"5\\"\'.', + ) + .option('--body-file ', 'JSON merge-patch body file') + .option('--set ', 'Set scalar field (repeatable)', (v, p: string[] = []) => [...p, v]) + .option( + '--clear ', + 'Clear field via merge-patch null (repeatable)', + (v, p: string[] = []) => [...p, v], + ) + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + if (opts.bodyFile && (opts.set || opts.clear)) { + throw new Error('--body-file is mutually exclusive with --set/--clear'); + } + const body = opts.bodyFile + ? await parseBody({ bodyFile: opts.bodyFile }) + : buildMergePatch({ set: opts.set, clear: opts.clear }); + dlpProfiles.renderPatched( + // biome-ignore lint/suspicious/noExplicitAny: buildMergePatch returns Record, cast for patch() + await new SdkDataProfilesService().patch(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('delete ') + .description('Not supported — prints the patch idiom and exits 2') + .action((id) => { + console.error(` +This DLP API has no DELETE for data profiles. +To soft-delete, fetch the profile to get its name + profile_type, then patch: + + airs runtime dlp profiles get ${id} --output json + airs runtime dlp profiles patch ${id} --body-file - < Date: Sat, 23 May 2026 13:17:41 -0500 Subject: [PATCH 16/27] feat(cli): add dlp dictionaries commands (multipart + 204 fallback) --- src/cli/commands/dlp/dictionaries.ts | 176 +++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/cli/commands/dlp/dictionaries.ts diff --git a/src/cli/commands/dlp/dictionaries.ts b/src/cli/commands/dlp/dictionaries.ts new file mode 100644 index 0000000..dfe1d83 --- /dev/null +++ b/src/cli/commands/dlp/dictionaries.ts @@ -0,0 +1,176 @@ +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import type { Command } from 'commander'; +import { SdkDictionariesService } from '../../../airs/dlp/dictionaries.js'; +import type { DictionaryRequest } from '../../../airs/dlp/types.js'; +import { dlpDictionaries, type OutputFormat, renderError } from '../../renderer/index.js'; +import { buildMergePatch, parseBody } from './patch.js'; + +// biome-ignore lint/suspicious/noExplicitAny: opts object from commander +async function buildMetadata(opts: any): Promise { + if (opts.metadataFile) { + return JSON.parse(await readFile(opts.metadataFile, 'utf-8')); + } + if (!opts.name || !opts.category || !opts.region || !opts.file) { + throw new Error('--name, --category, --region, and --file are required'); + } + return { + name: opts.name, + category: opts.category, + region_name: opts.region, + original_file_name: basename(opts.file), + description: opts.description, + classification: opts.classification, + } as DictionaryRequest; +} + +export function register(dlp: Command): void { + const group = dlp.command('dictionaries').description('DLP dictionaries (multipart upload)'); + + group + .command('list') + .description('List dictionaries') + .option('--page ', '', (v) => Number.parseInt(v, 10)) + .option('--size ', '', (v) => Number.parseInt(v, 10)) + .option('--sort ', '(repeatable)', (v, p: string[] = []) => [...p, v]) + .option('--keywords', 'Include keyword list in response') + .option('--include-keywords', 'Alias for --keywords') + .option('--output ', 'Output format', 'pretty') + .action(async (opts) => { + try { + const includeKeywords = opts.keywords || opts.includeKeywords; + dlpDictionaries.renderList( + await new SdkDictionariesService().list({ + page: opts.page, + size: opts.size, + sort: opts.sort, + keywords: includeKeywords ? true : undefined, + }), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('create') + .description('Create dictionary via multipart upload') + .option('--name ', '') + .option('--category ', '') + .option('--region ', '') + .option('--description ', '') + .option('--classification ', '') + .option('--file ', 'Keyword file') + .option('--metadata-file ', 'JSON metadata file (overrides --name/--category/...)') + .option('--include-keywords', 'Include keywords in response') + .action(async (opts) => { + try { + const metadata = await buildMetadata(opts); + if (!opts.file) throw new Error('--file is required (multipart upload)'); + const file = await readFile(opts.file); + const r = await new SdkDictionariesService().create({ + metadata, + file, + includeKeywords: opts.includeKeywords, + }); + dlpDictionaries.renderCreated(r, 'pretty'); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('get ') + .option('--keywords', '') + .option('--include-keywords', 'Alias for --keywords') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const includeKeywords = opts.keywords || opts.includeKeywords; + dlpDictionaries.renderGet( + await new SdkDictionariesService().get(id, { includeKeywords }), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + group + .command('replace ') + .description( + 'Full-replace via multipart upload. --file required. May return 200 (body) ' + + 'or 204 (re-get; falls back to "(state not echoed)" on transient failure).', + ) + .option('--name ', '') + .option('--category ', '') + .option('--region ', '') + .option('--description ', '') + .option('--classification ', '') + .option('--file ', 'Keyword file (required)') + .option('--metadata-file ', 'JSON metadata file') + .option('--include-keywords', '') + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + const metadata = await buildMetadata(opts); + if (!opts.file) throw new Error('--file is required (multipart upload)'); + const file = await readFile(opts.file); + const r = await new SdkDictionariesService().replace(id, { + metadata, + file, + includeKeywords: opts.includeKeywords, + }); + if ('kind' in r && r.kind === 'fallback') { + dlpDictionaries.renderReplaced204Fallback(id); + } else { + dlpDictionaries.renderReplaced(r, opts.output as OutputFormat); + } + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('patch ') + .option('--body-file ', '') + .option('--set ', '(repeatable)', (v, p: string[] = []) => [...p, v]) + .option('--clear ', '(repeatable)', (v, p: string[] = []) => [...p, v]) + .option('--output ', 'Output format', 'pretty') + .action(async (id, opts) => { + try { + if (opts.bodyFile && (opts.set || opts.clear)) { + throw new Error('--body-file is mutually exclusive with --set/--clear'); + } + const body = opts.bodyFile + ? await parseBody({ bodyFile: opts.bodyFile }) + : buildMergePatch({ set: opts.set, clear: opts.clear }); + dlpDictionaries.renderPatched( + // biome-ignore lint/suspicious/noExplicitAny: buildMergePatch returns Record, cast for patch() + await new SdkDictionariesService().patch(id, body as any), + opts.output as OutputFormat, + ); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + }); + + group + .command('delete ') + .description('Delete a dictionary') + .action(async (id) => { + try { + await new SdkDictionariesService().delete(id); + dlpDictionaries.renderDeleted(id); + } catch (err) { + renderError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} From 7375c0b7474815c79749f2c0a9c734d2b22ec969 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:18:41 -0500 Subject: [PATCH 17/27] feat(cli): register dlp command group on runtime --- src/cli/commands/dlp/index.ts | 18 ++++++++++++++++++ src/cli/commands/runtime.ts | 6 ++++++ 2 files changed, 24 insertions(+) create mode 100644 src/cli/commands/dlp/index.ts diff --git a/src/cli/commands/dlp/index.ts b/src/cli/commands/dlp/index.ts new file mode 100644 index 0000000..6b9f96c --- /dev/null +++ b/src/cli/commands/dlp/index.ts @@ -0,0 +1,18 @@ +import type { Command } from 'commander'; +import { register as registerDictionaries } from './dictionaries.js'; +import { register as registerFilteringProfiles } from './filtering-profiles.js'; +import { register as registerPatterns } from './patterns.js'; +import { register as registerProfiles } from './profiles.js'; + +export function registerDlpCommands(runtime: Command): void { + const dlp = runtime + .command('dlp') + .description( + 'DLP management (filtering-profiles, patterns, profiles, dictionaries). ' + + 'For the read-only DLP profile list used in Security Profiles, see `runtime dlp-profiles list`.', + ); + registerFilteringProfiles(dlp); + registerPatterns(dlp); + registerProfiles(dlp); + registerDictionaries(dlp); +} diff --git a/src/cli/commands/runtime.ts b/src/cli/commands/runtime.ts index cef8858..87e93d2 100644 --- a/src/cli/commands/runtime.ts +++ b/src/cli/commands/runtime.ts @@ -31,6 +31,7 @@ import { renderTopicList, } from '../renderer/index.js'; import { registerAuditCommand } from './audit.js'; +import { registerDlpCommands } from './dlp/index.js'; import { registerDlpGenCommand } from './dlp-gen.js'; import { registerCleanupCommand } from './profiles-cleanup.js'; import { registerApplyCommand } from './topics-apply.js'; @@ -892,6 +893,11 @@ export function registerRuntimeCommand(program: Command): void { } }); + // ----------------------------------------------------------------------- + // runtime dlp — DLP management subcommands + // ----------------------------------------------------------------------- + registerDlpCommands(runtime); + // ----------------------------------------------------------------------- // runtime dlp-gen — generate clean + dirty DLP test files // ----------------------------------------------------------------------- From 7137f640b1a4fbc7282b873f882987d65a2341dd Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:19:06 -0500 Subject: [PATCH 18/27] chore: add changeset for dlp commands --- .changeset/0019-dlp-commands.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/0019-dlp-commands.md diff --git a/.changeset/0019-dlp-commands.md b/.changeset/0019-dlp-commands.md new file mode 100644 index 0000000..e3d1bda --- /dev/null +++ b/.changeset/0019-dlp-commands.md @@ -0,0 +1,9 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Add `airs runtime dlp` command group for full DLP CRUD across four subclients: +filtering-profiles (list/get/replace), patterns (full CRUD + soft-delete), +profiles (no delete — patch profile_status), and dictionaries (multipart upload, +200/204 replace handling). Bumps `@cdot65/prisma-airs-sdk` pin to `^0.9.0`. +Adds optional `PANW_DLP_ENDPOINT` env var (defaults to SDK built-in). From 895def2b1c54d98b6e6c4a320e5334d00586b17d Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:19:57 -0500 Subject: [PATCH 19/27] docs(readme): document dlp commands and PANW_DLP_ENDPOINT --- .env.example | 4 ++ README.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/.env.example b/.env.example index 5eea980..8538b59 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,7 @@ SCAN_CONCURRENCY=5 # MEMORY_ENABLED=true # MEMORY_DIR=~/.prisma-airs/memory # MAX_MEMORY_CHARS=3000 + +# ── DLP API (Data Loss Prevention) ──────────────────────────────── +# Optional — overrides default DLP base URL (api.dlp.paloaltonetworks.com) +PANW_DLP_ENDPOINT= diff --git a/README.md b/README.md index e4c8827..7cff7d8 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,105 @@ airs redteam report airs model-security scans create --config scan-config.json ``` +## DLP Management + +Manage Data Loss Prevention resources across filtering profiles, patterns, profiles, and dictionaries. Each subclient supports different operation patterns: + +- **Filtering profiles** — read-only + full replace +- **Patterns** — full CRUD with soft-delete via patch +- **Profiles** — full CRUD, use patch to soft-delete via `profile_status: "deleted"` +- **Dictionaries** — create, replace, and delete with multipart upload support + +### Filtering Profiles + +Read-only access with full-replace support: + +```bash +# List all filtering profiles +airs runtime dlp filtering-profiles list --output json + +# Get a specific filtering profile +airs runtime dlp filtering-profiles get + +# Replace a filtering profile +airs runtime dlp filtering-profiles replace --body-file profile.json +``` + +### Patterns + +Full CRUD with soft-delete: + +```bash +# List patterns +airs runtime dlp patterns list + +# Get a pattern +airs runtime dlp patterns get + +# Create a new pattern +airs runtime dlp patterns create --body-file pattern.json + +# Update a pattern +airs runtime dlp patterns patch --set name="Updated Name" + +# Soft-delete a pattern +airs runtime dlp patterns patch --set is_archived=true + +# Hard delete a pattern +airs runtime dlp patterns delete +``` + +### Profiles + +Full CRUD with soft-delete via `profile_status`: + +```bash +# List profiles +airs runtime dlp profiles list + +# Get a profile +airs runtime dlp profiles get + +# Create a profile +airs runtime dlp profiles create --body-file profile.json + +# Update a profile +airs runtime dlp profiles patch --body-file - < --body-file - < +``` + +### Dictionaries + +Multipart upload support for dictionary management: + +```bash +# List dictionaries +airs runtime dlp dictionaries list + +# Get a dictionary +airs runtime dlp dictionaries get + +# Create a dictionary with file upload +airs runtime dlp dictionaries create --name "Allowlist" --category Confidential \ + --region us --file keywords.txt + +# Replace a dictionary (multipart upload) +airs runtime dlp dictionaries replace --file keywords.txt --name "Allowlist" \ + --category Confidential --region us + +# Delete a dictionary +airs runtime dlp dictionaries delete +``` + ## Commands | Command | Description | @@ -63,6 +162,28 @@ airs model-security scans create --config scan-config.json | `runtime deployment-profiles` | Deployment profile listing | | `runtime dlp-profiles` | DLP profile listing | | `runtime scan-logs` | Scan log querying | +| `runtime dlp filtering-profiles` | DLP filtering profile listing and full-replace | +| `runtime dlp filtering-profiles list` | List all filtering profiles | +| `runtime dlp filtering-profiles get` | Get a specific filtering profile | +| `runtime dlp filtering-profiles replace` | Replace a filtering profile | +| `runtime dlp patterns` | DLP pattern CRUD with soft-delete | +| `runtime dlp patterns list` | List all patterns | +| `runtime dlp patterns get` | Get a specific pattern | +| `runtime dlp patterns create` | Create a new pattern | +| `runtime dlp patterns patch` | Update a pattern or soft-delete via `is_archived` | +| `runtime dlp patterns delete` | Hard delete a pattern | +| `runtime dlp profiles` | DLP profile CRUD with soft-delete via `profile_status` | +| `runtime dlp profiles list` | List all profiles | +| `runtime dlp profiles get` | Get a specific profile | +| `runtime dlp profiles create` | Create a new profile | +| `runtime dlp profiles patch` | Update a profile or soft-delete via `profile_status: "deleted"` | +| `runtime dlp profiles delete` | Hard delete a profile | +| `runtime dlp dictionaries` | DLP dictionary management with multipart upload | +| `runtime dlp dictionaries list` | List all dictionaries | +| `runtime dlp dictionaries get` | Get a specific dictionary | +| `runtime dlp dictionaries create` | Create a dictionary with file upload | +| `runtime dlp dictionaries replace` | Replace a dictionary with multipart upload | +| `runtime dlp dictionaries delete` | Delete a dictionary | | `redteam scan` | Adversarial scanning (STATIC, DYNAMIC, CUSTOM) | | `redteam targets` | Red team target CRUD | | `redteam prompt-sets` | Custom prompt set management | @@ -76,6 +197,17 @@ airs model-security scans create --config scan-config.json Credentials are configured via environment variables or `~/.prisma-airs/config.json`. See [`.env.example`](.env.example) for the full list. +| Variable | Purpose | Required | +|----------|---------|----------| +| `PANW_AI_SEC_API_KEY` | Scanning API authentication | Yes (for scanning) | +| `PANW_MGMT_CLIENT_ID` | Management API OAuth client ID | Yes (for management) | +| `PANW_MGMT_CLIENT_SECRET` | Management API OAuth client secret | Yes (for management) | +| `PANW_MGMT_TSG_ID` | Management API tenant security group ID | Yes (for management) | +| `PANW_DLP_ENDPOINT` | DLP API base URL override | No (defaults to `api.dlp.paloaltonetworks.com`) | +| `LLM_PROVIDER` | LLM provider for profile audits | Yes (for audit) | +| `ANTHROPIC_API_KEY` | Claude API key | Yes (if using Claude) | +| `GOOGLE_API_KEY` | Google Gemini API key | Yes (if using Gemini) | + **Required for scanning:** `PANW_AI_SEC_API_KEY` **Required for management:** `PANW_MGMT_CLIENT_ID`, `PANW_MGMT_CLIENT_SECRET`, `PANW_MGMT_TSG_ID` **Required for profile audits:** one LLM provider key + scanning + management credentials From 05082b9e46cdb89343cddf58beebce81ecc36dac Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:21:05 -0500 Subject: [PATCH 20/27] docs: document dlp namespace in CLAUDE.md and MIGRATION.md --- CLAUDE.md | 13 +++++++++++++ MIGRATION.md | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1b82497..f06ad56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ src/ │ │ ├── backup.ts # Backup core logic (backupTargets, createRedTeamService, toBackupData) │ │ ├── restore.ts # Restore core logic (restoreTargets, prepareTargetPayload) │ │ ├── profiles-cleanup.ts # Delete old profile revisions, keep only latest per name +│ │ ├── dlp/ # DLP CLI commands (4 subgroups + aggregator + shared patch/parseBody utils) │ │ ├── runtime.ts # Runtime scanning + config management + topics + audit (profiles) │ │ ├── audit.ts # Profile-level multi-topic evaluation (registered under runtime profiles) │ │ ├── redteam.ts # Red team operations (scan, targets CRUD + backup/restore, prompt-sets CRUD, prompts CRUD, properties) @@ -99,6 +100,7 @@ src/ │ ├── runtime.ts # SdkRuntimeService — sync scan, async bulk scan, poll results, CSV export │ ├── management.ts # SdkManagementService — topic CRUD, profile CRUD, API keys, customer apps, deployment/DLP profiles, scan logs │ ├── promptsets.ts # SdkPromptSetService — custom prompt set CRUD via RedTeamClient +│ ├── dlp/ # DLP namespace: filtering-profiles, patterns, profiles, dictionaries SDK service wrappers │ ├── redteam.ts # SdkRedTeamService — red team scan CRUD, polling, reports │ ├── modelsecurity.ts # SdkModelSecurityService — security groups, rules, scans, labels │ └── types.ts # ScanResult, ScanService, ManagementService, PromptSetService, RedTeamService, ModelSecurityService @@ -192,6 +194,10 @@ These four commands compose into an autoresearch-style optimization loop: an age - `airs runtime deployment-profiles {list}` — deployment profile listing (`--unactivated` filter) - `airs runtime dlp-profiles {list}` — DLP profile listing - `airs runtime scan-logs {query}` — scan log querying (`--interval`/`--unit hours`/`--filter`) + - `airs runtime dlp filtering-profiles {list, get, replace}` — read + full-replace + - `airs runtime dlp patterns {list, create, get, replace, patch, delete}` — full CRUD + soft-delete + - `airs runtime dlp profiles {list, create, get, replace, patch, delete*}` — no real delete; patch profile_status + - `airs runtime dlp dictionaries {list, create, get, replace, patch, delete}` — multipart upload, 200/204 fallback ### Red Team (`src/airs/redteam.ts`, `src/airs/promptsets.ts`) - `SdkRedTeamService` wraps `RedTeamClient` for scan CRUD, polling, reports, **target CRUD** @@ -205,6 +211,13 @@ These four commands compose into an autoresearch-style optimization loop: an age - CLI top-level commands: `scan`, `status `, `report `, `list`, `abort `, `categories` - CLI subcommand groups: `targets {list,get,create,update,delete,probe,profile,update-profile,validate-auth,metadata,init,templates,backup,restore}`, `prompt-sets {list,get,create,update,archive,download,upload}`, `prompts {list,get,add,update,delete}`, `properties {list,create,values,add-value}` +### DLP (`src/airs/dlp/`) +- **Shape**: thin SDK wrappers; one class per resource (filtering-profiles, patterns, profiles, dictionaries); all instantiate via `getOrCreateManagementClient()` for shared OAuth token cache +- **Merge-patch semantics**: JSON Merge Patch (RFC 7396) — `null` clears, omit means leave alone. CLI `patch` exposes `--set k=v` (with coercion of `"true"/"false"/numbers/JSON literals`; quote `'"5"'` to force string) and `--clear key` (sets `null`). `--body-file` for nested fields; mutually exclusive with `--set/--clear`. +- **Multipart upload (dictionaries only)**: `create`/`replace` send `json` (metadata) + `file` parts. `--file` required; metadata via flags OR `--metadata-file`. +- **200/204 replace fallback (dictionaries only)**: PUT can return 200 with body or 204 No Content (region-dependent). On 204 the SDK re-GETs; if that fails, the service returns `{ kind: 'fallback', id }` sentinel and the CLI prints `(state not echoed by region)`. +- **No-DELETE for filtering-profiles and profiles**: API doesn't expose DELETE for either. `profiles delete ` is a stub that prints the patch idiom and exits 2. `filtering-profiles` has no `delete` subcommand at all. + ### Model Security (`src/airs/modelsecurity.ts`) - `SdkModelSecurityService` wraps `ModelSecurityClient` for security groups, rules, scans, labels, PyPI auth - snake_case (SDK) → camelCase normalization via `normalizeGroup()`, `normalizeRule()`, etc. diff --git a/MIGRATION.md b/MIGRATION.md index 1340c32..c3b29ca 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,15 @@ # Migration: daystrom → prisma-airs-cli +## SDK 0.9.0 (2026-05-23) + +The CLI now exposes `airs runtime dlp` (filtering-profiles, patterns, profiles, +dictionaries) backed by the SDK's new `client.dlp.*` namespace. Existing +`airs runtime dlp-profiles list` (read-only DLP profile references) is unchanged. + +New optional env: `PANW_DLP_ENDPOINT` (defaults to api.dlp.paloaltonetworks.com). + +## daystrom → prisma-airs-cli Rename + This project was renamed from `daystrom` to `prisma-airs-cli` in March 2026. ## What Changed From c9b75a96f8a442d5ad36bfd14cc33a9ea03cea2c Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:22:36 -0500 Subject: [PATCH 21/27] docs(mkdocs): add dlp command walkthroughs + nav --- docs/runtime/dlp/dictionaries.md | 164 ++++++++++++++++++++ docs/runtime/dlp/filtering-profiles.md | 111 ++++++++++++++ docs/runtime/dlp/patterns.md | 178 ++++++++++++++++++++++ docs/runtime/dlp/profiles.md | 203 +++++++++++++++++++++++++ mkdocs.yml | 5 + 5 files changed, 661 insertions(+) create mode 100644 docs/runtime/dlp/dictionaries.md create mode 100644 docs/runtime/dlp/filtering-profiles.md create mode 100644 docs/runtime/dlp/patterns.md create mode 100644 docs/runtime/dlp/profiles.md diff --git a/docs/runtime/dlp/dictionaries.md b/docs/runtime/dlp/dictionaries.md new file mode 100644 index 0000000..ad6a557 --- /dev/null +++ b/docs/runtime/dlp/dictionaries.md @@ -0,0 +1,164 @@ +--- +title: Data Dictionaries +--- + +# Data Dictionaries + +Manage Dictionaries on the DLP service. Dictionaries provide keyword-list-driven detection for DLP patterns. Create and replace use multipart upload (metadata + keyword file). Full CRUD is available: list, create, get, replace, patch, delete. + +## Commands + +| Command | Description | Exit Code | +|---------|-------------|-----------| +| `list` | List all dictionaries with optional keyword inclusion | 1 on error | +| `create` | Create a new dictionary (multipart: metadata + keyword file) | 1 on error | +| `get` | Fetch a single dictionary by ID | 1 on error | +| `replace` | Full multipart replace of metadata and keyword file | 1 on error | +| `patch` | JSON Merge Patch: update only specified metadata fields | 1 on error | +| `delete` | Delete a dictionary | 1 on error | + +## list + +List all dictionaries with optional pagination. + +```bash +airs runtime dlp dictionaries list +airs runtime dlp dictionaries list --page 0 --size 50 --output json +airs runtime dlp dictionaries list --keywords # Include keyword array in output +``` + +**Output** — paginated list of dictionary objects; `keywords` array populated only if requested. + +## create + +Create a new dictionary. Requires multipart: metadata JSON + keyword file (newline-delimited text). + +First, create the keyword file `codenames.txt`: + +``` +alpha +bravo +charlie +delta +echo +``` + +Then create the metadata file `dict-meta.json`: + +```json +{ + "category": "Confidential", + "name": "project-codenames", + "original_file_name": "codenames.txt", + "region_name": "us-west-2", + "description": "Internal project codenames — phonetic alphabet", + "is_case_sensitive": false +} +``` + +Then invoke create: + +```bash +airs runtime dlp dictionaries create --metadata-file dict-meta.json --file-path codenames.txt +airs runtime dlp dictionaries create --metadata-file dict-meta.json --file-path codenames.txt --output json +airs runtime dlp dictionaries create --metadata-file dict-meta.json --file-path codenames.txt --keywords +``` + +**Output** — created dictionary with server-assigned `id` and lifecycle stamps. If `--keywords` is passed, the `keywords[]` array is included showing all parsed entries. + +## get + +Retrieve a single dictionary by ID. + +```bash +airs runtime dlp dictionaries get dict-7f30c2 +airs runtime dlp dictionaries get dict-7f30c2 --output json +airs runtime dlp dictionaries get dict-7f30c2 --keywords # Include keyword array +``` + +**Output** — full dictionary object with metadata, keyword count, and optionally the keyword array. + +## replace + +Perform a full multipart replace of both metadata and keyword file. The API may return 200+body (some regions) or 204+empty (others). + +Create updated metadata `dict-meta-v2.json`: + +```json +{ + "category": "Confidential", + "name": "project-codenames", + "original_file_name": "codenames.txt", + "region_name": "us-west-2", + "description": "Internal project codenames — updated", + "is_case_sensitive": false +} +``` + +Create updated keyword file `codenames-v2.txt`: + +``` +alpha +bravo +charlie +delta +echo +foxtrot +``` + +Then invoke replace: + +```bash +airs runtime dlp dictionaries replace dict-7f30c2 --metadata-file dict-meta-v2.json --file-path codenames-v2.txt +airs runtime dlp dictionaries replace dict-7f30c2 --metadata-file dict-meta-v2.json --file-path codenames-v2.txt --output json +``` + +**Output** — updated dictionary with incremented keyword count and refreshed `audit_metadata`. If the API returns 204, the output is empty; always re-fetch with `get --keywords` to canonically observe state. + +## patch + +Use JSON Merge Patch to update only metadata fields. Required fields even on patch: `category`, `name`, `original_file_name`. Other fields use nullable semantics: omit to leave unchanged, send `null` to clear. + +Create a patch file `dict-patch.json`: + +```json +{ + "category": "Confidential", + "name": "project-codenames-v2", + "original_file_name": "codenames.txt", + "description": null +} +``` + +Then invoke patch: + +```bash +airs runtime dlp dictionaries patch dict-7f30c2 --body-file dict-patch.json +airs runtime dlp dictionaries patch dict-7f30c2 --body-file dict-patch.json --output json +``` + +**Output** — patched dictionary with `description` cleared (omitted from response) and the new name persisted. Keywords are not affected by PATCH — use REPLACE to change the keyword file. + +## delete + +Delete a dictionary. + +```bash +airs runtime dlp dictionaries delete dict-7f30c2 +``` + +**Exit code** — 0 on success, 1 on error. + +## Tips + +- **Multipart upload**: CREATE and REPLACE require two files: metadata (JSON) and keyword file (plain text, newline-delimited). The CLI combines them into a multipart body; do not set `Content-Type` manually. +- **200 vs 204 on replace**: The DLP API may return 200+body or 204+empty depending on region/configuration. The replace command handles both. After replace, always re-fetch via `get --keywords` to canonically observe the updated state. +- **Keyword file format**: Keywords must be newline-delimited. Trailing newline is optional but recommended. Empty lines are typically ignored server-side. +- **Category values**: Valid categories are `Academic`, `Confidential`, `Employment`, `Financial`, `Government`, `Healthcare`, `Legal`, `Marketing`, `Source Code` (note the space in the last one). +- **Patch vs Replace**: Use PATCH to update metadata only (name, description, is_case_sensitive). Use REPLACE if you need to change the keyword file or region. + +## See also + +- [Data Profiles](profiles.md) — profiles use `detection_technique: 'dictionary'` to reference dictionary ids +- [Data Patterns](patterns.md) — alternative detection surface (regex / weighted_regex / EDM) +- [Data Filtering Profiles](filtering-profiles.md) — binds profiles to scanning policy diff --git a/docs/runtime/dlp/filtering-profiles.md b/docs/runtime/dlp/filtering-profiles.md new file mode 100644 index 0000000..96c0f80 --- /dev/null +++ b/docs/runtime/dlp/filtering-profiles.md @@ -0,0 +1,111 @@ +--- +title: Data Filtering Profiles +--- + +# Data Filtering Profiles + +Manage Data Filtering Profiles on the DLP service. Filtering profiles define scan behavior across file and non-file content (chat, prompts). The underlying API exposes read + full-replace only — create and delete are not available. To onboard a new profile, provision it via the Strata Cloud Manager UI first, then manage it through the CLI. + +## Commands + +| Command | Description | Exit Code | +|---------|-------------|-----------| +| `list` | List all data filtering profiles with optional filters | 1 on error | +| `get` | Fetch a single profile by ID | 1 on error | +| `replace` | Full PUT: update all fields of a profile | 1 on error | + +## list + +List all filtering profiles. Supports pagination, sorting, and status filters. + +```bash +airs runtime dlp filtering-profiles list +airs runtime dlp filtering-profiles list --status enabled --page 0 --size 20 +airs runtime dlp filtering-profiles list --sort name,asc --output json +``` + +**Output** — paginated list with total count: + +```json +{ + "content": [ + { + "id": "dfp-001", + "name": "Outbound-HR", + "direction": "UPLOAD", + "log_severity": "HIGH", + "file_based": true, + "non_file_based": true, + "data_profile_id": 1001, + "audit_metadata": { + "created_by": "ops@example.com", + "created_at": "2026-04-12T09:15:00Z", + "updated_at": "2026-05-01T14:22:00Z" + } + } + ], + "totalElements": 5, + "totalPages": 1, + "number": 0, + "size": 20 +} +``` + +## get + +Retrieve a single filtering profile by ID. + +```bash +airs runtime dlp filtering-profiles get dfp-001 +airs runtime dlp filtering-profiles get dfp-001 --output json +``` + +**Output** — full profile object with exception rules and exclusions. + +## replace + +Perform a full PUT to update a filtering profile. All fields in the request body are treated as the complete desired state — existing fields not re-sent will be cleared. + +Create a file `dfp-update.json`: + +```json +{ + "file_based": true, + "non_file_based": true, + "description": "Updated HR data filtering", + "direction": "UPLOAD", + "log_severity": "CRITICAL", + "data_profile_id": 1001, + "exception_rules": [ + { + "action": "BLOCK", + "log_severity": "CRITICAL", + "data_profile_ids": [1001], + "source_attributes": { + "match_any": false, + "user_group_ids": ["legal-review"] + } + } + ] +} +``` + +Then invoke replace: + +```bash +airs runtime dlp filtering-profiles replace dfp-001 --body-file dfp-update.json +airs runtime dlp filtering-profiles replace dfp-001 --body-file dfp-update.json --output json +``` + +**Output** — updated profile with incremented version and refreshed audit metadata. + +## Tips + +- **Required fields on replace**: `file_based` and `non_file_based` are mandatory in the PUT body; omit the others only if you want them server-side defaulted. +- **Full replacement semantics**: `replace` performs a full PUT, so any field you omit will be cleared. If you need to preserve existing fields, fetch the current profile first, merge your changes, then PUT. +- **Exception rules and exclusions**: Both are optional nested objects. Use exception rules to override matching behavior for specific user groups; use exclusions to pre-filter applications, URLs, or keywords. + +## See also + +- [Data Profiles](profiles.md) — profiles linked via `data_profile_id` +- [Data Patterns](patterns.md) — patterns embedded in detection rules on profiles diff --git a/docs/runtime/dlp/patterns.md b/docs/runtime/dlp/patterns.md new file mode 100644 index 0000000..39fe334 --- /dev/null +++ b/docs/runtime/dlp/patterns.md @@ -0,0 +1,178 @@ +--- +title: Data Patterns +--- + +# Data Patterns + +Manage Data Patterns on the DLP service. Patterns define detection techniques (regex, weighted_regex, dictionary, EDM, classifier, etc.) and matching rules. Full CRUD is available: list, create, get, replace (full PUT), patch (JSON Merge Patch), delete. + +## Commands + +| Command | Description | Exit Code | +|---------|-------------|-----------| +| `list` | List all data patterns with optional pagination and sorting | 1 on error | +| `create` | Create a new pattern | 1 on error | +| `get` | Fetch a single pattern by ID | 1 on error | +| `replace` | Full PUT: update all fields of a pattern | 1 on error | +| `patch` | JSON Merge Patch: update only specified fields | 1 on error | +| `delete` | Soft-delete a pattern (status becomes 'deleted', still resolvable by get) | 1 on error | + +## list + +List all patterns with optional pagination and sorting. + +```bash +airs runtime dlp patterns list +airs runtime dlp patterns list --page 0 --size 50 --sort name,asc --output json +``` + +**Output** — paginated list of pattern objects. + +## create + +Create a new data pattern. Required fields: `name`, `type`, `detection_config`. + +Create a file `pattern.json`: + +```json +{ + "name": "cc-numbers-weighted", + "type": "custom", + "description": "Credit-card numbers, weighted by proximity to card-related keywords", + "detection_config": { + "technique": "weighted_regex", + "supported_confidence_levels": ["low", "medium", "high"] + }, + "matching_rules": { + "proximity_distance": 30, + "proximity_keywords": ["card", "credit", "visa", "mastercard", "amex"], + "regexes": [ + { "regex": "\\b\\d{16}\\b", "weight": 1.0 }, + { "regex": "\\b\\d{15}\\b", "weight": 0.8 } + ] + }, + "tags": { + "classification": ["PCI"], + "compliance": ["PCI-DSS-3.2.1"], + "geography": ["US", "EU"] + } +} +``` + +Then invoke create: + +```bash +airs runtime dlp patterns create --body-file pattern.json +airs runtime dlp patterns create --body-file pattern.json --output json +``` + +**Output** — created pattern with server-assigned `id`, `status: 'active'`, `version: 1`, and `audit_metadata`. + +## get + +Retrieve a single pattern by ID. + +```bash +airs runtime dlp patterns get pat-7c4a91 +airs runtime dlp patterns get pat-7c4a91 --output json +``` + +**Output** — full pattern object including matching rules and tags. + +## replace + +Perform a full PUT to update a pattern. The entire body is treated as the desired state. + +Create a file `pattern-update.json` with all required fields plus any changes: + +```json +{ + "name": "cc-numbers-weighted", + "type": "custom", + "detection_config": { + "technique": "weighted_regex", + "supported_confidence_levels": ["low", "medium", "high"] + }, + "matching_rules": { + "proximity_distance": 30, + "proximity_keywords": ["card", "credit", "visa", "mastercard", "amex"], + "regexes": [ + { "regex": "\\b\\d{16}\\b", "weight": 1.0 }, + { "regex": "\\b\\d{15}\\b", "weight": 0.8 }, + { "regex": "\\b\\d{13}\\b", "weight": 0.6 } + ] + }, + "tags": { + "classification": ["PCI"], + "compliance": ["PCI-DSS-3.2.1"], + "geography": ["US", "EU"] + } +} +``` + +Then invoke replace: + +```bash +airs runtime dlp patterns replace pat-7c4a91 --body-file pattern-update.json +airs runtime dlp patterns replace pat-7c4a91 --body-file pattern-update.json --output json +``` + +**Output** — updated pattern with incremented version and refreshed `audit_metadata`. + +## patch + +Use JSON Merge Patch to update only specified fields. Required fields even on patch: `name`, `type`, `detection_config`. Other fields use nullable semantics: omit to leave unchanged, send `null` to clear. + +Create a file `pattern-patch.json`: + +```json +{ + "name": "cc-numbers-weighted", + "type": "custom", + "detection_config": { + "technique": "weighted_regex", + "supported_confidence_levels": ["low", "medium", "high"] + }, + "matching_rules": { + "proximity_distance": 30, + "proximity_keywords": ["card", "credit", "visa", "mastercard", "amex"], + "regexes": [ + { "regex": "\\b\\d{16}\\b", "weight": 1.0 }, + { "regex": "\\b\\d{15}\\b", "weight": 0.8 }, + { "regex": "\\b\\d{13}\\b", "weight": 0.6 } + ] + }, + "description": null +} +``` + +Then invoke patch: + +```bash +airs runtime dlp patterns patch pat-7c4a91 --body-file pattern-patch.json +airs runtime dlp patterns patch pat-7c4a91 --body-file pattern-patch.json --output json +``` + +**Output** — patched pattern with `description` cleared (omitted from response) and the third regex persisted. + +## delete + +Soft-delete a pattern. The pattern becomes invisible to `list` but remains resolvable via `get` with `status: 'deleted'`. + +```bash +airs runtime dlp patterns delete pat-7c4a91 +``` + +**Exit code** — 0 on success, 1 on error. + +## Tips + +- **Merge Patch semantics**: On PATCH, `name`, `type`, and `detection_config` are required even if unchanged. Arrays and objects are replaced wholesale (not merged) — re-send the entire `matching_rules` if you modify any part. Omit fields to preserve them; send `null` to clear. +- **Detection techniques**: Valid techniques include `regex`, `weighted_regex`, `dictionary`, `edm`, `document_fingerprint`, `trainable_classifier`, `ml_document`, `ml`, `titus_tag`, `wildfire`, `file_property`, `pab`, and `document_classifier`. +- **Soft delete**: DELETE archives the pattern server-side. Fetching a deleted pattern via `get` returns `status: 'deleted'`. + +## See also + +- [Data Profiles](profiles.md) — profiles compose patterns via detection rules +- [Data Dictionaries](dictionaries.md) — keyword lists for `dictionary` detection technique +- [Data Filtering Profiles](filtering-profiles.md) — binds profiles to scanning policy diff --git a/docs/runtime/dlp/profiles.md b/docs/runtime/dlp/profiles.md new file mode 100644 index 0000000..3e33a40 --- /dev/null +++ b/docs/runtime/dlp/profiles.md @@ -0,0 +1,203 @@ +--- +title: Data Profiles +--- + +# Data Profiles + +Manage Data Profiles on the DLP service. Profiles define detection rules using two rule types: `expression_tree` (recursive boolean logic over detection techniques) and `multi_profile` (composition of other profiles). CRUD is available except DELETE — profiles are archived by patching `profile_status`. + +## Commands + +| Command | Description | Exit Code | +|---------|-------------|-----------| +| `list` | List all data profiles with optional pagination and sorting | 1 on error | +| `create` | Create a new profile | 1 on error | +| `get` | Fetch a single profile by ID | 1 on error | +| `replace` | Full PUT: update all fields of a profile | 1 on error | +| `patch` | JSON Merge Patch: update only specified fields | 1 on error | + +## list + +List all profiles with optional pagination and sorting. + +```bash +airs runtime dlp profiles list +airs runtime dlp profiles list --page 0 --size 50 --sort name,asc --output json +``` + +**Output** — paginated list of profile objects with detection rules. + +## create + +Create a new data profile with `expression_tree` or `multi_profile` rules. Required fields: `name`, `detection_rules`. + +### Example: expression_tree (AND logic) + +Create a file `profile-expr.json`: + +```json +{ + "name": "High-risk PII (SSN AND CC)", + "description": "Fires only when both SSN and CC pattern leaves match", + "detection_rules": [ + { + "rule_type": "expression_tree", + "expression_tree": { + "operator_type": "and", + "sub_expressions": [ + { + "rule_item": { + "detection_technique": "regex", + "match_type": "include", + "confidence_level": "high", + "occurrence_operator_type": "more_than_equal_to", + "occurrence_count": 1 + } + }, + { + "rule_item": { + "detection_technique": "weighted_regex", + "match_type": "include", + "confidence_level": "high", + "occurrence_operator_type": "more_than_equal_to", + "occurrence_count": 1 + } + } + ] + } + } + ] +} +``` + +Then invoke create: + +```bash +airs runtime dlp profiles create --body-file profile-expr.json +airs runtime dlp profiles create --body-file profile-expr.json --output json +``` + +### Example: multi_profile (OR composition) + +Create a file `profile-multi.json`: + +```json +{ + "name": "EU-Regulated (umbrella)", + "description": "GDPR-PII OR EU-Healthcare", + "detection_rules": [ + { + "rule_type": "multi_profile", + "multi_profile": { + "operator_type": "or", + "data_profile_ids": [1001, 1002] + } + } + ] +} +``` + +Then invoke create: + +```bash +airs runtime dlp profiles create --body-file profile-multi.json --output json +``` + +**Output** — created profile with server-assigned `id`, `status: 'active'`, `version: 1`, and `audit_metadata`. Multi-profile compositions auto-promote `profile_type` to `'advanced'`. + +## get + +Retrieve a single profile by ID. + +```bash +airs runtime dlp profiles get prof-3a91 +airs runtime dlp profiles get prof-3a91 --output json +``` + +**Output** — full profile object with detection rules fully expanded. + +## replace + +Perform a full PUT to update a profile. The entire body is treated as the desired state. + +Create a file `profile-update.json` with all required fields: + +```json +{ + "name": "High-risk PII (SSN AND CC)", + "description": "Updated: requires both SSN and CC with high confidence", + "detection_rules": [ + { + "rule_type": "expression_tree", + "expression_tree": { + "operator_type": "and", + "sub_expressions": [ + { + "rule_item": { + "detection_technique": "regex", + "match_type": "include", + "confidence_level": "high", + "occurrence_operator_type": "more_than_equal_to", + "occurrence_count": 1 + } + }, + { + "rule_item": { + "detection_technique": "weighted_regex", + "match_type": "include", + "confidence_level": "high", + "occurrence_operator_type": "more_than_equal_to", + "occurrence_count": 2 + } + } + ] + } + } + ] +} +``` + +Then invoke replace: + +```bash +airs runtime dlp profiles replace prof-3a91 --body-file profile-update.json +airs runtime dlp profiles replace prof-3a91 --body-file profile-update.json --output json +``` + +**Output** — updated profile with incremented version and refreshed `audit_metadata`. + +## patch + +Use JSON Merge Patch to update only specified fields. Required fields even on patch: `name` and `profile_type`. Other fields use nullable semantics: omit to leave unchanged, send `null` to clear. + +Create a file `profile-patch.json`: + +```json +{ + "name": "High-risk PII (SSN AND CC)", + "profile_type": "advanced", + "description": "Patched description without touching detection_rules" +} +``` + +Then invoke patch: + +```bash +airs runtime dlp profiles patch prof-3a91 --body-file profile-patch.json +airs runtime dlp profiles patch prof-3a91 --body-file profile-patch.json --output json +``` + +**Output** — patched profile with only the specified fields updated; detection rules preserved as-is. + +## Tips + +- **Expression tree nesting**: Build complex detection logic using `and` / `or` operators with nested `sub_expressions` and leaf `rule_item` nodes. Each leaf carries the detection technique and technique-specific thresholds. +- **Multi-profile composition**: Use `multi_profile` to combine multiple existing profiles with a single operator (`and` or `or`). The composed profile auto-promotes to `profile_type: 'advanced'` server-side. +- **Merge Patch semantics**: On PATCH, `name` and `profile_type` are required. Arrays like `detection_rules` are replaced wholesale if sent; omit to preserve. Send `null` to clear optional fields like `description`. +- **No DELETE**: Profiles cannot be deleted via API. To archive, PATCH with `profile_status: 'deleted'` if the API supports it, or use the Strata Cloud Manager UI. + +## See also + +- [Data Patterns](patterns.md) — patterns referenced in expression tree leaves +- [Data Dictionaries](dictionaries.md) — keyword lists for `detection_technique: 'dictionary'` leaves +- [Data Filtering Profiles](filtering-profiles.md) — binds profiles to scanning policy via `data_profile_id` diff --git a/mkdocs.yml b/mkdocs.yml index f819cc5..3085e26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,11 @@ nav: - Metrics & Evaluation: runtime/guardrails/metrics.md - Topic Constraints: runtime/guardrails/topic-constraints.md - Profile Audits: runtime/profile-audits.md + - DLP: + - Filtering Profiles: runtime/dlp/filtering-profiles.md + - Patterns: runtime/dlp/patterns.md + - Profiles: runtime/dlp/profiles.md + - Dictionaries: runtime/dlp/dictionaries.md - AI Red Teaming: - Overview: redteam/overview.md - Running Scans: redteam/scanning.md From 48748e7a1d5dba3006e3e0e3c08b2e295f481f73 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:24:02 -0500 Subject: [PATCH 22/27] fix(cli/dlp): correct string-escape in patch help text --- src/cli/commands/dlp/patterns.ts | 2 +- src/cli/commands/dlp/profiles.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/dlp/patterns.ts b/src/cli/commands/dlp/patterns.ts index f90181c..6ae301c 100644 --- a/src/cli/commands/dlp/patterns.ts +++ b/src/cli/commands/dlp/patterns.ts @@ -94,7 +94,7 @@ export function register(dlp: Command): void { .description( 'JSON Merge Patch. Use --body-file for nested fields. ' + '--set/--clear coerce values: numbers/booleans/JSON literals. ' + - 'To force a string, quote: --set count=\'\\"5\\"\'.', + 'To force a string, quote: --set count=\'"5"\'.', ) .option('--body-file ', 'JSON merge-patch body file') .option('--set ', 'Set scalar field (repeatable)', (v, p: string[] = []) => [...p, v]) diff --git a/src/cli/commands/dlp/profiles.ts b/src/cli/commands/dlp/profiles.ts index 297be61..6299a2c 100644 --- a/src/cli/commands/dlp/profiles.ts +++ b/src/cli/commands/dlp/profiles.ts @@ -98,7 +98,7 @@ export function register(dlp: Command): void { .description( 'JSON Merge Patch. body must include name + profile_type. Use --body-file for nested fields. ' + '--set/--clear coerce values: numbers/booleans/JSON literals. ' + - 'To force a string, quote: --set count=\'\\"5\\"\'.', + 'To force a string, quote: --set count=\'"5"\'.', ) .option('--body-file ', 'JSON merge-patch body file') .option('--set ', 'Set scalar field (repeatable)', (v, p: string[] = []) => [...p, v]) From 51f1d41a1f880fc8ed580a2d54451e3b46d02276 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 13:57:32 -0500 Subject: [PATCH 23/27] chore(deps): bump @cdot65/prisma-airs-sdk to ^0.9.1 Pulls in nullable DLP response schema fixes (cdot65/prisma-airs-sdk#159). Resolves Zod parse failures on filtering-profiles list against live tenant (null on description, is_end_user_coaching_enabled, euc_template_id, rule1, rule2; numeric epoch on audit_metadata.updated_at). --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index f3a876f..cb9372f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.4", - "@cdot65/prisma-airs-sdk": "^0.9.0", + "@cdot65/prisma-airs-sdk": "^0.9.1", "@inquirer/prompts": "^8.3.0", "@langchain/anthropic": "^1.3.25", "@langchain/aws": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3651b49..2cf93ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.14.4 version: 0.14.4(zod@3.25.76) '@cdot65/prisma-airs-sdk': - specifier: ^0.9.0 - version: 0.9.0 + specifier: ^0.9.1 + version: 0.9.1 '@inquirer/prompts': specifier: ^8.3.0 version: 8.3.0(@types/node@22.19.13) @@ -325,8 +325,8 @@ packages: cpu: [x64] os: [win32] - '@cdot65/prisma-airs-sdk@0.9.0': - resolution: {integrity: sha512-P1helNQ7qQ/LR6UVc3tb1UDk2d8sV647H7dxvQ/yZP+6Mpt8vYEtDDanTVSzpSYBlwn7Cbep4Xf+ByhaHsptHg==} + '@cdot65/prisma-airs-sdk@0.9.1': + resolution: {integrity: sha512-QLzVBkStelRVBhHR31kSwS4fZOG/cKFhwmrUN0P2o0oQ3+jPT7LPjyu7u85aLZemmDPUvU/n/lFpBCr+yn/ydg==} engines: {node: '>=18'} '@cfworker/json-schema@4.1.1': @@ -2680,7 +2680,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.5': optional: true - '@cdot65/prisma-airs-sdk@0.9.0': + '@cdot65/prisma-airs-sdk@0.9.1': dependencies: zod: 3.25.76 From d43668263be208d5b713f44062d6344292dfaf9b Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 14:27:17 -0500 Subject: [PATCH 24/27] chore: bump @cdot65/prisma-airs-sdk to ^0.9.2 Unblocks data-patterns list + data-profiles list against live tenant. Resolves Zod nulls on matching_rules.* and expression_tree.* nested schemas via SDK PR #161. --- .changeset/0020-sdk-092-bump.md | 5 +++++ package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/0020-sdk-092-bump.md diff --git a/.changeset/0020-sdk-092-bump.md b/.changeset/0020-sdk-092-bump.md new file mode 100644 index 0000000..1670e59 --- /dev/null +++ b/.changeset/0020-sdk-092-bump.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": patch +--- + +Bump @cdot65/prisma-airs-sdk to ^0.9.2 (DLP nested helper nullable sweep — unblocks `runtime dlp patterns list` and `runtime dlp profiles list` against live tenants). diff --git a/package.json b/package.json index cb9372f..22585c2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.4", - "@cdot65/prisma-airs-sdk": "^0.9.1", + "@cdot65/prisma-airs-sdk": "^0.9.2", "@inquirer/prompts": "^8.3.0", "@langchain/anthropic": "^1.3.25", "@langchain/aws": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cf93ab..7b20e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.14.4 version: 0.14.4(zod@3.25.76) '@cdot65/prisma-airs-sdk': - specifier: ^0.9.1 - version: 0.9.1 + specifier: ^0.9.2 + version: 0.9.2 '@inquirer/prompts': specifier: ^8.3.0 version: 8.3.0(@types/node@22.19.13) @@ -325,8 +325,8 @@ packages: cpu: [x64] os: [win32] - '@cdot65/prisma-airs-sdk@0.9.1': - resolution: {integrity: sha512-QLzVBkStelRVBhHR31kSwS4fZOG/cKFhwmrUN0P2o0oQ3+jPT7LPjyu7u85aLZemmDPUvU/n/lFpBCr+yn/ydg==} + '@cdot65/prisma-airs-sdk@0.9.2': + resolution: {integrity: sha512-J0W28DK3RLTi38r+G3MMS3mWF3wUAHBqIM2yIwm6bXXL4GF85aFaHJJeRH2KKWkQzrMLd4VoZGsb+5UWZT3AHQ==} engines: {node: '>=18'} '@cfworker/json-schema@4.1.1': @@ -2680,7 +2680,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.5': optional: true - '@cdot65/prisma-airs-sdk@0.9.1': + '@cdot65/prisma-airs-sdk@0.9.2': dependencies: zod: 3.25.76 From 434f6e309ebbdb5462da5d91ad7b0db5d567c058 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 14:36:30 -0500 Subject: [PATCH 25/27] docs(runtime/dlp): refresh wire-output snippets from live tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real redacted output captured against live tenant after SDK ^0.9.2 re-pin. Filtering-profiles and dictionaries: full list+get. Patterns/profiles: list only — added known-issue notes flagging the upstream GET-by-id 400 (verified server-side via curl, not addressable from CLI/SDK). --- docs/runtime/dlp/dictionaries.md | 96 ++++++++++++++++++--- docs/runtime/dlp/filtering-profiles.md | 114 ++++++++++++++++++++----- docs/runtime/dlp/patterns.md | 89 +++++++++++++++++-- docs/runtime/dlp/profiles.md | 108 ++++++++++++++++++++--- 4 files changed, 355 insertions(+), 52 deletions(-) diff --git a/docs/runtime/dlp/dictionaries.md b/docs/runtime/dlp/dictionaries.md index ad6a557..22131d8 100644 --- a/docs/runtime/dlp/dictionaries.md +++ b/docs/runtime/dlp/dictionaries.md @@ -27,7 +27,52 @@ airs runtime dlp dictionaries list --page 0 --size 50 --output json airs runtime dlp dictionaries list --keywords # Include keyword array in output ``` -**Output** — paginated list of dictionary objects; `keywords` array populated only if requested. +**Output** — Spring `Page<>` envelope (`totalElements`/`totalPages` are emitted as `null` by this endpoint); example with one predefined entry: + +```json +{ + "content": [ + { + "id": "6901...", + "name": "Bank Names", + "description": "List of large international banks", + "category": "Finance", + "region_name": "GLOBAL", + "type": "predefined", + "is_case_sensitive": false, + "is_parent_managed": false, + "detection_technique": "dictionary", + "detection_sub_technique": null, + "dictionary_metadata": { + "number_of_keywords": 0, + "original_file_name": "", + "original_file_size_in_byte": 0 + }, + "keywords": null, + "tags": { "classification": ["pab"] }, + "attributes": null, + "audit_metadata": { + "created_at": 1761657319491, + "created_by": "System", + "updated_at": 1761657319491, + "updated_by": "System" + } + } + ], + "pageable": { "page_number": 0, "page_size": 25, "offset": 0, "paged": true, "unpaged": false }, + "first": true, + "last": false, + "size": 25, + "number": 0, + "number_of_elements": 25, + "empty": false, + "totalElements": null, + "totalPages": null +} +``` + +!!! note + `keywords` is `null` unless `--keywords` is passed. `detection_sub_technique`, `attributes`, and the `audit_metadata.created_by`/`updated_by` slots are commonly `null` on predefined entries. ## create @@ -71,12 +116,43 @@ airs runtime dlp dictionaries create --metadata-file dict-meta.json --file-path Retrieve a single dictionary by ID. ```bash -airs runtime dlp dictionaries get dict-7f30c2 -airs runtime dlp dictionaries get dict-7f30c2 --output json -airs runtime dlp dictionaries get dict-7f30c2 --keywords # Include keyword array +airs runtime dlp dictionaries get 6901... +airs runtime dlp dictionaries get 6901... --output json +airs runtime dlp dictionaries get 6901... --keywords # Include keyword array +``` + +**Output** — full dictionary object: + +```json +{ + "id": "6901...", + "name": "Bank Names", + "description": "List of large international banks", + "category": "Finance", + "region_name": "GLOBAL", + "type": "predefined", + "is_case_sensitive": false, + "is_parent_managed": false, + "detection_technique": "dictionary", + "detection_sub_technique": null, + "dictionary_metadata": { + "number_of_keywords": 200, + "original_file_name": "", + "original_file_size_in_byte": 0 + }, + "keywords": null, + "tags": { "classification": ["pab"] }, + "attributes": null, + "audit_metadata": { + "created_at": 1761657319491, + "created_by": null, + "updated_at": 1761657319491, + "updated_by": null + } +} ``` -**Output** — full dictionary object with metadata, keyword count, and optionally the keyword array. +Note `dictionary_metadata.number_of_keywords` reflects the canonical server-side count even when `keywords` is `null`. Pass `--keywords` to populate the array. ## replace @@ -109,8 +185,8 @@ foxtrot Then invoke replace: ```bash -airs runtime dlp dictionaries replace dict-7f30c2 --metadata-file dict-meta-v2.json --file-path codenames-v2.txt -airs runtime dlp dictionaries replace dict-7f30c2 --metadata-file dict-meta-v2.json --file-path codenames-v2.txt --output json +airs runtime dlp dictionaries replace 6901... --metadata-file dict-meta-v2.json --file-path codenames-v2.txt +airs runtime dlp dictionaries replace 6901... --metadata-file dict-meta-v2.json --file-path codenames-v2.txt --output json ``` **Output** — updated dictionary with incremented keyword count and refreshed `audit_metadata`. If the API returns 204, the output is empty; always re-fetch with `get --keywords` to canonically observe state. @@ -133,8 +209,8 @@ Create a patch file `dict-patch.json`: Then invoke patch: ```bash -airs runtime dlp dictionaries patch dict-7f30c2 --body-file dict-patch.json -airs runtime dlp dictionaries patch dict-7f30c2 --body-file dict-patch.json --output json +airs runtime dlp dictionaries patch 6901... --body-file dict-patch.json +airs runtime dlp dictionaries patch 6901... --body-file dict-patch.json --output json ``` **Output** — patched dictionary with `description` cleared (omitted from response) and the new name persisted. Keywords are not affected by PATCH — use REPLACE to change the keyword file. @@ -144,7 +220,7 @@ airs runtime dlp dictionaries patch dict-7f30c2 --body-file dict-patch.json --ou Delete a dictionary. ```bash -airs runtime dlp dictionaries delete dict-7f30c2 +airs runtime dlp dictionaries delete 6901... ``` **Exit code** — 0 on success, 1 on error. diff --git a/docs/runtime/dlp/filtering-profiles.md b/docs/runtime/dlp/filtering-profiles.md index 96c0f80..97a844c 100644 --- a/docs/runtime/dlp/filtering-profiles.md +++ b/docs/runtime/dlp/filtering-profiles.md @@ -16,51 +16,119 @@ Manage Data Filtering Profiles on the DLP service. Filtering profiles define sca ## list -List all filtering profiles. Supports pagination, sorting, and status filters. +List all filtering profiles. Supports pagination and sorting. ```bash airs runtime dlp filtering-profiles list -airs runtime dlp filtering-profiles list --status enabled --page 0 --size 20 +airs runtime dlp filtering-profiles list --page 0 --size 20 airs runtime dlp filtering-profiles list --sort name,asc --output json ``` -**Output** — paginated list with total count: +**Output** — Spring `Page<>` envelope, sample with one entry (`file_type` truncated; `totalElements`/`totalPages` are emitted as `null` by this endpoint): ```json { "content": [ { - "id": "dfp-001", - "name": "Outbound-HR", - "direction": "UPLOAD", - "log_severity": "HIGH", + "id": "6a10...", + "name": "asdfafdsadsa", + "description": null, + "tenant_id": "", + "type": "custom", + "data_profile_id": 1234567890, + "direction": "c2s", "file_based": true, - "non_file_based": true, - "data_profile_id": 1001, + "non_file_based": false, + "log_severity": "low", + "scan_type": "include", + "is_end_user_coaching_enabled": null, + "is_granular_profile": false, + "is_parent_managed": false, + "euc_template_id": null, + "version": 1, + "file_type": ["csv", "doc", "docx", "pdf", "txt-upload", "xlsx", "7z"], "audit_metadata": { - "created_by": "ops@example.com", - "created_at": "2026-04-12T09:15:00Z", - "updated_at": "2026-05-01T14:22:00Z" - } + "created_at": 1779473698140, + "created_by": null, + "updated_at": 1779473698140, + "updated_by": "Strata Cloud Manager" + }, + "criteria_details": [], + "exception_rules": [], + "exclusions": { + "app_exclusion_list": [], + "url_exclusion_list": [], + "exclusion_list": {} + }, + "rule1": { + "action": "alert", + "response_page": "This file has dlp issues", + "show_rsp_page": "no" + }, + "rule2": null } ], - "totalElements": 5, - "totalPages": 1, + "pageable": { "page_number": 0, "page_size": 25, "offset": 0, "paged": true, "unpaged": false }, + "first": true, + "last": false, + "size": 25, "number": 0, - "size": 20 + "number_of_elements": 25, + "empty": false, + "totalElements": null, + "totalPages": null } ``` +!!! note "Nullable fields" + The DLP API returns `null` for several fields on real tenants — including `description`, `rule2`, `audit_metadata.created_by`, and `is_end_user_coaching_enabled`. Make sure your downstream parsing accepts these. (CLI requires `@cdot65/prisma-airs-sdk@^0.9.2` or newer for full nullable coverage.) + ## get Retrieve a single filtering profile by ID. ```bash -airs runtime dlp filtering-profiles get dfp-001 -airs runtime dlp filtering-profiles get dfp-001 --output json +airs runtime dlp filtering-profiles get 6a10... +airs runtime dlp filtering-profiles get 6a10... --output json ``` -**Output** — full profile object with exception rules and exclusions. +**Output** — same shape as a single `content[]` entry from `list`: + +```json +{ + "id": "6a10...", + "name": "asdfafdsadsa", + "description": null, + "tenant_id": "", + "type": "custom", + "data_profile_id": 1234567890, + "direction": "c2s", + "file_based": true, + "non_file_based": false, + "log_severity": "low", + "scan_type": "include", + "version": 1, + "audit_metadata": { + "created_at": 1779473698140, + "created_by": null, + "updated_at": 1779473698140, + "updated_by": "Strata Cloud Manager" + }, + "criteria_details": [], + "exception_rules": [], + "exclusions": { + "app_exclusion_list": [], + "url_exclusion_list": [], + "exclusion_list": {} + }, + "rule1": { + "action": "alert", + "response_page": "This file has dlp issues", + "show_rsp_page": "no" + }, + "rule2": null +} +``` ## replace @@ -75,12 +143,12 @@ Create a file `dfp-update.json`: "description": "Updated HR data filtering", "direction": "UPLOAD", "log_severity": "CRITICAL", - "data_profile_id": 1001, + "data_profile_id": 1234567890, "exception_rules": [ { "action": "BLOCK", "log_severity": "CRITICAL", - "data_profile_ids": [1001], + "data_profile_ids": [1234567890], "source_attributes": { "match_any": false, "user_group_ids": ["legal-review"] @@ -93,8 +161,8 @@ Create a file `dfp-update.json`: Then invoke replace: ```bash -airs runtime dlp filtering-profiles replace dfp-001 --body-file dfp-update.json -airs runtime dlp filtering-profiles replace dfp-001 --body-file dfp-update.json --output json +airs runtime dlp filtering-profiles replace 6a10... --body-file dfp-update.json +airs runtime dlp filtering-profiles replace 6a10... --body-file dfp-update.json --output json ``` **Output** — updated profile with incremented version and refreshed audit metadata. diff --git a/docs/runtime/dlp/patterns.md b/docs/runtime/dlp/patterns.md index 39fe334..ed2f0a7 100644 --- a/docs/runtime/dlp/patterns.md +++ b/docs/runtime/dlp/patterns.md @@ -26,7 +26,75 @@ airs runtime dlp patterns list airs runtime dlp patterns list --page 0 --size 50 --sort name,asc --output json ``` -**Output** — paginated list of pattern objects. +**Output** — Spring `Page<>` envelope; example showing both `custom` and `predefined` entries (`totalElements`/`totalPages` are emitted as `null` by this endpoint): + +```json +{ + "content": [ + { + "id": "6990...", + "name": "IPv4", + "description": "Just a simple test", + "tenant_id": "", + "type": "custom", + "status": "active", + "license_type": "standard", + "is_parent_managed": false, + "version": 1, + "detection_config": { + "technique": "regex", + "supported_confidence_levels": ["high", "low"] + }, + "matching_rules": { + "delimiter": ";", + "proximity_distance": 200, + "proximity_keywords": null, + "regexes": [ + { + "regex": "\\b(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)(?:\\.(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)){3}\\b", + "weight": 1 + } + ], + "metadata_criteria": null + }, + "tags": { "classification": ["pab"] }, + "audit_metadata": { + "created_at": 1771088671081, + "created_by": "Strata Cloud Manager", + "updated_at": 1771088671081, + "updated_by": "Strata Cloud Manager" + } + }, + { + "id": "6900...", + "name": "Passport - Australia", + "type": "predefined", + "status": "disabled", + "license_type": "standard", + "version": 1, + "detection_config": { "technique": "regex", "supported_confidence_levels": ["high"] }, + "matching_rules": { + "delimiter": null, + "proximity_keywords": ["passport", "passport no"], + "regexes": [{ "regex": "...", "weight": 1 }], + "metadata_criteria": null + } + } + ], + "pageable": { "page_number": 0, "page_size": 25, "offset": 0, "paged": true, "unpaged": false }, + "first": true, + "last": false, + "size": 25, + "number": 0, + "number_of_elements": 25, + "empty": false, + "totalElements": null, + "totalPages": null +} +``` + +!!! note "Nullable fields" + Real responses include several `null` values on `matching_rules` nested fields — `delimiter`, `proximity_keywords`, `regexes`, `metadata_criteria` are each independently nullable depending on the detection technique. CLI requires `@cdot65/prisma-airs-sdk@^0.9.2` or newer to parse this surface; older SDK pins fail Zod validation. ## create @@ -73,11 +141,14 @@ airs runtime dlp patterns create --body-file pattern.json --output json Retrieve a single pattern by ID. ```bash -airs runtime dlp patterns get pat-7c4a91 -airs runtime dlp patterns get pat-7c4a91 --output json +airs runtime dlp patterns get 6990... +airs runtime dlp patterns get 6990... --output json ``` -**Output** — full pattern object including matching rules and tags. +**Output** — same shape as a single `content[]` entry from `list`. + +!!! warning "Known issue (2026-05-23)" + The DLP API currently returns HTTP 400 for `GET /v2/api/data-patterns/{id}` against live tenants, even with valid IDs from the `list` response. This is a server-side issue, not a CLI or SDK bug — reproducible via `curl` with the same credentials. Use `list` and filter client-side until the upstream is fixed. ## replace @@ -113,8 +184,8 @@ Create a file `pattern-update.json` with all required fields plus any changes: Then invoke replace: ```bash -airs runtime dlp patterns replace pat-7c4a91 --body-file pattern-update.json -airs runtime dlp patterns replace pat-7c4a91 --body-file pattern-update.json --output json +airs runtime dlp patterns replace 6990... --body-file pattern-update.json +airs runtime dlp patterns replace 6990... --body-file pattern-update.json --output json ``` **Output** — updated pattern with incremented version and refreshed `audit_metadata`. @@ -149,8 +220,8 @@ Create a file `pattern-patch.json`: Then invoke patch: ```bash -airs runtime dlp patterns patch pat-7c4a91 --body-file pattern-patch.json -airs runtime dlp patterns patch pat-7c4a91 --body-file pattern-patch.json --output json +airs runtime dlp patterns patch 6990... --body-file pattern-patch.json +airs runtime dlp patterns patch 6990... --body-file pattern-patch.json --output json ``` **Output** — patched pattern with `description` cleared (omitted from response) and the third regex persisted. @@ -160,7 +231,7 @@ airs runtime dlp patterns patch pat-7c4a91 --body-file pattern-patch.json --outp Soft-delete a pattern. The pattern becomes invisible to `list` but remains resolvable via `get` with `status: 'deleted'`. ```bash -airs runtime dlp patterns delete pat-7c4a91 +airs runtime dlp patterns delete 6990... ``` **Exit code** — 0 on success, 1 on error. diff --git a/docs/runtime/dlp/profiles.md b/docs/runtime/dlp/profiles.md index 3e33a40..6991f8a 100644 --- a/docs/runtime/dlp/profiles.md +++ b/docs/runtime/dlp/profiles.md @@ -25,7 +25,92 @@ airs runtime dlp profiles list airs runtime dlp profiles list --page 0 --size 50 --sort name,asc --output json ``` -**Output** — paginated list of profile objects with detection rules. +**Output** — Spring `Page<>` envelope; example showing a `multi_profile` entry (server returns the composed pattern set echoed in `advance_data_patterns_rule_request`): + +```json +{ + "content": [ + { + "id": "1234567890", + "name": "EU-Regulated (umbrella)", + "description": null, + "tenant_id": "", + "type": "custom", + "profile_status": "active", + "profile_type": "advanced", + "is_granular_data_profile": false, + "is_parent_managed": false, + "version": 1, + "advance_data_patterns_rule_request": [ + "(... server-rendered detection expression, truncated ...)" + ], + "detection_rules": [ + { + "rule_type": "multi_profile", + "multi_profile": { + "data_profile_ids": [1234567891, 1234567892, 1234567893], + "operator_type": "or" + } + } + ], + "audit_metadata": { + "created_at": 1779473698140, + "created_by": "Strata Cloud Manager", + "updated_at": 1779473698140, + "updated_by": "Strata Cloud Manager" + } + } + ], + "pageable": { "page_number": 0, "page_size": 25, "offset": 0, "paged": true, "unpaged": false }, + "first": true, + "last": false, + "size": 25, + "number": 0, + "number_of_elements": 25, + "empty": false, + "totalElements": null, + "totalPages": null +} +``` + +Example `expression_tree` entry (truncated to one leaf for brevity — real responses nest several layers of `sub_expressions`): + +```json +{ + "id": "1234567890", + "name": "InfoSec - Code Assistant - Strict", + "profile_type": "advanced", + "version": 5, + "detection_rules": [ + { + "rule_type": "expression_tree", + "expression_tree": { + "operator_type": "or", + "rule_item": null, + "sub_expressions": [ + { + "operator_type": null, + "rule_item": { + "detection_technique": "regex", + "id": "6900...", + "name": "Bank - ABA Routing Number", + "version": 1, + "by_unique_count": false, + "confidence_level": "low", + "supported_confidence_levels": ["high", "low"], + "occurrence_operator_type": "any" + }, + "sub_expressions": [] + } + ] + } + } + ] +} +``` + +!!! note "Nullable fields" + The `expression_tree` is recursive and many nodes legitimately carry `null` values — particularly `operator_type`, `rule_item`, and (at intermediate nodes) `sub_expressions` slots. CLI requires `@cdot65/prisma-airs-sdk@^0.9.2` or newer to parse this surface; older SDK pins fail Zod validation on real responses. ## create @@ -90,7 +175,7 @@ Create a file `profile-multi.json`: "rule_type": "multi_profile", "multi_profile": { "operator_type": "or", - "data_profile_ids": [1001, 1002] + "data_profile_ids": [1234567891, 1234567892] } } ] @@ -103,18 +188,21 @@ Then invoke create: airs runtime dlp profiles create --body-file profile-multi.json --output json ``` -**Output** — created profile with server-assigned `id`, `status: 'active'`, `version: 1`, and `audit_metadata`. Multi-profile compositions auto-promote `profile_type` to `'advanced'`. +**Output** — created profile with server-assigned `id`, `profile_status: 'active'`, `version: 1`, and `audit_metadata`. Multi-profile compositions auto-promote `profile_type` to `'advanced'`. ## get Retrieve a single profile by ID. ```bash -airs runtime dlp profiles get prof-3a91 -airs runtime dlp profiles get prof-3a91 --output json +airs runtime dlp profiles get 1234567890 +airs runtime dlp profiles get 1234567890 --output json ``` -**Output** — full profile object with detection rules fully expanded. +**Output** — same shape as a single `content[]` entry from `list`. + +!!! warning "Known issue (2026-05-23)" + The DLP API currently returns HTTP 400 for `GET /v2/api/data-profiles/{id}` against live tenants, even with valid IDs from the `list` response. This is a server-side issue, not a CLI or SDK bug — reproducible via `curl` with the same credentials. Use `list` and filter client-side until the upstream is fixed. ## replace @@ -160,8 +248,8 @@ Create a file `profile-update.json` with all required fields: Then invoke replace: ```bash -airs runtime dlp profiles replace prof-3a91 --body-file profile-update.json -airs runtime dlp profiles replace prof-3a91 --body-file profile-update.json --output json +airs runtime dlp profiles replace 1234567890 --body-file profile-update.json +airs runtime dlp profiles replace 1234567890 --body-file profile-update.json --output json ``` **Output** — updated profile with incremented version and refreshed `audit_metadata`. @@ -183,8 +271,8 @@ Create a file `profile-patch.json`: Then invoke patch: ```bash -airs runtime dlp profiles patch prof-3a91 --body-file profile-patch.json -airs runtime dlp profiles patch prof-3a91 --body-file profile-patch.json --output json +airs runtime dlp profiles patch 1234567890 --body-file profile-patch.json +airs runtime dlp profiles patch 1234567890 --body-file profile-patch.json --output json ``` **Output** — patched profile with only the specified fields updated; detection rules preserved as-is. From 9734b2f93f4428d5f2881a773cbe6a68d8fb6880 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 14:41:05 -0500 Subject: [PATCH 26/27] =?UTF-8?q?chore(changeset):=20rename=200020-sdk-092?= =?UTF-8?q?-bump=20=E2=86=92=200021=20(avoid=20collision=20with=20#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/0004-topics-get-command.md | 5 +++++ .changeset/0006-sdk-071-upgrade.md | 5 +++++ .changeset/{0020-sdk-092-bump.md => 0021-sdk-092-bump.md} | 0 3 files changed, 10 insertions(+) create mode 100644 .changeset/0004-topics-get-command.md create mode 100644 .changeset/0006-sdk-071-upgrade.md rename .changeset/{0020-sdk-092-bump.md => 0021-sdk-092-bump.md} (100%) diff --git a/.changeset/0004-topics-get-command.md b/.changeset/0004-topics-get-command.md new file mode 100644 index 0000000..e974d79 --- /dev/null +++ b/.changeset/0004-topics-get-command.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Add `airs runtime topics get ` command to retrieve a custom topic by name or UUID. Displays name, description, examples, and metadata. Supports `--output pretty|json|yaml`. diff --git a/.changeset/0006-sdk-071-upgrade.md b/.changeset/0006-sdk-071-upgrade.md new file mode 100644 index 0000000..9d0bed8 --- /dev/null +++ b/.changeset/0006-sdk-071-upgrade.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Upgrade SDK to v0.7.1, fix target create/restore 422 errors. Backup field names now use `target_background`/`target_metadata` (legacy names auto-normalized on restore). Remove WEBSOCKET provider. Scaffold targets default to `APPLICATION` type. diff --git a/.changeset/0020-sdk-092-bump.md b/.changeset/0021-sdk-092-bump.md similarity index 100% rename from .changeset/0020-sdk-092-bump.md rename to .changeset/0021-sdk-092-bump.md From f6879afc083fd08380ed6ea4b61ce096720c5202 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 23 May 2026 14:41:36 -0500 Subject: [PATCH 27/27] chore(changeset): remove stale 0004 + 0006 entries from accidental sweep --- .changeset/0004-topics-get-command.md | 5 ----- .changeset/0006-sdk-071-upgrade.md | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 .changeset/0004-topics-get-command.md delete mode 100644 .changeset/0006-sdk-071-upgrade.md diff --git a/.changeset/0004-topics-get-command.md b/.changeset/0004-topics-get-command.md deleted file mode 100644 index e974d79..0000000 --- a/.changeset/0004-topics-get-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cdot65/prisma-airs-cli": minor ---- - -Add `airs runtime topics get ` command to retrieve a custom topic by name or UUID. Displays name, description, examples, and metadata. Supports `--output pretty|json|yaml`. diff --git a/.changeset/0006-sdk-071-upgrade.md b/.changeset/0006-sdk-071-upgrade.md deleted file mode 100644 index 9d0bed8..0000000 --- a/.changeset/0006-sdk-071-upgrade.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cdot65/prisma-airs-cli": minor ---- - -Upgrade SDK to v0.7.1, fix target create/restore 422 errors. Backup field names now use `target_background`/`target_metadata` (legacy names auto-normalized on restore). Remove WEBSOCKET provider. Scaffold targets default to `APPLICATION` type.