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). diff --git a/.changeset/0021-sdk-092-bump.md b/.changeset/0021-sdk-092-bump.md new file mode 100644 index 0000000..1670e59 --- /dev/null +++ b/.changeset/0021-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/.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/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 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 diff --git a/docs/runtime/dlp/dictionaries.md b/docs/runtime/dlp/dictionaries.md new file mode 100644 index 0000000..22131d8 --- /dev/null +++ b/docs/runtime/dlp/dictionaries.md @@ -0,0 +1,240 @@ +--- +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** — 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 + +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 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 + } +} +``` + +Note `dictionary_metadata.number_of_keywords` reflects the canonical server-side count even when `keywords` is `null`. Pass `--keywords` to populate the 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 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. + +## 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 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. + +## delete + +Delete a dictionary. + +```bash +airs runtime dlp dictionaries delete 6901... +``` + +**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..97a844c --- /dev/null +++ b/docs/runtime/dlp/filtering-profiles.md @@ -0,0 +1,179 @@ +--- +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 and sorting. + +```bash +airs runtime dlp filtering-profiles list +airs runtime dlp filtering-profiles list --page 0 --size 20 +airs runtime dlp filtering-profiles list --sort name,asc --output json +``` + +**Output** — Spring `Page<>` envelope, sample with one entry (`file_type` truncated; `totalElements`/`totalPages` are emitted as `null` by this endpoint): + +```json +{ + "content": [ + { + "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", + "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_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 + } + ], + "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" + 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 6a10... +airs runtime dlp filtering-profiles get 6a10... --output json +``` + +**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 + +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": 1234567890, + "exception_rules": [ + { + "action": "BLOCK", + "log_severity": "CRITICAL", + "data_profile_ids": [1234567890], + "source_attributes": { + "match_any": false, + "user_group_ids": ["legal-review"] + } + } + ] +} +``` + +Then invoke replace: + +```bash +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. + +## 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..ed2f0a7 --- /dev/null +++ b/docs/runtime/dlp/patterns.md @@ -0,0 +1,249 @@ +--- +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** — 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 + +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 6990... +airs runtime dlp patterns get 6990... --output json +``` + +**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 + +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 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`. + +## 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 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. + +## 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 6990... +``` + +**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..6991f8a --- /dev/null +++ b/docs/runtime/dlp/profiles.md @@ -0,0 +1,291 @@ +--- +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** — 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 + +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": [1234567891, 1234567892] + } + } + ] +} +``` + +Then invoke create: + +```bash +airs runtime dlp profiles create --body-file profile-multi.json --output json +``` + +**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 1234567890 +airs runtime dlp profiles get 1234567890 --output json +``` + +**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 + +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 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`. + +## 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 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. + +## 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 diff --git a/package.json b/package.json index 2859913..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.8.3", + "@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 77049c4..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.8.3 - version: 0.8.3 + 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.8.3': - resolution: {integrity: sha512-q6iNeaG/sdFBj7PguOk98TraS/YXfUmafFvYCIxhJ5fESt/Jjc1FACTZtCb6h0dJ+xdwRHLZRLSHv+4bk773jg==} + '@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.8.3': + '@cdot65/prisma-airs-sdk@0.9.2': dependencies: zod: 3.25.76 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/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/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/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/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; +} 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/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); + } + }); +} 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); + } + }); +} 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/dlp/patch.ts b/src/cli/commands/dlp/patch.ts new file mode 100644 index 0000000..1096823 --- /dev/null +++ b/src/cli/commands/dlp/patch.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; + +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; +} + +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/src/cli/commands/dlp/patterns.ts b/src/cli/commands/dlp/patterns.ts new file mode 100644 index 0000000..6ae301c --- /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); + } + }); +} diff --git a/src/cli/commands/dlp/profiles.ts b/src/cli/commands/dlp/profiles.ts new file mode 100644 index 0000000..6299a2c --- /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 - < { 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..7364e0e --- /dev/null +++ b/tests/unit/airs/dlp/client-wiring.spec.ts @@ -0,0 +1,48 @@ +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(async () => { + mockMgmtCtor.mockReset(); + delete process.env.PANW_DLP_ENDPOINT; + const { _resetManagementClient } = await import('../../../../src/airs/management.js'); + _resetManagementClient(); +}); + +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 })); + }); +}); 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); + }); +}); 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'); + }); +}); 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(); + }); +}); 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'); + }); +}); 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); + }); +}); diff --git a/tests/unit/cli/dlp-patch.spec.ts b/tests/unit/cli/dlp-patch.spec.ts new file mode 100644 index 0000000..0652814 --- /dev/null +++ b/tests/unit/cli/dlp-patch.spec.ts @@ -0,0 +1,74 @@ +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, parseBody } 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({}); + }); +}); + +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(); + }); +}); diff --git a/tests/unit/cli/dlp-renderer.spec.ts b/tests/unit/cli/dlp-renderer.spec.ts new file mode 100644 index 0000000..362f33f --- /dev/null +++ b/tests/unit/cli/dlp-renderer.spec.ts @@ -0,0 +1,74 @@ +// biome-ignore-all lint/suspicious/noExplicitAny: test payloads use arbitrary SDK shapes +import { describe, expect, it, vi } from 'vitest'; +import { + dlpDictionaries, + dlpFilteringProfiles, + dlpPatterns, + dlpProfiles, +} 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(); + }); +}); + +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(); + }); +});