From fd4ce59f589ced4f7ac70d8d203f567c6cba2a74 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Fri, 6 Mar 2026 14:59:22 +0530 Subject: [PATCH] @W-21461342 Configurable safety levels --- docs/cli/index.md | 26 ++ docs/guide/ci-cd.md | 39 ++ docs/guide/configuration.md | 1 + docs/guide/security.md | 162 ++++++++ .../b2c-cli/src/commands/am/clients/delete.ts | 3 + .../b2c-cli/src/commands/am/users/delete.ts | 3 + .../b2c-cli/src/commands/am/users/reset.ts | 3 + packages/b2c-cli/src/commands/code/delete.ts | 3 + .../src/commands/ecdn/certificates/delete.ts | 3 + .../src/commands/ecdn/logpush/jobs/delete.ts | 3 + .../src/commands/ecdn/mrt-rules/delete.ts | 3 + .../commands/ecdn/mrt-rules/rules/delete.ts | 3 + .../b2c-cli/src/commands/ecdn/mtls/delete.ts | 3 + .../commands/ecdn/origin-headers/delete.ts | 3 + .../ecdn/page-shield/notifications/delete.ts | 3 + .../ecdn/page-shield/policies/delete.ts | 3 + .../b2c-cli/src/commands/mrt/env/delete.ts | 3 + .../src/commands/mrt/env/redirect/delete.ts | 3 + .../src/commands/mrt/env/var/delete.ts | 3 + .../src/commands/mrt/project/delete.ts | 3 + .../mrt/project/notification/delete.ts | 3 + .../src/commands/sandbox/alias/delete.ts | 3 + .../b2c-cli/src/commands/sandbox/delete.ts | 3 + .../b2c-cli/src/commands/sandbox/reset.ts | 3 + .../src/commands/slas/client/delete.ts | 3 + .../b2c-tooling-sdk/src/cli/base-command.ts | 91 +++- packages/b2c-tooling-sdk/src/clients/index.ts | 1 + .../b2c-tooling-sdk/src/clients/middleware.ts | 39 ++ packages/b2c-tooling-sdk/src/index.ts | 4 + packages/b2c-tooling-sdk/src/safety/index.ts | 14 + .../src/safety/safety-middleware.ts | 138 +++++++ .../test/clients/safety-middleware.test.ts | 316 ++++++++++++++ .../test/safety/safety-middleware.test.ts | 390 ++++++++++++++++++ 33 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 packages/b2c-tooling-sdk/src/safety/index.ts create mode 100644 packages/b2c-tooling-sdk/src/safety/safety-middleware.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts create mode 100644 packages/b2c-tooling-sdk/test/safety/safety-middleware.test.ts diff --git a/docs/cli/index.md b/docs/cli/index.md index 2a274672..e720e5f2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -27,6 +27,32 @@ These flags are available on all commands that interact with B2C instances: | `--username`, `-u` | `SFCC_USERNAME` | Username for Basic Auth | | `--password`, `-p` | `SFCC_PASSWORD` | Password/access key for Basic Auth | +### Safety Mode + +Safety Mode provides protection against accidental or unwanted destructive operations. This is particularly important when using the CLI in automated environments, CI/CD pipelines, or as a tool for AI agents. + +| Environment Variable | Values | Description | +| ---------------------- | ------ | ----------- | +| `SFCC_SAFETY_LEVEL` | `NONE` (default) | No restrictions | +| | `NO_DELETE` | Block DELETE operations | +| | `NO_UPDATE` | Block DELETE and destructive operations (reset, stop, restart) | +| | `READ_ONLY` | Block all write operations (GET only) | + +**Example:** +```bash +# Prevent deletions in CI/CD +export SFCC_SAFETY_LEVEL=NO_DELETE +b2c sandbox create --realm test # ✅ Allowed +b2c sandbox delete test-id # ❌ Blocked + +# Read-only mode for reporting +export SFCC_SAFETY_LEVEL=READ_ONLY +b2c sandbox list # ✅ Allowed +b2c sandbox create --realm test # ❌ Blocked +``` + +Safety Mode operates at the HTTP layer and cannot be bypassed by command-line flags. See the [Security Guide](/guide/security#operational-security-safety-mode) for detailed information. + ### Other Environment Variables | Environment Variable | Description | diff --git a/docs/guide/ci-cd.md b/docs/guide/ci-cd.md index 58bc7afc..006d4451 100644 --- a/docs/guide/ci-cd.md +++ b/docs/guide/ci-cd.md @@ -438,3 +438,42 @@ Logs are always human-readable on stderr. The `--json` flag only controls the st All actions automatically configure: - **`NO_COLOR=1`** — clean log output without ANSI colors + +## Best Practices + +### Use Safety Mode + +Enable [Safety Mode](/guide/security#operational-security-safety-mode) in CI/CD to prevent accidental destructive operations: + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + env: + # Prevent accidental deletions + SFCC_SAFETY_LEVEL:NO_DELETE + steps: + - uses: SalesforceCommerceCloud/b2c-developer-tooling/actions/setup@v1 + - name: Deploy code + run: b2c code deploy +``` + +**Safety Levels for CI/CD:** + +- **`NO_DELETE`** (Recommended for most CI/CD) - Prevents deletions but allows deployments and updates +- **`NO_UPDATE`** (Strict) - Only allows read and create operations, blocks updates and deletions +- **`READ_ONLY`** (Monitoring/Reporting) - Only allows read operations + +**Example: Production deployment with safety:** +```yaml +deploy-production: + environment: production + env: + SFCC_SAFETY_LEVEL: NO_DELETE + steps: + - name: Deploy + run: | + b2c code deploy # ✅ Allowed + b2c sandbox start prod # ✅ Allowed + b2c sandbox delete test # ❌ Blocked by safety mode +``` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d013235e..3db3c5fb 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -82,6 +82,7 @@ You can configure the CLI using environment variables: | `MRT_PROJECT` | MRT project slug (`SFCC_MRT_PROJECT` also supported) | | `MRT_ENVIRONMENT` | MRT environment name (`SFCC_MRT_ENVIRONMENT`, `MRT_TARGET` also supported) | | `MRT_CLOUD_ORIGIN` | MRT API origin URL override (`SFCC_MRT_CLOUD_ORIGIN` also supported) | +| `SFCC_SAFETY_LEVEL` | Safety mode: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` (see [Safety Mode](/guide/security#operational-security-safety-mode)) | ## .env File diff --git a/docs/guide/security.md b/docs/guide/security.md index a41e9623..f0d752c4 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -64,6 +64,167 @@ When adding a new dependency that requires build scripts: This project uses [NPM trusted publishers](https://docs.npmjs.com/trusted-publishers) for package publication. Instead of storing long-lived npm tokens, packages are published via GitHub Actions using short-lived OIDC tokens that cannot be extracted or reused. +## Operational Security: Safety Mode + +The CLI includes a **Safety Mode** feature that prevents accidental or unwanted destructive operations. This is particularly important when: + +- Using the CLI in automated environments (CI/CD pipelines) +- Providing the CLI as a tool to AI agents/LLMs +- Working in production environments +- Training new team members +- Running commands from untrusted scripts + +### How It Works + +Safety Mode uses a **hybrid protection approach**: + +1. **HTTP Middleware Layer** (Primary Protection) + - Intercepts ALL HTTP requests before they're sent + - Cannot be bypassed by command-line flags + - Works automatically for all commands + - LLM-proof: controlled via environment variable + +2. **Command-Level Checks** (Better UX) + - Provides early, user-friendly error messages + - Catches operations before HTTP requests + +### Safety Levels + +Configure via the `SFCC_SAFETY_LEVEL` environment variable: + +| Level | Description | Blocks | +|-------|-------------|--------| +| `NONE` | No restrictions (default) | Nothing | +| `NO_DELETE` | Prevent deletions | DELETE operations | +| `NO_UPDATE` | Prevent deletions and destructive updates | DELETE + reset/stop/restart | +| `READ_ONLY` | Read-only mode | All writes (POST/PUT/PATCH/DELETE) | + +### Usage Examples + +#### Development (Allow Everything) +```bash +# No restrictions (default) +unset SFCC_SAFETY_LEVEL +# OR +export SFCC_SAFETY_LEVEL=NONE + +b2c sandbox delete test-id # ✅ Allowed +``` + +#### CI/CD (Prevent Deletions) +```bash +# Prevent accidental deletions in automated environments +export SFCC_SAFETY_LEVEL=NO_DELETE + +b2c sandbox create --realm test # ✅ Allowed +b2c sandbox delete test-id # ❌ Blocked +``` + +#### LLM/Agent Tools (Maximum Protection) +```bash +# Prevent AI agents from performing destructive operations +export SFCC_SAFETY_LEVEL=NO_UPDATE + +b2c sandbox list # ✅ Allowed +b2c sandbox create --realm test # ✅ Allowed +b2c sandbox delete test-id # ❌ Blocked +b2c sandbox reset test-id # ❌ Blocked +``` + +#### Monitoring/Reporting (Read-Only) +```bash +# Absolute read-only mode +export SFCC_SAFETY_LEVEL=READ_ONLY + +b2c sandbox list # ✅ Allowed +b2c sandbox get test-id # ✅ Allowed +b2c sandbox create test # ❌ Blocked +``` + +### What Gets Blocked + +| Safety Level | DELETE | POST (create) | POST (reset) | PUT/PATCH | GET | +|--------------|--------|---------------|--------------|-----------|-----| +| **NONE** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **NO_DELETE** | ❌ | ✅ | ✅ | ✅ | ✅ | +| **NO_UPDATE** | ❌ | ✅ | ❌ | ✅ | ✅ | +| **READ_ONLY** | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Protected Commands + +Safety Mode automatically protects ALL destructive commands across all topics: + +- **Sandbox**: `delete`, `reset`, `alias delete` +- **Account Manager**: `users delete`, `users reset`, `clients delete` +- **Code**: `delete` +- **MRT**: `project delete`, `env delete`, `env var delete`, `env redirect delete`, `project notification delete` +- **SLAS**: `client delete` +- **eCDN**: All delete operations (certificates, zones, rules, etc.) + +### Why Environment Variable? + +Environment variables are more secure than command-line flags because: + +1. **LLMs Don't Control Them**: When an LLM uses the CLI as a tool, it controls commands and flags but NOT the environment +2. **Session-Level**: Set once for the entire session/container +3. **Audit Trail**: Can be logged and monitored in CI/CD +4. **Cannot Be Bypassed**: Even `--force` flags don't override safety mode + +### Error Messages + +When an operation is blocked, you'll see clear error messages: + +```bash +export SFCC_SAFETY_LEVEL=NO_DELETE +b2c sandbox delete test-id + +# Error: Cannot delete sandbox: blocked by safety level NO_DELETE. +# +# Delete operations are blocked in NO_DELETE mode +# +# To allow this operation, unset or change the SFCC_SAFETY_LEVEL environment variable. +``` + +### Best Practices + +#### For CI/CD Pipelines +```yaml +# GitHub Actions example +- name: Deploy to Production + env: + SFCC_SAFETY_LEVEL: NO_DELETE # Prevent accidental deletions + run: | + b2c code deploy + b2c sandbox start production +``` + +#### For AI Agent Tools +```bash +# Provide CLI to LLMs with safety enabled +export SFCC_SAFETY_LEVEL=NO_UPDATE +# LLMs can now read and create, but cannot delete or reset +``` + +#### For Production Environments +```bash +# Set in shell profile for production access +echo 'export SFCC_SAFETY_LEVEL=NO_UPDATE' >> ~/.bashrc +``` + +### Testing Safety Mode + +Verify safety mode is working: + +```bash +export SFCC_SAFETY_LEVEL=NO_DELETE +b2c sandbox delete fake-id + +# Expected: "blocked by safety level NO_DELETE" +# NOT expected: Authentication error or API call +``` + +For comprehensive testing, see [GitHub Issue #67](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/issues/67). + ## Best Practices ### For Contributors @@ -78,3 +239,4 @@ This project uses [NPM trusted publishers](https://docs.npmjs.com/trusted-publis - Keep the CLI updated to receive security patches - Review the `pnpm-workspace.yaml` settings if you fork or modify this project - Consider using similar protections in your own projects +- **Use Safety Mode** when running CLI in automated environments or providing it as a tool to AI agents diff --git a/packages/b2c-cli/src/commands/am/clients/delete.ts b/packages/b2c-cli/src/commands/am/clients/delete.ts index fae9a5af..19a3a6f7 100644 --- a/packages/b2c-cli/src/commands/am/clients/delete.ts +++ b/packages/b2c-cli/src/commands/am/clients/delete.ts @@ -27,6 +27,9 @@ export default class ClientDelete extends AmCommand { static examples = ['<%= config.bin %> <%= command.id %> ']; async run(): Promise { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete API client'); + const apiClientId = this.args['api-client-id']; this.log(t('commands.client.delete.deleting', 'Deleting API client {{id}}...', {id: apiClientId})); diff --git a/packages/b2c-cli/src/commands/am/users/delete.ts b/packages/b2c-cli/src/commands/am/users/delete.ts index 7847510e..31992ecc 100644 --- a/packages/b2c-cli/src/commands/am/users/delete.ts +++ b/packages/b2c-cli/src/commands/am/users/delete.ts @@ -35,6 +35,9 @@ export default class UserDelete extends AmCommand { }; async run(): Promise { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete user'); + const {login} = this.args; const {purge} = this.flags; diff --git a/packages/b2c-cli/src/commands/am/users/reset.ts b/packages/b2c-cli/src/commands/am/users/reset.ts index 88f36522..6dc640af 100644 --- a/packages/b2c-cli/src/commands/am/users/reset.ts +++ b/packages/b2c-cli/src/commands/am/users/reset.ts @@ -28,6 +28,9 @@ export default class UserReset extends AmCommand { ]; async run(): Promise { + // Prevent password reset in safe mode + this.assertDestructiveOperationAllowed('reset user password'); + const {login} = this.args; this.log(t('commands.user.reset.fetching', 'Fetching user {{login}}...', {login})); diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index 6ff4d872..8dae3729 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -60,6 +60,9 @@ export default class CodeDelete extends InstanceCommand { }; async run(): Promise { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete code version'); + this.requireOAuthCredentials(); const codeVersion = this.args.codeVersion; diff --git a/packages/b2c-cli/src/commands/ecdn/certificates/delete.ts b/packages/b2c-cli/src/commands/ecdn/certificates/delete.ts index c38b770d..bff526ba 100644 --- a/packages/b2c-cli/src/commands/ecdn/certificates/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/certificates/delete.ts @@ -49,6 +49,9 @@ export default class EcdnCertificatesDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete certificate'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/ecdn/logpush/jobs/delete.ts b/packages/b2c-cli/src/commands/ecdn/logpush/jobs/delete.ts index cddade50..ac46e645 100644 --- a/packages/b2c-cli/src/commands/ecdn/logpush/jobs/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/logpush/jobs/delete.ts @@ -37,6 +37,9 @@ export default class EcdnLogpushJobsDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete Logpush job'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/ecdn/mrt-rules/delete.ts b/packages/b2c-cli/src/commands/ecdn/mrt-rules/delete.ts index f36911c1..c645cdf3 100644 --- a/packages/b2c-cli/src/commands/ecdn/mrt-rules/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/mrt-rules/delete.ts @@ -39,6 +39,9 @@ export default class EcdnMrtRulesDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete MRT ruleset'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/ecdn/mrt-rules/rules/delete.ts b/packages/b2c-cli/src/commands/ecdn/mrt-rules/rules/delete.ts index 08cc2809..2a5510ef 100644 --- a/packages/b2c-cli/src/commands/ecdn/mrt-rules/rules/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/mrt-rules/rules/delete.ts @@ -43,6 +43,9 @@ export default class EcdnMrtRulesRulesDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete MRT rule'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/ecdn/mtls/delete.ts b/packages/b2c-cli/src/commands/ecdn/mtls/delete.ts index 6b5db2b9..8cda6a9a 100644 --- a/packages/b2c-cli/src/commands/ecdn/mtls/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/mtls/delete.ts @@ -39,6 +39,9 @@ export default class EcdnMtlsDelete extends EcdnCommand { }; async run(): Promise { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete mTLS certificate'); + this.requireOAuthCredentials(); const mtlsCertificateId = this.flags['certificate-id']; diff --git a/packages/b2c-cli/src/commands/ecdn/origin-headers/delete.ts b/packages/b2c-cli/src/commands/ecdn/origin-headers/delete.ts index 5bddfadb..d6ee991f 100644 --- a/packages/b2c-cli/src/commands/ecdn/origin-headers/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/origin-headers/delete.ts @@ -32,6 +32,9 @@ export default class EcdnOriginHeadersDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete origin header modification'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/delete.ts b/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/delete.ts index 9049ff01..31eacd5c 100644 --- a/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/delete.ts @@ -37,6 +37,9 @@ export default class EcdnPageShieldNotificationsDelete extends EcdnCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete Page Shield webhook'); + this.requireOAuthCredentials(); const webhookId = this.flags['webhook-id']; diff --git a/packages/b2c-cli/src/commands/ecdn/page-shield/policies/delete.ts b/packages/b2c-cli/src/commands/ecdn/page-shield/policies/delete.ts index c197bff3..f342e974 100644 --- a/packages/b2c-cli/src/commands/ecdn/page-shield/policies/delete.ts +++ b/packages/b2c-cli/src/commands/ecdn/page-shield/policies/delete.ts @@ -39,6 +39,9 @@ export default class EcdnPageShieldPoliciesDelete extends EcdnZoneCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete Page Shield policy'); + this.requireOAuthCredentials(); const zoneId = await this.resolveZoneId(); diff --git a/packages/b2c-cli/src/commands/mrt/env/delete.ts b/packages/b2c-cli/src/commands/mrt/env/delete.ts index 63b588c8..ad549057 100644 --- a/packages/b2c-cli/src/commands/mrt/env/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/delete.ts @@ -64,6 +64,9 @@ export default class MrtEnvDelete extends MrtCommand { }; async run(): Promise<{slug: string; project: string}> { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete MRT environment'); + this.requireMrtCredentials(); const {slug} = this.args; diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts index fb82e374..256d7601 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts @@ -59,6 +59,9 @@ export default class MrtRedirectDelete extends MrtCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete redirect'); + this.requireMrtCredentials(); const {fromPath} = this.args; diff --git a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts index 93647dc8..57eb250c 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts @@ -40,6 +40,9 @@ export default class MrtEnvVarDelete extends MrtCommand }; async run(): Promise<{key: string; project: string; environment: string}> { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete environment variable'); + this.requireMrtCredentials(); const {key} = this.args; diff --git a/packages/b2c-cli/src/commands/mrt/project/delete.ts b/packages/b2c-cli/src/commands/mrt/project/delete.ts index 2431a261..81b20c49 100644 --- a/packages/b2c-cli/src/commands/mrt/project/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/project/delete.ts @@ -67,6 +67,9 @@ export default class MrtProjectDelete extends MrtCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete MRT project'); + this.requireMrtCredentials(); const {slug} = this.args; diff --git a/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts b/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts index 53adc627..6e427d8b 100644 --- a/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/project/notification/delete.ts @@ -59,6 +59,9 @@ export default class MrtNotificationDelete extends MrtCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete notification'); + this.requireMrtCredentials(); const {id} = this.args; diff --git a/packages/b2c-cli/src/commands/sandbox/alias/delete.ts b/packages/b2c-cli/src/commands/sandbox/alias/delete.ts index 6799c209..89421887 100644 --- a/packages/b2c-cli/src/commands/sandbox/alias/delete.ts +++ b/packages/b2c-cli/src/commands/sandbox/alias/delete.ts @@ -48,6 +48,9 @@ export default class SandboxAliasDelete extends OdsCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete sandbox alias'); + const {sandboxId, aliasId} = this.args; const {force} = this.flags; diff --git a/packages/b2c-cli/src/commands/sandbox/delete.ts b/packages/b2c-cli/src/commands/sandbox/delete.ts index bfe37603..500958d0 100644 --- a/packages/b2c-cli/src/commands/sandbox/delete.ts +++ b/packages/b2c-cli/src/commands/sandbox/delete.ts @@ -81,6 +81,9 @@ export default class SandboxDelete extends OdsCommand { }; async run(): Promise { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete sandbox'); + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); const wait = this.flags.wait as boolean; const pollInterval = this.flags['poll-interval'] as number; diff --git a/packages/b2c-cli/src/commands/sandbox/reset.ts b/packages/b2c-cli/src/commands/sandbox/reset.ts index 6529e581..c30a5771 100644 --- a/packages/b2c-cli/src/commands/sandbox/reset.ts +++ b/packages/b2c-cli/src/commands/sandbox/reset.ts @@ -65,6 +65,9 @@ export default class SandboxReset extends OdsCommand { }; async run(): Promise { + // Prevent reset in safe mode + this.assertDestructiveOperationAllowed('reset sandbox'); + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); const {wait, 'poll-interval': pollInterval, timeout, force} = this.flags; diff --git a/packages/b2c-cli/src/commands/slas/client/delete.ts b/packages/b2c-cli/src/commands/slas/client/delete.ts index b549f2f8..65ed6329 100644 --- a/packages/b2c-cli/src/commands/slas/client/delete.ts +++ b/packages/b2c-cli/src/commands/slas/client/delete.ts @@ -37,6 +37,9 @@ export default class SlasClientDelete extends SlasClientCommand { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete SLAS client'); + this.requireOAuthCredentials(); const tenantId = this.requireTenantId(); diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 850b1f1a..1e143748 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -18,7 +18,8 @@ import type { } from './hooks.js'; import {setLanguage} from '../i18n/index.js'; import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging/index.js'; -import {createExtraParamsMiddleware, type ExtraParamsConfig} from '../clients/middleware.js'; +import {createExtraParamsMiddleware, createSafetyMiddleware, type ExtraParamsConfig} from '../clients/middleware.js'; +import {getSafetyLevel, describeSafetyLevel} from '../safety/index.js'; import {globalConfigSourceRegistry} from '../config/config-source-registry.js'; import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; import {globalAuthMiddlewareRegistry} from '../auth/middleware.js'; @@ -160,6 +161,10 @@ export abstract class BaseCommand extends Command { // This must happen before any API clients are created this.registerExtraParamsMiddleware(); + // Register safety middleware FIRST (before any other middleware) + // This provides unbypassable protection at the HTTP layer + this.registerSafetyMiddleware(); + // Collect middleware from plugins before any API clients are created await this.collectPluginHttpMiddleware(); @@ -581,6 +586,62 @@ export abstract class BaseCommand extends Command { this.logger.info('BaseCommand initialized'); } + /** + * Check if destructive operations are allowed based on safety level. + * Provides early, user-friendly error messages before HTTP requests are attempted. + * + * This is a command-level check that complements the HTTP middleware safety guard. + * While the middleware provides unbypassable protection, this method offers better + * error messages and early detection. + * + * Destructive operations include: + * - Deleting resources (sandboxes, users, API clients, etc.) + * - Resetting or wiping data + * - Force operations that overwrite data + * - Revoking access or permissions + * + * NOTE: This is optional - the HTTP middleware will catch any operations that bypass + * this check. Use this method for better UX when you know an operation is destructive. + * + * @param operationDescription - Description of the operation (e.g., "delete sandbox", "reset user password") + * @throws Error if safety level blocks the operation + * + * @example + * ```typescript + * async run() { + * this.assertDestructiveOperationAllowed('delete sandbox'); + * // ... proceed with deletion + * } + * ``` + */ + protected assertDestructiveOperationAllowed(operationDescription?: string): void { + const safetyLevel = getSafetyLevel('NONE'); + + if (safetyLevel === 'NONE') { + return; // No restrictions + } + + const operation = operationDescription || 'this destructive operation'; + + // Determine if this operation should be blocked + // We assume all calls to this method are for destructive operations + // The safety level determines blocking: + // - READ_ONLY: blocks all writes + // - NO_DELETE: blocks deletes (we assume this method is called for deletes/destructive ops) + // - NO_UPDATE: blocks deletes and resets + const shouldBlock = safetyLevel === 'READ_ONLY' || safetyLevel === 'NO_DELETE' || safetyLevel === 'NO_UPDATE'; + + if (shouldBlock) { + this.error( + `Cannot ${operation}: blocked by safety level ${safetyLevel}.\n\n` + + `${describeSafetyLevel(safetyLevel)}\n\n` + + `To allow this operation, unset or change the SFCC_SAFETY_LEVEL environment variable.\n` + + `See: https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/issues/67`, + {exit: 1}, + ); + } + } + /** * Parse extra params from --extra-query, --extra-body, and --extra-headers flags. * Returns undefined if no extra params are specified. @@ -625,6 +686,34 @@ export abstract class BaseCommand extends Command { return config; } + /** + * Register safety middleware to block destructive operations. + * This provides unbypassable protection at the HTTP layer - cannot be circumvented + * by command-line flags since it operates on all HTTP requests. + * + * Safety level is determined by the SFCC_SAFETY_LEVEL environment variable: + * - NONE: No restrictions (default) + * - NO_DELETE: Block DELETE operations + * - NO_UPDATE: Block DELETE + destructive operations (reset/stop/restart) + * - READ_ONLY: Block all writes (GET only) + */ + private registerSafetyMiddleware(): void { + const safetyLevel = getSafetyLevel('NONE'); + + // Only register if safety is enabled + if (safetyLevel === 'NONE') return; + + // Safety mode is silent until it blocks something + // Error messages will be shown when operations are actually blocked + + globalMiddlewareRegistry.register({ + name: 'cli-safety-guard', + getMiddleware() { + return createSafetyMiddleware({level: safetyLevel}); + }, + }); + } + /** * Register extra params (query, body, headers) as global middleware. * This applies to ALL HTTP clients created during command execution. diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 8d7c0f45..61b77b04 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -125,6 +125,7 @@ export { createLoggingMiddleware, createExtraParamsMiddleware, createUserAgentMiddleware, + createSafetyMiddleware, } from './middleware.js'; export type { ExtraParamsConfig, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index 410d17aa..df894aca 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -533,6 +533,45 @@ export function createLoggingMiddleware(config?: string | LoggingMiddlewareConfi * })); * ``` */ +/** + * Configuration for safety middleware. + */ +import type {SafetyConfig} from '../safety/safety-middleware.js'; +import {checkSafetyViolation, SafetyBlockedError} from '../safety/safety-middleware.js'; + +/** + * Creates safety middleware that blocks destructive operations. + * + * This middleware intercepts HTTP requests BEFORE they are sent and blocks + * destructive operations based on the configured safety level. It cannot be + * bypassed by command-line flags since it operates at the HTTP layer. + * + * @param config - Safety configuration + * @returns Middleware that blocks destructive operations + * + * @example + * ```typescript + * const client = createOdsClient(config, auth); + * client.use(createSafetyMiddleware({ level: 'NO_DELETE' })); + * ``` + */ +export function createSafetyMiddleware(config: SafetyConfig): Middleware { + const logger = getLogger(); + + return { + async onRequest({request}) { + const errorMessage = checkSafetyViolation(request.method, request.url, config); + + if (errorMessage) { + logger.warn({method: request.method, url: request.url, safetyLevel: config.level}, `[SAFETY] ${errorMessage}`); + throw new SafetyBlockedError(errorMessage, request.method, request.url, config.level); + } + + return request; + }, + }; +} + /** * Configuration for User-Agent middleware. */ diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 5cda8a8d..fdd2754d 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -304,6 +304,10 @@ export {getRole, listRoles} from './operations/roles/index.js'; // Operations - Organizations export {getOrg, getOrgByName, listOrgs} from './operations/orgs/index.js'; +// Safety - Protection against destructive operations +export {getSafetyLevel, describeSafetyLevel, checkSafetyViolation, SafetyBlockedError} from './safety/index.js'; +export type {SafetyLevel, SafetyConfig} from './safety/index.js'; + // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST, DEFAULT_PUBLIC_CLIENT_ID} from './defaults.js'; diff --git a/packages/b2c-tooling-sdk/src/safety/index.ts b/packages/b2c-tooling-sdk/src/safety/index.ts new file mode 100644 index 00000000..2df7ea76 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/safety/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Safety module for preventing destructive operations. + * + * @module safety + */ + +export type {SafetyLevel, SafetyConfig} from './safety-middleware.js'; +export {SafetyBlockedError, checkSafetyViolation, getSafetyLevel, describeSafetyLevel} from './safety-middleware.js'; diff --git a/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts b/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts new file mode 100644 index 00000000..6ef9cd78 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Safety levels for preventing destructive operations. + * + * - NONE: No safety restrictions (default) + * - NO_DELETE: Block DELETE operations only + * - NO_UPDATE: Block DELETE and destructive operations (reset, stop, restart) + * - READ_ONLY: Block all write operations (only GET allowed) + */ +export type SafetyLevel = 'NONE' | 'NO_DELETE' | 'NO_UPDATE' | 'READ_ONLY'; + +export interface SafetyConfig { + level: SafetyLevel; + allowedPaths?: string[]; // Whitelist specific paths (e.g., ['/auth/token']) + blockedPaths?: string[]; // Blacklist specific paths +} + +/** + * Safety error thrown when an operation is blocked by safety middleware. + */ +export class SafetyBlockedError extends Error { + constructor( + message: string, + public readonly method: string, + public readonly url: string, + public readonly safetyLevel: SafetyLevel, + ) { + super(message); + this.name = 'SafetyBlockedError'; + } +} + +/** + * Checks if an HTTP operation should be blocked based on safety configuration. + * + * @param method - HTTP method (GET, POST, PUT, PATCH, DELETE) + * @param url - Request URL + * @param config - Safety configuration + * @returns Error message if blocked, undefined if allowed + */ +export function checkSafetyViolation(method: string, url: string, config: SafetyConfig): string | undefined { + const upperMethod = method.toUpperCase(); + const path = new URL(url, 'http://dummy').pathname; + + // Check whitelist first + if (config.allowedPaths && config.allowedPaths.some((allowed) => path.startsWith(allowed))) { + return undefined; // Explicitly allowed + } + + // Check blacklist + if (config.blockedPaths && config.blockedPaths.some((blocked) => path.startsWith(blocked))) { + return `Operation blocked: ${upperMethod} ${path} is in the blocked paths list`; + } + + switch (config.level) { + case 'NONE': + return undefined; // No restrictions + + case 'NO_DELETE': + if (upperMethod === 'DELETE') { + return `Delete operation blocked: DELETE ${path} (NO_DELETE mode prevents deletions)`; + } + return undefined; + + case 'NO_UPDATE': + // Block DELETE operations + if (upperMethod === 'DELETE') { + return `Delete operation blocked: DELETE ${path} (NO_UPDATE mode prevents deletions)`; + } + // Block operations that contain reset, stop, restart in path or might be destructive + const destructivePatterns = ['/reset', '/stop', '/restart', '/operations']; + if (destructivePatterns.some((pattern) => path.includes(pattern)) && upperMethod === 'POST') { + return `Destructive operation blocked: POST ${path} (NO_UPDATE mode prevents reset/stop/restart)`; + } + return undefined; + + case 'READ_ONLY': + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod)) { + return `Write operation blocked: ${upperMethod} ${path} (READ_ONLY mode prevents all modifications)`; + } + return undefined; + + default: + return undefined; + } +} + +/** + * Parse safety level from environment variable or config. + * + * Reads from SFCC_SAFETY_LEVEL environment variable. + * Also supports legacy naming for backward compatibility with early adopters. + * + * @param defaultLevel - Default level if no environment variable is set + * @returns Parsed safety level + */ +export function getSafetyLevel(defaultLevel: SafetyLevel = 'NONE'): SafetyLevel { + const safetyLevelEnv = process.env['SFCC_SAFETY_LEVEL']; + if (safetyLevelEnv) { + const upper = safetyLevelEnv.toUpperCase().replace('-', '_'); + if (['NONE', 'NO_DELETE', 'NO_UPDATE', 'READ_ONLY'].includes(upper)) { + return upper as SafetyLevel; + } + + // Backward compatibility: map old names to new names (for early adopters) + if (upper === 'NO_DESTRUCTIVE') { + return 'NO_UPDATE'; + } + if (upper === 'READONLY') { + return 'READ_ONLY'; + } + } + + return defaultLevel; +} + +/** + * Get a user-friendly description of the safety level. + */ +export function describeSafetyLevel(level: SafetyLevel): string { + switch (level) { + case 'NONE': + return 'No safety restrictions'; + case 'NO_DELETE': + return 'Delete operations blocked'; + case 'NO_UPDATE': + return 'Destructive operations blocked (delete, reset, stop, restart)'; + case 'READ_ONLY': + return 'Read-only mode - all write operations blocked'; + default: + return 'Unknown safety level'; + } +} diff --git a/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts b/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts new file mode 100644 index 00000000..f46e684e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {createSafetyMiddleware} from '@salesforce/b2c-tooling-sdk/clients'; +import type {SafetyConfig} from '@salesforce/b2c-tooling-sdk'; +import {SafetyBlockedError} from '@salesforce/b2c-tooling-sdk'; + +describe('clients/middleware - createSafetyMiddleware', () => { + describe('HTTP request interception', () => { + it('allows safe operations to pass through', async () => { + const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/items', {method: 'GET'}); + const result = await middleware.onRequest!({request} as unknown as OnRequestParams); + + expect(result).to.equal(request); + }); + + it('throws SafetyBlockedError for blocked operations', async () => { + const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/items/1', {method: 'DELETE'}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).method).to.equal('DELETE'); + expect((error as SafetyBlockedError).url).to.equal('https://api.example.com/items/1'); + expect((error as SafetyBlockedError).safetyLevel).to.equal('NO_DELETE'); + expect((error as SafetyBlockedError).message).to.include('Delete operation blocked'); + } + }); + }); + + describe('NO_DELETE level middleware', () => { + it('blocks DELETE requests', async () => { + const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/sandboxes/123', {method: 'DELETE'}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('Delete operation blocked'); + } + }); + + it('allows GET, POST, PUT, PATCH requests', async () => { + const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + type OnRequestParams = Parameters>[0]; + + const methods = ['GET', 'POST', 'PUT', 'PATCH']; + + for (const method of methods) { + const request = new Request('https://api.example.com/items', {method}); + const result = await middleware.onRequest!({request} as unknown as OnRequestParams); + expect(result).to.equal(request); + } + }); + }); + + describe('NO_UPDATE level middleware', () => { + it('blocks DELETE requests', async () => { + const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/items/1', {method: 'DELETE'}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + }); + + it('blocks destructive POST operations', async () => { + const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + type OnRequestParams = Parameters>[0]; + + const destructivePaths = [ + 'https://api.example.com/sandboxes/123/reset', + 'https://api.example.com/sandboxes/123/stop', + 'https://api.example.com/sandboxes/123/restart', + 'https://api.example.com/sandboxes/123/operations', + ]; + + for (const url of destructivePaths) { + const request = new Request(url, {method: 'POST'}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error(`Expected SafetyBlockedError for ${url}`); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('Destructive operation blocked'); + } + } + }); + + it('allows normal POST operations', async () => { + const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + type OnRequestParams = Parameters>[0]; + + const safePaths = [ + 'https://api.example.com/sandboxes', + 'https://api.example.com/items', + 'https://api.example.com/users', + ]; + + for (const url of safePaths) { + const request = new Request(url, {method: 'POST'}); + const result = await middleware.onRequest!({request} as unknown as OnRequestParams); + expect(result).to.equal(request); + } + }); + }); + + describe('READ_ONLY level middleware', () => { + it('blocks all write operations', async () => { + const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + type OnRequestParams = Parameters>[0]; + + const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; + + for (const method of writeMethods) { + const request = new Request('https://api.example.com/items', {method}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error(`Expected SafetyBlockedError for ${method}`); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('Write operation blocked'); + expect((error as SafetyBlockedError).message).to.include('READ_ONLY mode'); + } + } + }); + + it('allows GET operations', async () => { + const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/items', {method: 'GET'}); + const result = await middleware.onRequest!({request} as unknown as OnRequestParams); + + expect(result).to.equal(request); + }); + }); + + describe('NONE level middleware', () => { + it('allows all operations', async () => { + const middleware = createSafetyMiddleware({level: 'NONE'}); + type OnRequestParams = Parameters>[0]; + + const allMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + + for (const method of allMethods) { + const request = new Request('https://api.example.com/items', {method}); + const result = await middleware.onRequest!({request} as unknown as OnRequestParams); + expect(result).to.equal(request); + } + }); + }); + + describe('allowedPaths configuration', () => { + it('allows whitelisted paths regardless of safety level', async () => { + const config: SafetyConfig = { + level: 'READ_ONLY', + allowedPaths: ['/auth/token', '/health'], + }; + const middleware = createSafetyMiddleware(config); + type OnRequestParams = Parameters>[0]; + + // POST should normally be blocked in READ_ONLY + const authRequest = new Request('https://api.example.com/auth/token', {method: 'POST'}); + const result1 = await middleware.onRequest!({request: authRequest} as unknown as OnRequestParams); + expect(result1).to.equal(authRequest); + + const healthRequest = new Request('https://api.example.com/health', {method: 'POST'}); + const result2 = await middleware.onRequest!({request: healthRequest} as unknown as OnRequestParams); + expect(result2).to.equal(healthRequest); + + // Non-whitelisted path should still be blocked + const itemsRequest = new Request('https://api.example.com/items', {method: 'POST'}); + try { + await middleware.onRequest!({request: itemsRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + }); + }); + + describe('blockedPaths configuration', () => { + it('blocks blacklisted paths even with NONE level', async () => { + const config: SafetyConfig = { + level: 'NONE', + blockedPaths: ['/admin', '/dangerous'], + }; + const middleware = createSafetyMiddleware(config); + type OnRequestParams = Parameters>[0]; + + const adminRequest = new Request('https://api.example.com/admin/users', {method: 'GET'}); + try { + await middleware.onRequest!({request: adminRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('blocked paths list'); + } + + const dangerousRequest = new Request('https://api.example.com/dangerous/op', {method: 'DELETE'}); + try { + await middleware.onRequest!({request: dangerousRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + + // Non-blacklisted path should be allowed + const safeRequest = new Request('https://api.example.com/items/1', {method: 'DELETE'}); + const result = await middleware.onRequest!({request: safeRequest} as unknown as OnRequestParams); + expect(result).to.equal(safeRequest); + }); + }); + + describe('combined allowedPaths and blockedPaths', () => { + it('whitelist takes precedence over blacklist', async () => { + const config: SafetyConfig = { + level: 'NONE', + allowedPaths: ['/admin/readonly'], + blockedPaths: ['/admin'], + }; + const middleware = createSafetyMiddleware(config); + type OnRequestParams = Parameters>[0]; + + // Whitelisted admin path should be allowed + const readonlyRequest = new Request('https://api.example.com/admin/readonly/data', {method: 'GET'}); + const result = await middleware.onRequest!({request: readonlyRequest} as unknown as OnRequestParams); + expect(result).to.equal(readonlyRequest); + + // Other admin paths should be blocked + const usersRequest = new Request('https://api.example.com/admin/users', {method: 'GET'}); + try { + await middleware.onRequest!({request: usersRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + }); + }); + + describe('real-world scenarios', () => { + it('protects against accidental sandbox deletion', async () => { + const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://api.example.com/sandboxes/prod-123', {method: 'DELETE'}); + + try { + await middleware.onRequest!({request} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).method).to.equal('DELETE'); + expect((error as SafetyBlockedError).url).to.include('prod-123'); + } + }); + + it('protects against destructive operations on production', async () => { + const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + type OnRequestParams = Parameters>[0]; + + const resetRequest = new Request('https://api.example.com/sandboxes/prod-123/reset', {method: 'POST'}); + + try { + await middleware.onRequest!({request: resetRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('Destructive operation blocked'); + } + }); + + it('enforces read-only access for audit/investigation scenarios', async () => { + const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + type OnRequestParams = Parameters>[0]; + + // Reading should work + const getRequest = new Request('https://api.example.com/logs', {method: 'GET'}); + const getResult = await middleware.onRequest!({request: getRequest} as unknown as OnRequestParams); + expect(getResult).to.equal(getRequest); + + // Writing should be blocked + const postRequest = new Request('https://api.example.com/logs', {method: 'POST'}); + try { + await middleware.onRequest!({request: postRequest} as unknown as OnRequestParams); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/safety/safety-middleware.test.ts b/packages/b2c-tooling-sdk/test/safety/safety-middleware.test.ts new file mode 100644 index 00000000..e0814dd2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/safety/safety-middleware.test.ts @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + checkSafetyViolation, + getSafetyLevel, + describeSafetyLevel, + SafetyBlockedError, + type SafetyConfig, + type SafetyLevel, +} from '@salesforce/b2c-tooling-sdk'; + +describe('safety/safety-middleware', () => { + describe('checkSafetyViolation', () => { + describe('NONE level', () => { + it('allows all operations', () => { + const config: SafetyConfig = {level: 'NONE'}; + + expect(checkSafetyViolation('GET', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('POST', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('PUT', 'https://api.example.com/items/1', config)).to.be.undefined; + expect(checkSafetyViolation('PATCH', 'https://api.example.com/items/1', config)).to.be.undefined; + expect(checkSafetyViolation('DELETE', 'https://api.example.com/items/1', config)).to.be.undefined; + }); + }); + + describe('NO_DELETE level', () => { + it('blocks DELETE operations', () => { + const config: SafetyConfig = {level: 'NO_DELETE'}; + + const result = checkSafetyViolation('DELETE', 'https://api.example.com/items/1', config); + expect(result).to.include('Delete operation blocked'); + expect(result).to.include('NO_DELETE mode'); + }); + + it('allows GET, POST, PUT, PATCH operations', () => { + const config: SafetyConfig = {level: 'NO_DELETE'}; + + expect(checkSafetyViolation('GET', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('POST', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('PUT', 'https://api.example.com/items/1', config)).to.be.undefined; + expect(checkSafetyViolation('PATCH', 'https://api.example.com/items/1', config)).to.be.undefined; + }); + + it('is case-insensitive for method', () => { + const config: SafetyConfig = {level: 'NO_DELETE'}; + + expect(checkSafetyViolation('delete', 'https://api.example.com/items/1', config)).to.include( + 'Delete operation blocked', + ); + expect(checkSafetyViolation('Delete', 'https://api.example.com/items/1', config)).to.include( + 'Delete operation blocked', + ); + expect(checkSafetyViolation('DELETE', 'https://api.example.com/items/1', config)).to.include( + 'Delete operation blocked', + ); + }); + }); + + describe('NO_UPDATE level', () => { + it('blocks DELETE operations', () => { + const config: SafetyConfig = {level: 'NO_UPDATE'}; + + const result = checkSafetyViolation('DELETE', 'https://api.example.com/sandboxes/123', config); + expect(result).to.include('Delete operation blocked'); + expect(result).to.include('NO_UPDATE mode'); + }); + + it('blocks destructive POST operations (reset, stop, restart)', () => { + const config: SafetyConfig = {level: 'NO_UPDATE'}; + + const resetResult = checkSafetyViolation('POST', 'https://api.example.com/sandboxes/123/reset', config); + expect(resetResult).to.include('Destructive operation blocked'); + expect(resetResult).to.include('NO_UPDATE mode'); + + const stopResult = checkSafetyViolation('POST', 'https://api.example.com/sandboxes/123/stop', config); + expect(stopResult).to.include('Destructive operation blocked'); + + const restartResult = checkSafetyViolation('POST', 'https://api.example.com/sandboxes/123/restart', config); + expect(restartResult).to.include('Destructive operation blocked'); + }); + + it('blocks POST to /operations paths', () => { + const config: SafetyConfig = {level: 'NO_UPDATE'}; + + const result = checkSafetyViolation('POST', 'https://api.example.com/sandboxes/123/operations', config); + expect(result).to.include('Destructive operation blocked'); + }); + + it('allows normal POST operations', () => { + const config: SafetyConfig = {level: 'NO_UPDATE'}; + + expect(checkSafetyViolation('POST', 'https://api.example.com/sandboxes', config)).to.be.undefined; + expect(checkSafetyViolation('POST', 'https://api.example.com/items', config)).to.be.undefined; + }); + + it('allows GET, PUT, PATCH operations', () => { + const config: SafetyConfig = {level: 'NO_UPDATE'}; + + expect(checkSafetyViolation('GET', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('PUT', 'https://api.example.com/items/1', config)).to.be.undefined; + expect(checkSafetyViolation('PATCH', 'https://api.example.com/items/1', config)).to.be.undefined; + }); + }); + + describe('READ_ONLY level', () => { + it('blocks all write operations (POST, PUT, PATCH, DELETE)', () => { + const config: SafetyConfig = {level: 'READ_ONLY'}; + + const postResult = checkSafetyViolation('POST', 'https://api.example.com/items', config); + expect(postResult).to.include('Write operation blocked'); + expect(postResult).to.include('READ_ONLY mode'); + + const putResult = checkSafetyViolation('PUT', 'https://api.example.com/items/1', config); + expect(putResult).to.include('Write operation blocked'); + + const patchResult = checkSafetyViolation('PATCH', 'https://api.example.com/items/1', config); + expect(patchResult).to.include('Write operation blocked'); + + const deleteResult = checkSafetyViolation('DELETE', 'https://api.example.com/items/1', config); + expect(deleteResult).to.include('Write operation blocked'); + }); + + it('allows GET operations', () => { + const config: SafetyConfig = {level: 'READ_ONLY'}; + + expect(checkSafetyViolation('GET', 'https://api.example.com/items', config)).to.be.undefined; + expect(checkSafetyViolation('GET', 'https://api.example.com/items/1', config)).to.be.undefined; + }); + }); + + describe('allowedPaths whitelist', () => { + it('allows operations to whitelisted paths regardless of safety level', () => { + const config: SafetyConfig = { + level: 'READ_ONLY', + allowedPaths: ['/auth/token', '/health'], + }; + + // DELETE should normally be blocked in READ_ONLY, but allowed for whitelisted paths + expect(checkSafetyViolation('DELETE', 'https://api.example.com/auth/token', config)).to.be.undefined; + expect(checkSafetyViolation('POST', 'https://api.example.com/health', config)).to.be.undefined; + + // Non-whitelisted path should still be blocked + const result = checkSafetyViolation('POST', 'https://api.example.com/items', config); + expect(result).to.include('Write operation blocked'); + }); + + it('matches paths using startsWith (prefix matching)', () => { + const config: SafetyConfig = { + level: 'READ_ONLY', + allowedPaths: ['/auth'], + }; + + // All auth paths should be allowed + expect(checkSafetyViolation('POST', 'https://api.example.com/auth/token', config)).to.be.undefined; + expect(checkSafetyViolation('POST', 'https://api.example.com/auth/refresh', config)).to.be.undefined; + + // Non-auth path should be blocked + const result = checkSafetyViolation('POST', 'https://api.example.com/items', config); + expect(result).to.include('Write operation blocked'); + }); + }); + + describe('blockedPaths blacklist', () => { + it('blocks operations to blacklisted paths regardless of safety level', () => { + const config: SafetyConfig = { + level: 'NONE', + blockedPaths: ['/admin', '/dangerous'], + }; + + // Even with NONE level, blacklisted paths should be blocked + const adminResult = checkSafetyViolation('GET', 'https://api.example.com/admin/users', config); + expect(adminResult).to.include('blocked paths list'); + + const dangerousResult = checkSafetyViolation('GET', 'https://api.example.com/dangerous/operation', config); + expect(dangerousResult).to.include('blocked paths list'); + + // Non-blacklisted path should be allowed (NONE level) + expect(checkSafetyViolation('DELETE', 'https://api.example.com/items/1', config)).to.be.undefined; + }); + + it('matches paths using startsWith (prefix matching)', () => { + const config: SafetyConfig = { + level: 'NONE', + blockedPaths: ['/admin'], + }; + + // All admin paths should be blocked + const result1 = checkSafetyViolation('GET', 'https://api.example.com/admin/users', config); + expect(result1).to.include('blocked paths list'); + + const result2 = checkSafetyViolation('GET', 'https://api.example.com/admin/settings', config); + expect(result2).to.include('blocked paths list'); + + // Non-admin path should be allowed + expect(checkSafetyViolation('GET', 'https://api.example.com/public/data', config)).to.be.undefined; + }); + }); + + describe('allowedPaths takes precedence over blockedPaths', () => { + it('allows whitelisted paths even if they match blacklist', () => { + const config: SafetyConfig = { + level: 'NONE', + allowedPaths: ['/admin/readonly'], + blockedPaths: ['/admin'], + }; + + // Whitelisted path should be allowed despite matching blacklist + expect(checkSafetyViolation('GET', 'https://api.example.com/admin/readonly/data', config)).to.be.undefined; + + // Other admin paths should still be blocked + const result = checkSafetyViolation('GET', 'https://api.example.com/admin/users', config); + expect(result).to.include('blocked paths list'); + }); + }); + + describe('URL parsing', () => { + it('extracts pathname correctly from full URLs', () => { + const config: SafetyConfig = {level: 'NO_DELETE'}; + + const result = checkSafetyViolation( + 'DELETE', + 'https://api.example.com:8080/items/1?query=value#fragment', + config, + ); + + expect(result).to.include('/items/1'); + }); + + it('handles URLs without protocol', () => { + const config: SafetyConfig = {level: 'NO_DELETE'}; + + // Should not throw error + const result = checkSafetyViolation('DELETE', '/items/1', config); + expect(result).to.include('Delete operation blocked'); + }); + }); + }); + + describe('getSafetyLevel', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env['SFCC_SAFETY_LEVEL']; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['SFCC_SAFETY_LEVEL'] = originalEnv; + } else { + delete process.env['SFCC_SAFETY_LEVEL']; + } + }); + + it('returns default level when env var is not set', () => { + delete process.env['SFCC_SAFETY_LEVEL']; + + expect(getSafetyLevel()).to.equal('NONE'); + expect(getSafetyLevel('READ_ONLY')).to.equal('READ_ONLY'); + }); + + it('reads from SFCC_SAFETY_LEVEL environment variable', () => { + process.env['SFCC_SAFETY_LEVEL'] = 'NO_DELETE'; + expect(getSafetyLevel()).to.equal('NO_DELETE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'NO_UPDATE'; + expect(getSafetyLevel()).to.equal('NO_UPDATE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'READ_ONLY'; + expect(getSafetyLevel()).to.equal('READ_ONLY'); + + process.env['SFCC_SAFETY_LEVEL'] = 'NONE'; + expect(getSafetyLevel()).to.equal('NONE'); + }); + + it('is case-insensitive', () => { + process.env['SFCC_SAFETY_LEVEL'] = 'no_delete'; + expect(getSafetyLevel()).to.equal('NO_DELETE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'No_Delete'; + expect(getSafetyLevel()).to.equal('NO_DELETE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'read_only'; + expect(getSafetyLevel()).to.equal('READ_ONLY'); + }); + + it('handles dash separators (converts to underscore)', () => { + process.env['SFCC_SAFETY_LEVEL'] = 'no-delete'; + expect(getSafetyLevel()).to.equal('NO_DELETE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'read-only'; + expect(getSafetyLevel()).to.equal('READ_ONLY'); + }); + + it('supports backward compatibility aliases', () => { + process.env['SFCC_SAFETY_LEVEL'] = 'NO_DESTRUCTIVE'; + expect(getSafetyLevel()).to.equal('NO_UPDATE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'READONLY'; + expect(getSafetyLevel()).to.equal('READ_ONLY'); + + process.env['SFCC_SAFETY_LEVEL'] = 'readonly'; + expect(getSafetyLevel()).to.equal('READ_ONLY'); + }); + + it('returns default level for invalid values', () => { + process.env['SFCC_SAFETY_LEVEL'] = 'invalid-value'; + expect(getSafetyLevel()).to.equal('NONE'); + + process.env['SFCC_SAFETY_LEVEL'] = 'invalid-value'; + expect(getSafetyLevel('READ_ONLY')).to.equal('READ_ONLY'); + }); + + it('ignores empty string', () => { + process.env['SFCC_SAFETY_LEVEL'] = ''; + expect(getSafetyLevel()).to.equal('NONE'); + expect(getSafetyLevel('NO_DELETE')).to.equal('NO_DELETE'); + }); + }); + + describe('describeSafetyLevel', () => { + it('returns description for NONE', () => { + expect(describeSafetyLevel('NONE')).to.equal('No safety restrictions'); + }); + + it('returns description for NO_DELETE', () => { + expect(describeSafetyLevel('NO_DELETE')).to.equal('Delete operations blocked'); + }); + + it('returns description for NO_UPDATE', () => { + const desc = describeSafetyLevel('NO_UPDATE'); + expect(desc).to.include('Destructive operations blocked'); + expect(desc).to.include('delete'); + expect(desc).to.include('reset'); + }); + + it('returns description for READ_ONLY', () => { + const desc = describeSafetyLevel('READ_ONLY'); + expect(desc).to.include('Read-only mode'); + expect(desc).to.include('write operations blocked'); + }); + + it('returns unknown for invalid level', () => { + expect(describeSafetyLevel('INVALID' as SafetyLevel)).to.equal('Unknown safety level'); + }); + }); + + describe('SafetyBlockedError', () => { + it('creates error with correct properties', () => { + const error = new SafetyBlockedError( + 'Test error message', + 'DELETE', + 'https://api.example.com/items/1', + 'NO_DELETE', + ); + + expect(error).to.be.instanceOf(Error); + expect(error).to.be.instanceOf(SafetyBlockedError); + expect(error.name).to.equal('SafetyBlockedError'); + expect(error.message).to.equal('Test error message'); + expect(error.method).to.equal('DELETE'); + expect(error.url).to.equal('https://api.example.com/items/1'); + expect(error.safetyLevel).to.equal('NO_DELETE'); + }); + + it('includes message in error string', () => { + const error = new SafetyBlockedError( + 'Delete operation blocked', + 'DELETE', + 'https://api.example.com/items/1', + 'NO_DELETE', + ); + + expect(error.toString()).to.include('Delete operation blocked'); + }); + + it('is catchable as standard Error', () => { + try { + throw new SafetyBlockedError('Test', 'DELETE', '/test', 'NO_DELETE'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e).to.be.instanceOf(SafetyBlockedError); + } + }); + }); +});