diff --git a/__tests__/bin/vip-defensive-mode-disable.js b/__tests__/bin/vip-defensive-mode-disable.js new file mode 100644 index 000000000..cae5886ff --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-disable.js @@ -0,0 +1,190 @@ +import { defensiveModeDisableCommand } from '../../src/bin/vip-defensive-mode-disable'; +import * as defensiveModeLib from '../../src/lib/api/defensive-mode'; +import * as exit from '../../src/lib/cli/exit'; +import * as prompt from '../../src/lib/cli/prompt'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( _msg => { + throw new Error( 'EXIT DEFENSIVE MODE WITH ERROR' ); +} ); +jest.spyOn( process, 'exit' ).mockImplementation( _code => { + throw new Error( 'EXIT PROCESS' ); +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/defensive-mode', () => ( { + disableDefensiveMode: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/cli/prompt', () => ( { + confirm: jest.fn(), +} ) ); + +describe( 'defensiveModeDisableCommand()', () => { + const opts = { + app: { + id: 123, + name: 'example-app', + }, + env: { + id: 456, + name: 'production', + primaryDomain: { + name: 'example.com', + }, + }, + }; + + beforeEach( jest.clearAllMocks ); + + it( 'should prompt for confirmation before disabling', async () => { + prompt.confirm.mockResolvedValue( true ); + defensiveModeLib.disableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { enabled: false }, + }, + status: 'success', + } ); + + await defensiveModeDisableCommand( [], opts ); + + expect( prompt.confirm ).toHaveBeenCalledWith( "Type 'DISABLE' to confirm:", 'DISABLE' ); + expect( defensiveModeLib.disableDefensiveMode ).toHaveBeenCalledWith( 123, 456 ); + } ); + + it( 'should skip confirmation when --confirm flag is provided', async () => { + defensiveModeLib.disableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { enabled: false }, + }, + status: 'success', + } ); + + await defensiveModeDisableCommand( [], { ...opts, confirm: true } ); + + expect( prompt.confirm ).not.toHaveBeenCalled(); + expect( defensiveModeLib.disableDefensiveMode ).toHaveBeenCalledWith( 123, 456 ); + } ); + + it( 'should cancel operation when user declines confirmation', async () => { + prompt.confirm.mockResolvedValue( false ); + + await expect( defensiveModeDisableCommand( [], opts ) ).rejects.toThrow( 'EXIT PROCESS' ); + + expect( defensiveModeLib.disableDefensiveMode ).not.toHaveBeenCalled(); + expect( process.exit ).toHaveBeenCalledWith( 0 ); + } ); + + it( 'should disable defensive mode and show success message', async () => { + defensiveModeLib.disableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { enabled: false }, + }, + status: 'success', + } ); + + await defensiveModeDisableCommand( [], { ...opts, confirm: true } ); + + expect( defensiveModeLib.disableDefensiveMode ).toHaveBeenCalledWith( 123, 456 ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'disabled' ) ); + + const trackingParams = { + app_id: 123, + command: 'vip defensive-mode disable', + env_id: 456, + }; + + expect( tracker.trackEvent ).toHaveBeenCalledTimes( 2 ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 1, + 'defensive_mode_disable_command_execute', + trackingParams + ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 2, + 'defensive_mode_disable_command_success', + trackingParams + ); + } ); + + it( 'should output JSON format when --format=json', async () => { + const result = { + data: { + statusUpdated: true, + effective: { enabled: false }, + }, + status: 'success', + }; + + defensiveModeLib.disableDefensiveMode.mockResolvedValue( result ); + + await defensiveModeDisableCommand( [], { ...opts, confirm: true, format: 'json' } ); + + expect( console.log ).toHaveBeenCalledWith( JSON.stringify( result, null, 2 ) ); + } ); + + it( 'should show already disabled message when statusUpdated is false', async () => { + defensiveModeLib.disableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: false, + effective: { enabled: false }, + }, + status: 'success', + } ); + + await defensiveModeDisableCommand( [], { ...opts, confirm: true } ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'already disabled' ) ); + } ); + + it( 'should handle API errors and track them', async () => { + const error = new Error( 'Insufficient permissions' ); + defensiveModeLib.disableDefensiveMode.mockRejectedValue( error ); + + await expect( defensiveModeDisableCommand( [], { ...opts, confirm: true } ) ).rejects.toThrow( + 'EXIT DEFENSIVE MODE WITH ERROR' + ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'defensive_mode_disable_command_error', { + app_id: 123, + command: 'vip defensive-mode disable', + env_id: 456, + error: 'Insufficient permissions', + } ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to disable Defensive Mode: Insufficient permissions' + ); + } ); + + it( 'should display warning with domain information', async () => { + prompt.confirm.mockResolvedValue( true ); + defensiveModeLib.disableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { enabled: false }, + }, + status: 'success', + } ); + + await defensiveModeDisableCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'https://example.com' ) ); + } ); +} ); diff --git a/__tests__/bin/vip-defensive-mode-enable.js b/__tests__/bin/vip-defensive-mode-enable.js new file mode 100644 index 000000000..48eda7f62 --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-enable.js @@ -0,0 +1,172 @@ +import { defensiveModeEnableCommand } from '../../src/bin/vip-defensive-mode-enable'; +import * as defensiveModeLib from '../../src/lib/api/defensive-mode'; +import * as exit from '../../src/lib/cli/exit'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( _msg => { + throw new Error( 'EXIT DEFENSIVE MODE WITH ERROR' ); +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/defensive-mode', () => ( { + enableDefensiveMode: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'defensiveModeEnableCommand()', () => { + const opts = { + app: { + id: 123, + name: 'example-app', + }, + env: { + id: 456, + name: 'production', + }, + }; + + beforeEach( jest.clearAllMocks ); + + it( 'should enable defensive mode and show success message', async () => { + defensiveModeLib.enableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + configUpdated: false, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + }, + }, + status: 'success', + } ); + + await defensiveModeEnableCommand( [], opts ); + + expect( defensiveModeLib.enableDefensiveMode ).toHaveBeenCalledWith( 123, 456 ); + expect( console.log ).toHaveBeenCalled(); + + const trackingParams = { + app_id: 123, + command: 'vip defensive-mode enable', + env_id: 456, + }; + + expect( tracker.trackEvent ).toHaveBeenCalledTimes( 2 ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 1, + 'defensive_mode_enable_command_execute', + trackingParams + ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 2, + 'defensive_mode_enable_command_success', + trackingParams + ); + } ); + + it( 'should output JSON format when --format=json', async () => { + const result = { + data: { + statusUpdated: true, + configUpdated: false, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + }, + }, + status: 'success', + }; + + defensiveModeLib.enableDefensiveMode.mockResolvedValue( result ); + + await defensiveModeEnableCommand( [], { ...opts, format: 'json' } ); + + expect( console.log ).toHaveBeenCalledWith( JSON.stringify( result, null, 2 ) ); + } ); + + it( 'should show already enabled message when statusUpdated is false', async () => { + defensiveModeLib.enableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: false, + configUpdated: false, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + }, + }, + status: 'success', + } ); + + await defensiveModeEnableCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'already enabled' ) ); + } ); + + it( 'should handle API errors and track them', async () => { + const error = new Error( 'Insufficient permissions' ); + defensiveModeLib.enableDefensiveMode.mockRejectedValue( error ); + + await expect( defensiveModeEnableCommand( [], opts ) ).rejects.toThrow( + 'EXIT DEFENSIVE MODE WITH ERROR' + ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'defensive_mode_enable_command_error', { + app_id: 123, + command: 'vip defensive-mode enable', + env_id: 456, + error: 'Insufficient permissions', + } ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to enable Defensive Mode: Insufficient permissions' + ); + } ); + + it( 'should display threshold for WordPress sites', async () => { + defensiveModeLib.enableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { + enabled: true, + connectionThresholdPercentage: 85, + }, + }, + status: 'success', + } ); + + await defensiveModeEnableCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '85% PHP workers' ) ); + } ); + + it( 'should display threshold for Node.js sites', async () => { + defensiveModeLib.enableDefensiveMode.mockResolvedValue( { + data: { + statusUpdated: true, + effective: { + enabled: true, + connectionThresholdAbsolute: 100, + }, + }, + status: 'success', + } ); + + await defensiveModeEnableCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( '100 concurrent requests' ) + ); + } ); +} ); diff --git a/__tests__/bin/vip-defensive-mode-status.js b/__tests__/bin/vip-defensive-mode-status.js new file mode 100644 index 000000000..2d45221a2 --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-status.js @@ -0,0 +1,275 @@ +import { defensiveModeStatusCommand } from '../../src/bin/vip-defensive-mode-status'; +import * as defensiveModeLib from '../../src/lib/api/defensive-mode'; +import * as exit from '../../src/lib/cli/exit'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( _msg => { + throw new Error( 'EXIT DEFENSIVE MODE WITH ERROR' ); +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/defensive-mode', () => ( { + getDefensiveMode: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'defensiveModeStatusCommand()', () => { + const opts = { + app: { + id: 123, + name: 'example-app', + }, + env: { + id: 456, + name: 'production', + }, + }; + + beforeEach( jest.clearAllMocks ); + + it( 'should display status when defensive mode is active', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: { + enabled: true, + connectionThresholdPercentage: 85, + }, + effective: { + enabled: true, + connectionThresholdPercentage: 85, + challengeType: 1, + maxRequestRate: 10, + keepEnabledUnderThresholdForSeconds: 300, + priorityBypass: 3, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( defensiveModeLib.getDefensiveMode ).toHaveBeenCalledWith( 123, 456, opts.env ); + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( 'Defensive Mode Status' ) + ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'ACTIVE' ) ); + + const trackingParams = { + app_id: 123, + command: 'vip defensive-mode status', + env_id: 456, + }; + + expect( tracker.trackEvent ).toHaveBeenCalledTimes( 2 ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 1, + 'defensive_mode_status_command_execute', + trackingParams + ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( + 2, + 'defensive_mode_status_command_success', + trackingParams + ); + } ); + + it( 'should display status when defensive mode is inactive', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: null, + effective: { + enabled: false, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'INACTIVE' ) ); + } ); + + it( 'should output JSON format when --format=json', async () => { + const result = { + data: { + stored: null, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + }, + }, + status: 'success', + }; + + defensiveModeLib.getDefensiveMode.mockResolvedValue( result ); + + await defensiveModeStatusCommand( [], { ...opts, format: 'json' } ); + + expect( console.log ).toHaveBeenCalledWith( JSON.stringify( result, null, 2 ) ); + } ); + + it( 'should display WordPress site threshold', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: { + connectionThresholdPercentage: 85, + }, + effective: { + enabled: true, + connectionThresholdPercentage: 85, + challengeType: 1, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '85% PHP workers' ) ); + // Threshold should not show (default) since it's custom + const calls = console.log.mock.calls.flat().join( '\n' ); + expect( calls ).toMatch( /85% PHP workers(?!\s+\(default\))/ ); + } ); + + it( 'should display Node.js site threshold', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: null, + effective: { + enabled: true, + connectionThresholdAbsolute: 100, + challengeType: 1, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( '100 concurrent requests' ) + ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '(default)' ) ); + } ); + + it( 'should display challenge type correctly', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: { + challengeType: 2, + }, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + challengeType: 2, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( 'Interactive Challenge' ) + ); + } ); + + it( 'should display max request rate as unlimited when 0', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: null, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + challengeType: 1, + maxRequestRate: 0, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'Unlimited' ) ); + } ); + + it( 'should display max request rate with value', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: { + maxRequestRate: 15, + }, + effective: { + enabled: true, + connectionThresholdPercentage: 90, + challengeType: 1, + maxRequestRate: 15, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '15 req/s' ) ); + } ); + + it( 'should handle API errors and track them', async () => { + const error = new Error( 'Network timeout' ); + defensiveModeLib.getDefensiveMode.mockRejectedValue( error ); + + await expect( defensiveModeStatusCommand( [], opts ) ).rejects.toThrow( + 'EXIT DEFENSIVE MODE WITH ERROR' + ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'defensive_mode_status_command_error', { + app_id: 123, + command: 'vip defensive-mode status', + env_id: 456, + error: 'Network timeout', + } ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to get Defensive Mode status: Network timeout' + ); + } ); + + it( 'should indicate custom vs default values', async () => { + defensiveModeLib.getDefensiveMode.mockResolvedValue( { + data: { + stored: { + connectionThresholdPercentage: 85, + }, + effective: { + enabled: true, + connectionThresholdPercentage: 85, + challengeType: 1, + maxRequestRate: 10, + keepEnabledUnderThresholdForSeconds: 300, + priorityBypass: 3, + }, + }, + status: 'success', + } ); + + await defensiveModeStatusCommand( [], opts ); + + // Custom threshold should not show (default) + const calls = console.log.mock.calls.flat().join( '\n' ); + expect( calls ).toMatch( /85% PHP workers(?!\s+\(default\))/ ); + // Default values should show (default) + expect( calls ).toContain( 'Proof of Work (default)' ); + } ); +} ); diff --git a/docs/defensive-mode.md b/docs/defensive-mode.md new file mode 100644 index 000000000..0f85de947 --- /dev/null +++ b/docs/defensive-mode.md @@ -0,0 +1,527 @@ +# Defensive Mode CLI Commands + +**Commands**: `vip defensive-mode enable`, `vip defensive-mode disable`, `vip defensive-mode status` +**Available in**: VIP-CLI 3.25.1+ +**Minimum Role**: Org Admin or App Admin (for enable/disable) + +## Overview + +Defensive Mode is VIP's bot and DDoS protection system that automatically detects and blocks malicious traffic at the edge. These CLI commands allow you to automate Defensive Mode management across your VIP environments. + +**What is Defensive Mode?** + +- Automatically detects bot and DDoS attack patterns +- Challenges suspicious requests before they reach your application +- Reduces server load during attacks +- Configurable threshold and challenge types + +**Use Cases**: + +- Enable protection before planned traffic spikes +- Automate protection for multiple sites +- Integrate with CI/CD pipelines +- Quick response to ongoing attacks + +## Commands + +### `vip defensive-mode enable` + +Enable bot and DDoS protection for an environment. + +**Syntax**: + +```bash +vip @app.env defensive-mode enable [options] +``` + +**Options**: + +- `--format=json` - Output results in JSON format (for automation) + +**Examples**: + +Enable defensive mode for a single environment: + +```bash +vip @example-app.production defensive-mode enable +``` + +Output: + +``` +✓ Defensive Mode enabled for example-app (production) + +Status: ACTIVE +Threshold: 90% PHP workers +``` + +Enable with JSON output for automation: + +```bash +vip @example-app.production defensive-mode enable --format=json +``` + +Output: + +```json +{ + "data": { + "statusUpdated": true, + "configUpdated": false, + "effective": { + "enabled": true, + "connectionThresholdPercentage": 90, + "challengeType": 1, + "maxRequestRate": 10, + "priorityBypass": 3 + } + }, + "status": "success" +} +``` + +**Exit Codes**: + +- `0` - Success +- `1` - General error (API failure, network issue) +- `2` - Permission denied + +--- + +### `vip defensive-mode disable` + +Disable bot and DDoS protection for an environment. + +**Syntax**: + +```bash +vip @app.env defensive-mode disable [options] +``` + +**Options**: + +- `--confirm` - Skip confirmation prompt (for automation) +- `--format=json` - Output results in JSON format + +**Interactive Mode** (default): + +```bash +vip @example-app.production defensive-mode disable +``` + +Output: + +``` +⚠ Warning +You are about to disable Defensive Mode for example-app (production) +This will remove bot/DDoS protection from https://example.com + +Type 'DISABLE' to confirm: _ +``` + +After typing "DISABLE": + +``` +✓ Defensive Mode disabled for example-app (production) + +Status: INACTIVE +``` + +**Non-Interactive Mode** (automation): + +```bash +vip @example-app.production defensive-mode disable --confirm +``` + +⚠️ **Warning**: Use `--confirm` carefully. Disabling protection during an active attack can cause site outages. + +**Exit Codes**: + +- `0` - Success or cancelled by user +- `1` - General error +- `2` - Permission denied + +--- + +### `vip defensive-mode status` + +Display current Defensive Mode configuration and status. + +**Syntax**: + +```bash +vip @app.env defensive-mode status [options] +``` + +**Options**: + +- `--format=json` - Output results in JSON format + +**Example** (Active): + +```bash +vip @example-app.production defensive-mode status +``` + +Output: + +``` +Defensive Mode Status: example-app (production) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Status: ACTIVE + +Configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Threshold: 90% PHP workers (default) +Challenge: Proof of Work (default) +Max Rate: 10 req/s per client (default) +Hysteresis: 300s (default) +Priority Bypass: Level 3 (default) +``` + +**Example** (Inactive): + +```bash +vip @example-app.develop defensive-mode status +``` + +Output: + +``` +Defensive Mode Status: example-app (develop) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Status: INACTIVE +``` + +**JSON Output**: + +```bash +vip @example-app.production defensive-mode status --format=json +``` + +Output: + +```json +{ + "data": { + "stored": null, + "effective": { + "enabled": true, + "connectionThresholdPercentage": 90, + "challengeType": 1, + "maxRequestRate": 10, + "keepEnabledUnderThresholdForSeconds": 300, + "priorityBypass": 3 + } + }, + "status": "success" +} +``` + +--- + +## Configuration Fields + +When viewing status, you may see these configuration fields: + +### Threshold + +**WordPress Sites**: + +- Shown as: "XX% PHP workers" +- Meaning: Defensive Mode activates when PHP worker usage exceeds this percentage +- Default: 90% + +**Node.js Sites**: + +- Shown as: "XXX concurrent requests" +- Meaning: Defensive Mode activates when concurrent requests exceed this number +- Default: 100 + +### Challenge Type + +The type of challenge presented to suspicious clients: + +- **Proof of Work** (default): Client must solve a computational puzzle +- **Interactive Challenge**: Client must complete a CAPTCHA-like challenge + +### Max Request Rate + +Maximum requests per second allowed from a single client: + +- **Unlimited**: No per-client rate limit +- **XX req/s per client**: Specific rate limit + +### Hysteresis + +Duration (in seconds) that Defensive Mode remains active after traffic drops below threshold: + +- Prevents rapid toggling during fluctuating attack patterns +- Default: 300 seconds (5 minutes) + +### Priority Bypass + +Priority level for bypass rules (1-3): + +- Affects which requests can bypass Defensive Mode challenges +- Default: 3 + +--- + +## Bulk Operations + +To manage multiple environments, use shell scripting: + +**Example: Enable for Multiple Sites** + +```bash +#!/bin/bash +# bulk-enable-defensive-mode.sh + +set -euo pipefail + +ENVIRONMENTS=( + "app1.production" + "app2.production" + "app3.production" +) + +for env in "${ENVIRONMENTS[@]}"; do + echo "Enabling Defensive Mode for $env..." + + if vip @"$env" defensive-mode enable --format=json > /dev/null; then + echo "✓ Success: $env" + else + echo "✗ Failed: $env" + fi + + # Prevent rate limiting + sleep 2 +done +``` + +**Example: Check Status Across Sites** + +```bash +#!/bin/bash +# check-defensive-mode-status.sh + +while IFS= read -r env; do + STATUS=$(vip @"$env" defensive-mode status --format=json 2>/dev/null) + ENABLED=$(echo "$STATUS" | jq -r '.data.effective.enabled') + + if [ "$ENABLED" == "true" ]; then + echo "✓ $env: ENABLED" + else + echo "✗ $env: DISABLED" + fi +done < environments.txt +``` + +--- + +## CI/CD Integration + +**GitHub Actions Example**: + +```yaml +name: Enable Defensive Mode Before Deploy + +on: + push: + branches: [main] + +jobs: + deploy-with-protection: + runs-on: ubuntu-latest + steps: + - name: Install VIP-CLI + run: npm install -g @automattic/vip + + - name: Authenticate + env: + VIP_TOKEN: ${{ secrets.VIP_TOKEN }} + run: echo "$VIP_TOKEN" | vip login --token + + - name: Enable Defensive Mode + run: | + vip @myapp.production defensive-mode enable --format=json + + - name: Deploy Application + run: vip @myapp.production app deploy ./build.tar.gz + + - name: Verify Defensive Mode Active + run: | + STATUS=$(vip @myapp.production defensive-mode status --format=json) + ENABLED=$(echo "$STATUS" | jq -r '.data.effective.enabled') + if [ "$ENABLED" != "true" ]; then + echo "ERROR: Defensive Mode not active!" + exit 1 + fi +``` + +--- + +## Error Messages + +### Permission Denied + +``` +Error: Insufficient permissions to manage Defensive Mode + +Required role: Org Admin or App Admin +Your current role: App Write + +To resolve: Contact your organization admin to upgrade your role +``` + +**Solution**: Request Org Admin or App Admin role from your organization administrator. + +### App or Environment Not Found + +``` +Error: Application not found: example-app +``` + +**Solution**: Verify the app name is correct using `vip app list`. + +### Network or API Error + +``` +Error: Failed to enable Defensive Mode: API request timed out (network timeout after 30s) + +Suggestions: + 1. Verify your network connection + 2. Try again in a few moments + 3. Check VIP status: https://status.wpvip.com/ +``` + +**Solution**: Check network connection and retry. If problem persists, check VIP status page. + +--- + +## Permissions + +**To view status** (read-only): + +- Any authenticated VIP user + +**To enable or disable** (write): + +- Organization Admin +- Application Admin + +Check your current role: + +```bash +vip whoami +``` + +--- + +## Best Practices + +### Production Environments + +✅ **DO**: + +- Enable Defensive Mode before expected traffic spikes +- Test automation scripts in non-production environments first +- Use `status` command to check current state before making changes +- Monitor audit logs for unexpected changes +- Keep confirmation prompts enabled for manual operations + +❌ **DON'T**: + +- Disable protection during active attacks without careful assessment +- Use `--confirm` flag in interactive terminal sessions +- Toggle defensive mode rapidly (can trigger rate limits) +- Share automation scripts containing `--confirm` without warnings + +### Automation Scripts + +✅ **DO**: + +- Add `sleep 2` between operations to avoid rate limits +- Check status before making changes (idempotent operations) +- Use `--format=json` for reliable parsing +- Handle errors gracefully (use `set -euo pipefail`) +- Log all operations for audit trail + +❌ **DON'T**: + +- Run parallel operations against the same environment +- Ignore exit codes in scripts +- Use `--confirm` without understanding the risks + +--- + +## Troubleshooting + +### Command Not Found + +```bash +bash: vip: command not found +``` + +**Solution**: Install VIP-CLI: + +```bash +npm install -g @automattic/vip +``` + +### Authentication Required + +```bash +Error: No authentication token found +``` + +**Solution**: Log in to VIP: + +```bash +vip login +``` + +### Rate Limit Exceeded + +```bash +Error: Too many requests (429) +``` + +**Solution**: Wait 1-2 minutes, then retry. Add delays (`sleep 2`) between operations in scripts. + +--- + +## Related Commands + +- `vip app list` - List your VIP applications +- `vip whoami` - Check your current role and permissions +- `vip logout` - Log out of VIP-CLI + +--- + +## Support + +For help with Defensive Mode: + +- Dashboard: https://dashboard.wpvip.com/ +- VIP Support: Create a ticket in the Dashboard +- Documentation: https://docs.wpvip.com/ + +For CLI issues: + +- GitHub: https://github.com/Automattic/vip-cli/issues +- Slack: #vip-cli (Automattic internal) + +--- + +## Changelog + +### 3.23.0 (2026-03-03) + +- Initial release of defensive-mode commands +- `enable`, `disable`, and `status` subcommands +- JSON output support for automation +- Interactive confirmation for disable operations diff --git a/package.json b/package.json index 6c22a3a3b..7f4ca7326 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "vip-config-software-update": "dist/bin/vip-config-software-update.js", "vip-db": "dist/bin/vip-db.js", "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js", + "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", + "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", + "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", + "vip-defensive-mode-status": "dist/bin/vip-defensive-mode-status.js", "vip-dev-env": "dist/bin/vip-dev-env.js", "vip-dev-env-create": "dist/bin/vip-dev-env-create.js", "vip-dev-env-update": "dist/bin/vip-dev-env-update.js", diff --git a/src/bin/vip-defensive-mode-disable.js b/src/bin/vip-defensive-mode-disable.js new file mode 100755 index 000000000..7ea7dd452 --- /dev/null +++ b/src/bin/vip-defensive-mode-disable.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import { appQuery, disableDefensiveMode } from '../lib/api/defensive-mode'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { confirm } from '../lib/cli/prompt'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip defensive-mode disable'; +const exampleUsage = 'vip @example-app.develop defensive-mode disable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Disable Defensive Mode for the specified environment (interactive).', + }, + { + usage: `${ exampleUsage } --confirm`, + description: 'Disable Defensive Mode without confirmation prompt (for automation).', + }, + { + usage: `${ exampleUsage } --format=json`, + description: 'Disable Defensive Mode with JSON output.', + }, +]; + +export async function defensiveModeDisableCommand( arg, opt = {} ) { + const trackingParams = { + app_id: opt.app.id, + command: 'vip defensive-mode disable', + env_id: opt.env.id, + }; + + await trackEvent( 'defensive_mode_disable_command_execute', trackingParams ); + + // Confirmation prompt (unless --confirm flag is provided) + if ( ! opt.confirm ) { + const primaryDomain = opt.env.primaryDomain?.name || opt.app.name; + console.log( chalk.yellow( '⚠ Warning' ) ); + console.log( + `You are about to disable Defensive Mode for ${ chalk.bold( opt.app.name ) } (${ chalk.bold( + opt.env.name + ) })` + ); + console.log( `This will remove bot/DDoS protection from https://${ primaryDomain }` ); + console.log(); + + const confirmed = await confirm( "Type 'DISABLE' to confirm:", 'DISABLE' ); + + if ( ! confirmed ) { + console.log( chalk.red( 'Operation cancelled.' ) ); + process.exit( 0 ); + } + } + + let result; + try { + result = await disableDefensiveMode( opt.app.id, opt.env.id ); + } catch ( err ) { + await trackEvent( 'defensive_mode_disable_command_error', { + ...trackingParams, + error: err.message, + } ); + + exit.withError( `Failed to disable Defensive Mode: ${ err.message }` ); + } + + await trackEvent( 'defensive_mode_disable_command_success', trackingParams ); + + // JSON output format + if ( opt.format === 'json' ) { + console.log( JSON.stringify( result, null, 2 ) ); + return; + } + + // Table output format (default) + const wasDisabled = result.data.statusUpdated === false; + const envName = opt.env.type || opt.env.name; + + console.log(); + console.log( + chalk.green( + `✓ Defensive Mode ${ wasDisabled ? 'already ' : '' }disabled for ${ + opt.app.name + } (${ envName })` + ) + ); + console.log(); + console.log( `Status: ${ chalk.bold( 'INACTIVE' ) }` ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'confirm', 'Skip confirmation prompt (for automation)' ) + .option( 'format', 'Output format: table (default) or json' ) + .examples( examples ) + .argv( process.argv, defensiveModeDisableCommand ); diff --git a/src/bin/vip-defensive-mode-enable.js b/src/bin/vip-defensive-mode-enable.js new file mode 100755 index 000000000..d31218180 --- /dev/null +++ b/src/bin/vip-defensive-mode-enable.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import { appQuery, enableDefensiveMode } from '../lib/api/defensive-mode'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip defensive-mode enable'; +const exampleUsage = 'vip @example-app.develop defensive-mode enable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Enable Defensive Mode for the specified environment.', + }, + { + usage: `${ exampleUsage } --format=json`, + description: 'Enable Defensive Mode with JSON output (for automation).', + }, +]; + +export async function defensiveModeEnableCommand( arg, opt = {} ) { + const trackingParams = { + app_id: opt.app.id, + command: 'vip defensive-mode enable', + env_id: opt.env.id, + }; + + await trackEvent( 'defensive_mode_enable_command_execute', trackingParams ); + + let result; + try { + result = await enableDefensiveMode( opt.app.id, opt.env.id ); + } catch ( err ) { + await trackEvent( 'defensive_mode_enable_command_error', { + ...trackingParams, + error: err.message, + } ); + + exit.withError( `Failed to enable Defensive Mode: ${ err.message }` ); + } + + await trackEvent( 'defensive_mode_enable_command_success', trackingParams ); + + // JSON output format + if ( opt.format === 'json' ) { + console.log( JSON.stringify( result, null, 2 ) ); + return; + } + + // Table output format (default) + const config = result.data.effective; + const wasEnabled = result.data.statusUpdated === false; + const envName = opt.env.type || opt.env.name; + + console.log( + chalk.green( + `✓ Defensive Mode ${ wasEnabled ? 'already ' : '' }enabled for ${ + opt.app.name + } (${ envName })` + ) + ); + console.log(); + console.log( `Status: ${ chalk.bold( 'ACTIVE' ) }` ); + + if ( config.connectionThresholdPercentage !== undefined ) { + console.log( `Threshold: ${ config.connectionThresholdPercentage }% PHP workers` ); + } else if ( config.connectionThresholdAbsolute !== undefined ) { + console.log( `Threshold: ${ config.connectionThresholdAbsolute } concurrent requests` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'format', 'Output format: table (default) or json' ) + .examples( examples ) + .argv( process.argv, defensiveModeEnableCommand ); diff --git a/src/bin/vip-defensive-mode-status.js b/src/bin/vip-defensive-mode-status.js new file mode 100755 index 000000000..936503774 --- /dev/null +++ b/src/bin/vip-defensive-mode-status.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import { appQuery, getDefensiveMode } from '../lib/api/defensive-mode'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip defensive-mode status'; +const exampleUsage = 'vip @example-app.develop defensive-mode status'; + +const examples = [ + { + usage: exampleUsage, + description: 'Display current Defensive Mode status and configuration.', + }, + { + usage: `${ exampleUsage } --format=json`, + description: 'Display status in JSON format (for automation).', + }, +]; + +// eslint-disable-next-line complexity +export async function defensiveModeStatusCommand( arg, opt = {} ) { + const trackingParams = { + app_id: opt.app.id, + command: 'vip defensive-mode status', + env_id: opt.env.id, + }; + + await trackEvent( 'defensive_mode_status_command_execute', trackingParams ); + + let result; + try { + result = await getDefensiveMode( opt.app.id, opt.env.id, opt.env ); + } catch ( err ) { + await trackEvent( 'defensive_mode_status_command_error', { + ...trackingParams, + error: err.message, + } ); + + exit.withError( `Failed to get Defensive Mode status: ${ err.message }` ); + } + + await trackEvent( 'defensive_mode_status_command_success', trackingParams ); + + // JSON output format + if ( opt.format === 'json' ) { + console.log( JSON.stringify( result, null, 2 ) ); + return; + } + + // Table output format (default) + const config = result.data.effective; + const stored = result.data.stored; + + const envName = opt.env.type || opt.env.name; + console.log( chalk.bold( `Defensive Mode Status: ${ opt.app.name } (${ envName })` ) ); + console.log( '━'.repeat( 60 ) ); + console.log(); + + // Status + const statusColor = config.enabled ? chalk.green : chalk.gray; + console.log( `Status: ${ statusColor( config.enabled ? 'ACTIVE' : 'INACTIVE' ) }` ); + + // Configuration details + if ( config.enabled ) { + console.log(); + console.log( chalk.bold( 'Configuration' ) ); + console.log( '━'.repeat( 60 ) ); + + // Threshold (WordPress vs Node.js) - only show the one that's actually set + if ( config.connectionThresholdPercentage !== undefined ) { + const isCustom = stored?.connectionThresholdPercentage !== undefined; + console.log( + `Threshold: ${ config.connectionThresholdPercentage }% PHP workers${ + isCustom ? '' : ' (default)' + }` + ); + } else if ( config.connectionThresholdAbsolute !== undefined ) { + const isCustom = stored?.connectionThresholdAbsolute !== undefined; + console.log( + `Threshold: ${ config.connectionThresholdAbsolute } concurrent requests${ + isCustom ? '' : ' (default)' + }` + ); + } + + // Challenge type + const challengeTypes = { + 1: 'Proof of Work', + 2: 'Interactive Challenge', + }; + const challengeLabel = challengeTypes[ config.challengeType ] || 'Unknown'; + const isCustomChallenge = stored?.challengeType !== undefined; + console.log( `Challenge: ${ challengeLabel }${ isCustomChallenge ? '' : ' (default)' }` ); + + // Max request rate + if ( config.maxRequestRate !== undefined ) { + const rateLabel = + config.maxRequestRate === 0 ? 'Unlimited' : `${ config.maxRequestRate } req/s per client`; + const isCustomRate = stored?.maxRequestRate !== undefined; + console.log( `Max Rate: ${ rateLabel }${ isCustomRate ? '' : ' (default)' }` ); + } + + // Hysteresis + if ( config.keepEnabledUnderThresholdForSeconds !== undefined ) { + const isCustomHysteresis = stored?.keepEnabledUnderThresholdForSeconds !== undefined; + console.log( + `Hysteresis: ${ config.keepEnabledUnderThresholdForSeconds }s${ + isCustomHysteresis ? '' : ' (default)' + }` + ); + } + + // Priority bypass + if ( config.priorityBypass !== undefined ) { + const isCustomPriority = stored?.priorityBypass !== undefined; + console.log( + `Priority Bypass: Level ${ config.priorityBypass }${ isCustomPriority ? '' : ' (default)' }` + ); + } + + // Auto-disable + if ( config.disableAtEpoch && config.disableAtEpoch > 0 ) { + const disableDate = new Date( config.disableAtEpoch * 1000 ); + console.log( `Auto-disable: ${ disableDate.toISOString() }` ); + } + } + + console.log(); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'format', 'Output format: table (default) or json' ) + .examples( examples ) + .argv( process.argv, defensiveModeStatusCommand ); diff --git a/src/bin/vip-defensive-mode.js b/src/bin/vip-defensive-mode.js new file mode 100755 index 000000000..47ca6b8c0 --- /dev/null +++ b/src/bin/vip-defensive-mode.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; + +const usage = 'vip defensive-mode'; +const exampleUsage = 'vip @example-app.develop defensive-mode'; + +const examples = [ + { + usage: `${ exampleUsage } enable`, + description: 'Enable Defensive Mode bot/DDoS protection.', + }, + { + usage: `${ exampleUsage } disable`, + description: 'Disable Defensive Mode bot/DDoS protection.', + }, + { + usage: `${ exampleUsage } status`, + description: 'Display current Defensive Mode status and statistics.', + }, +]; + +command( { + requiredArgs: 1, + usage, +} ) + .command( 'enable', 'Enable Defensive Mode bot/DDoS protection.' ) + .command( 'disable', 'Disable Defensive Mode bot/DDoS protection.' ) + .command( 'status', 'Display current Defensive Mode status and statistics.' ) + .examples( examples ) + .argv( process.argv ); diff --git a/src/bin/vip.js b/src/bin/vip.js index a991da695..8acf7fdde 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -35,6 +35,8 @@ const runCmd = async function () { .command( 'backup', 'Generate a backup of an environment.' ) .command( 'cache', 'Manage page cache for an environment.' ) .command( 'config', 'Manage environment configurations.' ) + .command( 'db', "Access an environment's database." ) + .command( 'defensive-mode', 'Manage bot and DDoS protection for an environment.' ) .command( 'dev-env', 'Create and manage VIP Local Development Environments.' ) .command( 'export', 'Export a copy of data associated with an environment.' ) .command( 'import', 'Import media or SQL database files to an environment.' ) @@ -44,7 +46,6 @@ const runCmd = async function () { 'Search for a string in a local SQL file and replace it with a new string.' ) .command( 'slowlogs', 'Retrieve MySQL slow query logs from an environment.' ) - .command( 'db', "Access an environment's database." ) .command( 'sync', 'Sync the database from production to a non-production environment.' ) .command( 'whoami', 'Retrieve details about the current authenticated VIP-CLI user.' ) .command( 'wp', 'Execute a WP-CLI command against an environment.' ); diff --git a/src/lib/api/defensive-mode.ts b/src/lib/api/defensive-mode.ts new file mode 100644 index 000000000..c6cf2de96 --- /dev/null +++ b/src/lib/api/defensive-mode.ts @@ -0,0 +1,299 @@ +import gql from 'graphql-tag'; + +import API from '../../lib/api'; + +// GraphQL query for app and environment data +export const appQuery = ` + id + name + environments { + id + appId + name + primaryDomain { + name + } + type + defensiveMode { + config { + stored { + enabled + disableAtEpoch + connectionThresholdPercentage + connectionThresholdAbsolute + keepEnabledUnderThresholdForSeconds + challengeType + maxRequestRate + priorityBypass + } + effective { + enabled + disableAtEpoch + connectionThresholdPercentage + connectionThresholdAbsolute + keepEnabledUnderThresholdForSeconds + challengeType + maxRequestRate + priorityBypass + } + } + } + } +`; + +// Types for Defensive Mode configuration +export interface DefensiveModeConfig { + enabled: boolean; + disableAtEpoch?: number; + connectionThresholdPercentage?: number; + connectionThresholdAbsolute?: number; + keepEnabledUnderThresholdForSeconds?: number; + challengeType?: number; + maxRequestRate?: number; + priorityBypass?: number; +} + +export interface DefensiveModeResponse { + data: { + stored: DefensiveModeConfig | null; + effective: DefensiveModeConfig; + statusUpdated?: boolean; + configUpdated?: boolean; + updates?: Record< string, { from: unknown; to: unknown } >; + }; + status: string; +} + +// GraphQL response types +interface DefensiveModeGraphQLConfig { + stored: DefensiveModeConfig | null; + effective: DefensiveModeConfig; +} + +interface DefensiveModeQueryResponse { + app?: { + environments?: Array< { + defensiveMode?: { + config: DefensiveModeGraphQLConfig; + }; + } >; + }; +} + +interface UpdateDefensiveModeConfigResponse { + updateDefensiveModeConfig?: { + success: boolean; + message?: string; + }; +} + +interface UpdateDefensiveModeStatusResponse { + updateDefensiveModeStatus?: { + success: boolean; + message?: string; + }; +} + +// Environment data type +interface EnvironmentData { + defensiveMode?: { + config: DefensiveModeGraphQLConfig; + }; +} + +/** + * Get current Defensive Mode configuration for an environment + * Note: Data is fetched via appQuery and passed through opt.env.defensiveMode + */ +export async function getDefensiveMode( + appId: number, + envId: number, + envData?: EnvironmentData +): Promise< DefensiveModeResponse > { + // If defensiveMode data was already loaded via appQuery, use it + if ( envData?.defensiveMode?.config ) { + return { + data: envData.defensiveMode.config, + status: 'success', + }; + } + + // Otherwise, query GraphQL directly + const query = gql` + query GetDefensiveMode($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + defensiveMode { + config { + stored { + enabled + disableAtEpoch + connectionThresholdPercentage + connectionThresholdAbsolute + keepEnabledUnderThresholdForSeconds + challengeType + maxRequestRate + priorityBypass + } + effective { + enabled + disableAtEpoch + connectionThresholdPercentage + connectionThresholdAbsolute + keepEnabledUnderThresholdForSeconds + challengeType + maxRequestRate + priorityBypass + } + } + } + } + } + } + `; + + const api = API(); + const response = await api.query< DefensiveModeQueryResponse >( { + query, + variables: { appId, envId }, + } ); + + if ( ! response.data?.app?.environments?.[ 0 ]?.defensiveMode ) { + throw new Error( 'Failed to get defensive mode status' ); + } + + return { + data: response.data.app.environments[ 0 ].defensiveMode.config, + status: 'success', + }; +} + +/** + * Update Defensive Mode configuration for an environment + */ +export async function updateDefensiveMode( + appId: number, + envId: number, + config: Partial< DefensiveModeConfig > +): Promise< DefensiveModeResponse > { + const mutation = gql` + mutation UpdateDefensiveModeConfig($input: AppEnvironmentDefensiveModeConfigInput!) { + updateDefensiveModeConfig(input: $input) { + success + message + } + } + `; + + const api = API(); + const response = await api.mutate< UpdateDefensiveModeConfigResponse >( { + mutation, + variables: { + input: { + id: appId, + environmentId: envId, + ...config, + }, + }, + } ); + + if ( ! response.data?.updateDefensiveModeConfig?.success ) { + const message = + response.data?.updateDefensiveModeConfig?.message || 'Failed to update defensive mode'; + if ( message.includes( 'permission' ) ) { + throw new Error( + 'Insufficient permissions to manage Defensive Mode. Required role: Org Admin or App Admin' + ); + } + throw new Error( message ); + } + + // Query for updated config + const updatedConfig = await getDefensiveMode( appId, envId ); + + // Calculate if status was updated + const statusUpdated = config.enabled !== undefined; + + return { + data: { + statusUpdated, + configUpdated: true, + stored: updatedConfig.data.stored, + effective: updatedConfig.data.effective, + }, + status: 'success', + }; +} + +/** + * Internal helper to update defensive mode status + */ +async function updateDefensiveModeStatus( + appId: number, + envId: number, + enabled: boolean, + operationName: string +): Promise< DefensiveModeResponse > { + const mutation = gql` + mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput!) { + updateDefensiveModeStatus(input: $input) { + success + message + } + } + `; + + const api = API(); + const response = await api.mutate< UpdateDefensiveModeStatusResponse >( { + mutation, + variables: { + input: { + id: appId, + environmentId: envId, + enabled, + }, + }, + } ); + + if ( ! response.data?.updateDefensiveModeStatus?.success ) { + const message = response.data?.updateDefensiveModeStatus?.message || operationName; + if ( message.includes( 'permission' ) ) { + throw new Error( + 'Insufficient permissions to manage Defensive Mode. Required role: Org Admin or App Admin' + ); + } + throw new Error( message ); + } + + const updatedConfig = await getDefensiveMode( appId, envId ); + + return { + data: { + statusUpdated: true, + configUpdated: false, + stored: updatedConfig.data.stored, + effective: updatedConfig.data.effective, + }, + status: 'success', + }; +} + +/** + * Enable Defensive Mode for an environment + */ +export async function enableDefensiveMode( + appId: number, + envId: number +): Promise< DefensiveModeResponse > { + return updateDefensiveModeStatus( appId, envId, true, 'Failed to enable defensive mode' ); +} + +/** + * Disable Defensive Mode for an environment + */ +export async function disableDefensiveMode( + appId: number, + envId: number +): Promise< DefensiveModeResponse > { + return updateDefensiveModeStatus( appId, envId, false, 'Failed to disable defensive mode' ); +}