mcp-maestro-mobile-ai 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1200 @@
1
+ /**
2
+ * Security Utility Module
3
+ *
4
+ * Implements security boundaries for the MCP Maestro Mobile AI server.
5
+ * Includes Safe Mode, command allowlists, blocked operations, and input validation.
6
+ *
7
+ * @module security
8
+ * @version 1.0.0
9
+ */
10
+
11
+ import { logger } from "./logger.js";
12
+
13
+ // ============================================
14
+ // CONSTANTS & CONFIGURATION
15
+ // ============================================
16
+
17
+ /**
18
+ * Security modes available
19
+ */
20
+ export const SecurityMode = {
21
+ SAFE: "safe",
22
+ FULL: "full",
23
+ };
24
+
25
+ /**
26
+ * Security error codes
27
+ */
28
+ export const SecurityErrorCode = {
29
+ SAFE_MODE_VIOLATION: "SAFE_MODE_VIOLATION",
30
+ BLOCKED_COMMAND: "BLOCKED_COMMAND",
31
+ BLOCKED_PATTERN: "BLOCKED_PATTERN",
32
+ INVALID_INPUT: "INVALID_INPUT",
33
+ UNAUTHORIZED_OPERATION: "UNAUTHORIZED_OPERATION",
34
+ };
35
+
36
+ // ============================================
37
+ // SECURITY ERROR CLASS
38
+ // ============================================
39
+
40
+ /**
41
+ * Custom error class for security violations
42
+ */
43
+ export class SecurityError extends Error {
44
+ /**
45
+ * Create a SecurityError
46
+ * @param {string} message - Error message
47
+ * @param {string} code - Error code from SecurityErrorCode
48
+ * @param {object} details - Additional details about the error
49
+ */
50
+ constructor(
51
+ message,
52
+ code = SecurityErrorCode.UNAUTHORIZED_OPERATION,
53
+ details = {}
54
+ ) {
55
+ super(message);
56
+ this.name = "SecurityError";
57
+ this.code = code;
58
+ this.details = details;
59
+ this.timestamp = new Date().toISOString();
60
+
61
+ // Log security event
62
+ logger.warn(`Security violation: ${code}`, {
63
+ message,
64
+ code,
65
+ details,
66
+ timestamp: this.timestamp,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Convert error to JSON for API responses
72
+ */
73
+ toJSON() {
74
+ return {
75
+ error: this.name,
76
+ message: this.message,
77
+ code: this.code,
78
+ details: this.details,
79
+ timestamp: this.timestamp,
80
+ };
81
+ }
82
+ }
83
+
84
+ // ============================================
85
+ // SAFE MODE FUNCTIONS
86
+ // ============================================
87
+
88
+ /**
89
+ * Check if Safe Mode is enabled
90
+ * Safe Mode is ON by default for security
91
+ *
92
+ * @returns {boolean} True if Safe Mode is enabled
93
+ */
94
+ export function isSafeModeEnabled() {
95
+ const safeMode = process.env.SAFE_MODE;
96
+
97
+ // Safe Mode is ON by default (secure by default)
98
+ // Only disable if explicitly set to 'false'
99
+ if (safeMode === "false" || safeMode === "0") {
100
+ return false;
101
+ }
102
+
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Get the current security mode
108
+ *
109
+ * @returns {string} Current security mode ('safe' or 'full')
110
+ */
111
+ export function getSecurityMode() {
112
+ return isSafeModeEnabled() ? SecurityMode.SAFE : SecurityMode.FULL;
113
+ }
114
+
115
+ /**
116
+ * Get security configuration summary
117
+ *
118
+ * @returns {object} Security configuration details
119
+ */
120
+ export function getSecurityConfig() {
121
+ return {
122
+ safeMode: isSafeModeEnabled(),
123
+ mode: getSecurityMode(),
124
+ logSecurityEvents: process.env.LOG_SECURITY_EVENTS !== "false",
125
+ };
126
+ }
127
+
128
+ // ============================================
129
+ // INPUT SANITIZATION
130
+ // ============================================
131
+
132
+ /**
133
+ * Sanitize a string input by removing dangerous characters
134
+ *
135
+ * @param {string} input - Input string to sanitize
136
+ * @returns {string} Sanitized string
137
+ */
138
+ export function sanitizeInput(input) {
139
+ if (typeof input !== "string") {
140
+ return input;
141
+ }
142
+
143
+ // Remove null bytes
144
+ let sanitized = input.replace(/\0/g, "");
145
+
146
+ // Remove control characters (except newlines and tabs)
147
+ // Using character code range to avoid linter issues with control chars
148
+ sanitized = sanitized
149
+ .split("")
150
+ .filter((char) => {
151
+ const code = char.charCodeAt(0);
152
+ // Allow printable ASCII, newlines (\n = 10), carriage return (\r = 13), and tabs (\t = 9)
153
+ return (
154
+ code === 9 ||
155
+ code === 10 ||
156
+ code === 13 ||
157
+ (code >= 32 && code <= 126) ||
158
+ code > 127
159
+ );
160
+ })
161
+ .join("");
162
+
163
+ return sanitized;
164
+ }
165
+
166
+ /**
167
+ * Sanitize a file path
168
+ *
169
+ * @param {string} filePath - File path to sanitize
170
+ * @returns {string} Sanitized file path
171
+ * @throws {SecurityError} If path contains dangerous patterns
172
+ */
173
+ export function sanitizeFilePath(filePath) {
174
+ if (typeof filePath !== "string") {
175
+ throw new SecurityError(
176
+ "File path must be a string",
177
+ SecurityErrorCode.INVALID_INPUT,
178
+ { received: typeof filePath }
179
+ );
180
+ }
181
+
182
+ // Normalize path separators
183
+ let sanitized = filePath.replace(/\\/g, "/");
184
+
185
+ // Remove null bytes
186
+ sanitized = sanitized.replace(/\0/g, "");
187
+
188
+ // Check for path traversal attempts
189
+ if (sanitized.includes("..")) {
190
+ throw new SecurityError(
191
+ "Path traversal not allowed",
192
+ SecurityErrorCode.BLOCKED_PATTERN,
193
+ { path: filePath, pattern: ".." }
194
+ );
195
+ }
196
+
197
+ return sanitized;
198
+ }
199
+
200
+ /**
201
+ * Validate that an app ID is properly formatted
202
+ *
203
+ * @param {string} appId - App package ID to validate
204
+ * @returns {boolean} True if valid
205
+ * @throws {SecurityError} If app ID is invalid
206
+ */
207
+ export function validateAppId(appId) {
208
+ if (typeof appId !== "string" || appId.length === 0) {
209
+ throw new SecurityError(
210
+ "App ID must be a non-empty string",
211
+ SecurityErrorCode.INVALID_INPUT,
212
+ { received: appId }
213
+ );
214
+ }
215
+
216
+ // Valid app ID pattern: lowercase letters, numbers, dots, underscores
217
+ // Examples: com.google.android.youtube, com.example.app
218
+ const appIdPattern = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
219
+
220
+ if (!appIdPattern.test(appId)) {
221
+ throw new SecurityError(
222
+ "Invalid app ID format",
223
+ SecurityErrorCode.INVALID_INPUT,
224
+ { appId, expectedPattern: "com.example.app" }
225
+ );
226
+ }
227
+
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * Validate a device ID format
233
+ *
234
+ * @param {string} deviceId - Device ID to validate
235
+ * @returns {boolean} True if valid
236
+ * @throws {SecurityError} If device ID is invalid
237
+ */
238
+ export function validateDeviceId(deviceId) {
239
+ if (typeof deviceId !== "string" || deviceId.length === 0) {
240
+ throw new SecurityError(
241
+ "Device ID must be a non-empty string",
242
+ SecurityErrorCode.INVALID_INPUT,
243
+ { received: deviceId }
244
+ );
245
+ }
246
+
247
+ // Valid device ID patterns:
248
+ // - emulator-5554 (emulator)
249
+ // - RF8M12345XY (physical device)
250
+ // - 192.168.1.100:5555 (network device)
251
+ const deviceIdPattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-.:]*$/;
252
+
253
+ if (!deviceIdPattern.test(deviceId)) {
254
+ throw new SecurityError(
255
+ "Invalid device ID format",
256
+ SecurityErrorCode.INVALID_INPUT,
257
+ { deviceId }
258
+ );
259
+ }
260
+
261
+ // Check for maximum length
262
+ if (deviceId.length > 100) {
263
+ throw new SecurityError(
264
+ "Device ID too long",
265
+ SecurityErrorCode.INVALID_INPUT,
266
+ { deviceId, maxLength: 100 }
267
+ );
268
+ }
269
+
270
+ return true;
271
+ }
272
+
273
+ // ============================================
274
+ // OPERATION PERMISSION CHECKS
275
+ // ============================================
276
+
277
+ /**
278
+ * Operation types for permission checking
279
+ */
280
+ export const OperationType = {
281
+ // Read-only operations (always allowed)
282
+ READ: "read",
283
+ LIST: "list",
284
+ VALIDATE: "validate",
285
+
286
+ // Test operations (allowed in all modes)
287
+ RUN_TEST: "run_test",
288
+ SCREENSHOT: "screenshot",
289
+
290
+ // Potentially destructive operations (blocked in Safe Mode)
291
+ INSTALL_APP: "install_app",
292
+ UNINSTALL_APP: "uninstall_app",
293
+ CLEAR_APP_DATA: "clear_app_data",
294
+
295
+ // Always blocked operations
296
+ SYSTEM_SETTINGS: "system_settings",
297
+ FILE_SYSTEM: "file_system",
298
+ DEVICE_ADMIN: "device_admin",
299
+ };
300
+
301
+ /**
302
+ * Operations allowed in Safe Mode
303
+ */
304
+ const SAFE_MODE_ALLOWED_OPERATIONS = new Set([
305
+ OperationType.READ,
306
+ OperationType.LIST,
307
+ OperationType.VALIDATE,
308
+ OperationType.RUN_TEST,
309
+ OperationType.SCREENSHOT,
310
+ ]);
311
+
312
+ /**
313
+ * Operations allowed in Full Mode (Safe Mode + these)
314
+ */
315
+ const FULL_MODE_ADDITIONAL_OPERATIONS = new Set([
316
+ OperationType.INSTALL_APP,
317
+ OperationType.UNINSTALL_APP,
318
+ OperationType.CLEAR_APP_DATA,
319
+ ]);
320
+
321
+ /**
322
+ * Operations that are always blocked regardless of mode
323
+ */
324
+ const ALWAYS_BLOCKED_OPERATIONS = new Set([
325
+ OperationType.SYSTEM_SETTINGS,
326
+ OperationType.FILE_SYSTEM,
327
+ OperationType.DEVICE_ADMIN,
328
+ ]);
329
+
330
+ /**
331
+ * Check if an operation is allowed based on current security mode
332
+ *
333
+ * @param {string} operation - Operation type from OperationType
334
+ * @returns {boolean} True if operation is allowed
335
+ */
336
+ export function isOperationAllowed(operation) {
337
+ // Always blocked operations
338
+ if (ALWAYS_BLOCKED_OPERATIONS.has(operation)) {
339
+ logger.warn(`Blocked operation attempted: ${operation}`);
340
+ return false;
341
+ }
342
+
343
+ // Check Safe Mode operations
344
+ if (SAFE_MODE_ALLOWED_OPERATIONS.has(operation)) {
345
+ return true;
346
+ }
347
+
348
+ // If not in Safe Mode, check additional operations
349
+ if (!isSafeModeEnabled() && FULL_MODE_ADDITIONAL_OPERATIONS.has(operation)) {
350
+ return true;
351
+ }
352
+
353
+ // Unknown operation - deny by default
354
+ logger.warn(`Unknown operation denied: ${operation}`);
355
+ return false;
356
+ }
357
+
358
+ /**
359
+ * Assert that an operation is allowed, throw if not
360
+ *
361
+ * @param {string} operation - Operation type from OperationType
362
+ * @param {string} description - Description for error message
363
+ * @throws {SecurityError} If operation is not allowed
364
+ */
365
+ export function assertOperationAllowed(operation, description = "") {
366
+ if (!isOperationAllowed(operation)) {
367
+ const mode = getSecurityMode();
368
+ const isBlocked = ALWAYS_BLOCKED_OPERATIONS.has(operation);
369
+
370
+ throw new SecurityError(
371
+ isBlocked
372
+ ? `Operation "${operation}" is not allowed: ${description}`
373
+ : `Operation "${operation}" is not allowed in Safe Mode: ${description}`,
374
+ isBlocked
375
+ ? SecurityErrorCode.BLOCKED_COMMAND
376
+ : SecurityErrorCode.SAFE_MODE_VIOLATION,
377
+ { operation, mode, description }
378
+ );
379
+ }
380
+ }
381
+
382
+ // ============================================
383
+ // COMMAND ALLOWLISTS
384
+ // ============================================
385
+
386
+ /**
387
+ * Allowed Maestro CLI commands
388
+ * Only these commands can be executed via the MCP server
389
+ */
390
+ export const MAESTRO_ALLOWED_COMMANDS = new Set([
391
+ "test", // Run test flow
392
+ "validate", // Validate YAML
393
+ "screenshot", // Capture screen
394
+ "--version", // Version check
395
+ "--help", // Help
396
+ "hierarchy", // View hierarchy (useful for debugging)
397
+ ]);
398
+
399
+ /**
400
+ * Allowed Maestro command flags
401
+ */
402
+ export const MAESTRO_ALLOWED_FLAGS = new Set([
403
+ "--device", // Device selection
404
+ "--format", // Output format
405
+ "--output", // Output path
406
+ "--no-ansi", // Disable ANSI colors
407
+ "--debug", // Debug mode
408
+ "-e", // Environment variable
409
+ "--env", // Environment variable
410
+ ]);
411
+
412
+ /**
413
+ * ADB commands allowed in Safe Mode
414
+ * These are read-only and non-destructive operations
415
+ */
416
+ export const ADB_SAFE_MODE_COMMANDS = new Set([
417
+ "devices", // List devices
418
+ "devices -l", // List devices with details
419
+ "get-state", // Get device state
420
+ "get-serialno", // Get serial number
421
+ "shell getprop", // Get device properties
422
+ "shell pm list packages", // List installed packages
423
+ "shell pm path", // Get APK path
424
+ "shell dumpsys package", // Package info
425
+ "shell input tap", // Tap gesture
426
+ "shell input text", // Text input
427
+ "shell input swipe", // Swipe gesture
428
+ "shell input keyevent", // Key event
429
+ "shell screencap", // Screenshot
430
+ "pull", // Pull file from device
431
+ "version", // ADB version
432
+ ]);
433
+
434
+ /**
435
+ * Additional ADB commands allowed in Full Mode
436
+ * These are potentially destructive operations
437
+ */
438
+ export const ADB_FULL_MODE_COMMANDS = new Set([
439
+ "install", // Install APK
440
+ "install-multiple", // Install split APKs
441
+ "uninstall", // Uninstall app
442
+ "shell pm clear", // Clear app data
443
+ "shell am force-stop", // Force stop app
444
+ "shell am start", // Start activity
445
+ "shell am broadcast", // Send broadcast
446
+ "push", // Push file to device
447
+ "logcat", // View logs
448
+ "bugreport", // Bug report
449
+ ]);
450
+
451
+ /**
452
+ * ADB commands that are ALWAYS blocked regardless of mode
453
+ * These are dangerous system-level operations
454
+ */
455
+ export const ADB_BLOCKED_COMMANDS = new Set([
456
+ "root", // Root access
457
+ "unroot", // Unroot
458
+ "reboot", // Reboot device
459
+ "reboot-bootloader", // Reboot to bootloader
460
+ "reboot recovery", // Reboot to recovery
461
+ "shell rm", // Delete files
462
+ "shell rm -rf", // Recursive delete
463
+ "shell rm -r", // Recursive delete
464
+ "shell rmdir", // Remove directory
465
+ "shell mv", // Move files
466
+ "shell cp", // Copy files (can overwrite)
467
+ "shell dd", // Direct disk access
468
+ "shell su", // Superuser
469
+ "shell settings put", // Change settings
470
+ "shell settings delete", // Delete settings
471
+ "shell pm disable", // Disable package
472
+ "shell pm enable", // Enable package (can be dangerous)
473
+ "shell pm grant", // Grant permissions
474
+ "shell pm revoke", // Revoke permissions
475
+ "shell pm set-installer", // Set installer
476
+ "shell pm hide", // Hide package
477
+ "shell pm unhide", // Unhide package
478
+ "shell wm", // Window manager (screen changes)
479
+ "shell svc", // Service control
480
+ "shell setprop", // Set system properties
481
+ "shell am kill", // Kill process
482
+ "shell am kill-all", // Kill all processes
483
+ "shell am crash", // Crash app
484
+ "remount", // Remount filesystem
485
+ "disable-verity", // Disable verification
486
+ "enable-verity", // Enable verification
487
+ "keygen", // Key generation
488
+ "restore", // Restore backup
489
+ "sideload", // Sideload OTA
490
+ "shell content delete", // Delete content
491
+ "shell content update", // Update content
492
+ "emu kill", // Kill emulator
493
+ "emu avd stop", // Stop AVD
494
+ ]);
495
+
496
+ /**
497
+ * Check if a Maestro command is allowed
498
+ *
499
+ * @param {string} command - The Maestro command to check
500
+ * @returns {object} Result with allowed status and reason
501
+ */
502
+ export function isMaestroCommandAllowed(command) {
503
+ if (typeof command !== "string" || command.length === 0) {
504
+ return {
505
+ allowed: false,
506
+ reason: "Command must be a non-empty string",
507
+ };
508
+ }
509
+
510
+ // Extract the base command (first argument)
511
+ const parts = command.trim().split(/\s+/);
512
+ const baseCommand = parts[0];
513
+
514
+ // Check if base command is allowed
515
+ if (!MAESTRO_ALLOWED_COMMANDS.has(baseCommand)) {
516
+ logSecurityEvent("MAESTRO_COMMAND_BLOCKED", {
517
+ command,
518
+ baseCommand,
519
+ reason: "Command not in allowlist",
520
+ });
521
+
522
+ return {
523
+ allowed: false,
524
+ reason: `Maestro command "${baseCommand}" is not allowed`,
525
+ allowedCommands: Array.from(MAESTRO_ALLOWED_COMMANDS),
526
+ };
527
+ }
528
+
529
+ // Validate flags if present
530
+ for (let i = 1; i < parts.length; i++) {
531
+ const part = parts[i];
532
+ // Check if it's a flag (starts with -)
533
+ if (part.startsWith("-")) {
534
+ // Extract flag name (handle --flag=value format)
535
+ const flagName = part.split("=")[0];
536
+
537
+ if (!MAESTRO_ALLOWED_FLAGS.has(flagName) && flagName !== "--device") {
538
+ // Allow unknown flags but log them
539
+ logger.info(`Unknown Maestro flag used: ${flagName}`);
540
+ }
541
+ }
542
+ }
543
+
544
+ return {
545
+ allowed: true,
546
+ command: baseCommand,
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Check if an ADB command is allowed based on current security mode
552
+ *
553
+ * @param {string} command - The ADB command to check
554
+ * @returns {object} Result with allowed status and reason
555
+ */
556
+ export function isAdbCommandAllowed(command) {
557
+ if (typeof command !== "string" || command.length === 0) {
558
+ return {
559
+ allowed: false,
560
+ reason: "Command must be a non-empty string",
561
+ };
562
+ }
563
+
564
+ const trimmedCommand = command.trim();
565
+ const safeMode = isSafeModeEnabled();
566
+
567
+ // Check against blocked commands first (always blocked)
568
+ for (const blocked of ADB_BLOCKED_COMMANDS) {
569
+ if (
570
+ trimmedCommand === blocked ||
571
+ trimmedCommand.startsWith(blocked + " ")
572
+ ) {
573
+ logSecurityEvent("ADB_COMMAND_BLOCKED", {
574
+ command: trimmedCommand,
575
+ blockedPattern: blocked,
576
+ reason: "Command is in blocked list",
577
+ });
578
+
579
+ return {
580
+ allowed: false,
581
+ reason: `ADB command "${blocked}" is blocked for security reasons`,
582
+ blocked: true,
583
+ };
584
+ }
585
+ }
586
+
587
+ // Check against Safe Mode allowlist
588
+ for (const allowed of ADB_SAFE_MODE_COMMANDS) {
589
+ if (
590
+ trimmedCommand === allowed ||
591
+ trimmedCommand.startsWith(allowed + " ")
592
+ ) {
593
+ return {
594
+ allowed: true,
595
+ mode: "safe",
596
+ };
597
+ }
598
+ }
599
+
600
+ // If not in Safe Mode, check Full Mode commands
601
+ if (!safeMode) {
602
+ for (const allowed of ADB_FULL_MODE_COMMANDS) {
603
+ if (
604
+ trimmedCommand === allowed ||
605
+ trimmedCommand.startsWith(allowed + " ")
606
+ ) {
607
+ return {
608
+ allowed: true,
609
+ mode: "full",
610
+ };
611
+ }
612
+ }
613
+ }
614
+
615
+ // Check if it's a Full Mode command that's blocked by Safe Mode
616
+ for (const fullModeCmd of ADB_FULL_MODE_COMMANDS) {
617
+ if (
618
+ trimmedCommand === fullModeCmd ||
619
+ trimmedCommand.startsWith(fullModeCmd + " ")
620
+ ) {
621
+ logSecurityEvent("ADB_COMMAND_SAFE_MODE_BLOCKED", {
622
+ command: trimmedCommand,
623
+ reason: "Command requires Full Mode",
624
+ });
625
+
626
+ return {
627
+ allowed: false,
628
+ reason: `ADB command "${fullModeCmd}" is not allowed in Safe Mode. Set SAFE_MODE=false to enable.`,
629
+ requiresFullMode: true,
630
+ };
631
+ }
632
+ }
633
+
634
+ // Unknown command - deny by default
635
+ logSecurityEvent("ADB_COMMAND_UNKNOWN", {
636
+ command: trimmedCommand,
637
+ reason: "Command not recognized",
638
+ });
639
+
640
+ return {
641
+ allowed: false,
642
+ reason: `ADB command not recognized or not allowed: "${trimmedCommand}"`,
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Validate a complete command before execution
648
+ *
649
+ * @param {string} command - The command to validate
650
+ * @param {string} type - Command type: 'maestro' or 'adb'
651
+ * @returns {object} Validation result
652
+ * @throws {SecurityError} If command is not allowed
653
+ */
654
+ export function validateCommand(command, type) {
655
+ let result;
656
+
657
+ if (type === "maestro") {
658
+ result = isMaestroCommandAllowed(command);
659
+ } else if (type === "adb") {
660
+ result = isAdbCommandAllowed(command);
661
+ } else {
662
+ throw new SecurityError(
663
+ `Unknown command type: ${type}`,
664
+ SecurityErrorCode.INVALID_INPUT,
665
+ { type, allowedTypes: ["maestro", "adb"] }
666
+ );
667
+ }
668
+
669
+ if (!result.allowed) {
670
+ throw new SecurityError(
671
+ result.reason,
672
+ result.blocked
673
+ ? SecurityErrorCode.BLOCKED_COMMAND
674
+ : SecurityErrorCode.SAFE_MODE_VIOLATION,
675
+ { command, type, ...result }
676
+ );
677
+ }
678
+
679
+ return result;
680
+ }
681
+
682
+ /**
683
+ * Get all allowed commands for display/documentation
684
+ *
685
+ * @returns {object} Object containing all allowlists
686
+ */
687
+ export function getAllowedCommands() {
688
+ return {
689
+ maestro: {
690
+ commands: Array.from(MAESTRO_ALLOWED_COMMANDS),
691
+ flags: Array.from(MAESTRO_ALLOWED_FLAGS),
692
+ },
693
+ adb: {
694
+ safeMode: Array.from(ADB_SAFE_MODE_COMMANDS),
695
+ fullMode: Array.from(ADB_FULL_MODE_COMMANDS),
696
+ blocked: Array.from(ADB_BLOCKED_COMMANDS),
697
+ },
698
+ };
699
+ }
700
+
701
+ // ============================================
702
+ // BLOCKED PATTERNS & DETECTION
703
+ // ============================================
704
+
705
+ /**
706
+ * Pattern types for categorizing blocked patterns
707
+ */
708
+ export const PatternType = {
709
+ SHELL_INJECTION: "shell_injection",
710
+ COMMAND_SUBSTITUTION: "command_substitution",
711
+ PATH_TRAVERSAL: "path_traversal",
712
+ ENVIRONMENT_EXPANSION: "environment_expansion",
713
+ NULL_BYTE: "null_byte",
714
+ DANGEROUS_CHARACTERS: "dangerous_characters",
715
+ SQL_INJECTION: "sql_injection",
716
+ SCRIPT_INJECTION: "script_injection",
717
+ };
718
+
719
+ /**
720
+ * Blocked patterns with descriptions
721
+ * Each pattern has a regex, type, and description for better error messages
722
+ */
723
+ export const BLOCKED_PATTERNS = [
724
+ // Shell injection patterns
725
+ {
726
+ pattern: /[;&|]/,
727
+ type: PatternType.SHELL_INJECTION,
728
+ description: "Shell command chaining characters (; & |)",
729
+ example: "command1; command2",
730
+ },
731
+ {
732
+ pattern: /\|\|/,
733
+ type: PatternType.SHELL_INJECTION,
734
+ description: "Shell OR operator (||)",
735
+ example: "cmd1 || cmd2",
736
+ },
737
+ {
738
+ pattern: /&&/,
739
+ type: PatternType.SHELL_INJECTION,
740
+ description: "Shell AND operator (&&)",
741
+ example: "cmd1 && cmd2",
742
+ },
743
+
744
+ // Command substitution patterns
745
+ {
746
+ pattern: /`[^`]*`/,
747
+ type: PatternType.COMMAND_SUBSTITUTION,
748
+ description: "Backtick command substitution",
749
+ example: "`whoami`",
750
+ },
751
+ {
752
+ pattern: /\$\([^)]*\)/,
753
+ type: PatternType.COMMAND_SUBSTITUTION,
754
+ description: "Dollar-paren command substitution",
755
+ example: "$(whoami)",
756
+ },
757
+
758
+ // Path traversal patterns
759
+ {
760
+ pattern: /\.\.[/\\]/,
761
+ type: PatternType.PATH_TRAVERSAL,
762
+ description: "Parent directory traversal (../)",
763
+ example: "../../../etc/passwd",
764
+ },
765
+ {
766
+ pattern: /[/\\]\.\./,
767
+ type: PatternType.PATH_TRAVERSAL,
768
+ description: "Parent directory traversal (/..)",
769
+ example: "/dir/../secret",
770
+ },
771
+
772
+ // Environment variable expansion
773
+ {
774
+ pattern: /\$\{[^}]+\}/,
775
+ type: PatternType.ENVIRONMENT_EXPANSION,
776
+ description: "Environment variable expansion (${VAR})",
777
+ example: "${HOME}",
778
+ },
779
+ {
780
+ pattern: /\$[A-Z_][A-Z0-9_]*/i,
781
+ type: PatternType.ENVIRONMENT_EXPANSION,
782
+ description: "Environment variable reference ($VAR)",
783
+ example: "$PATH",
784
+ },
785
+
786
+ // Null byte injection - using Unicode escape for linter compatibility
787
+ {
788
+ pattern: new RegExp(String.fromCharCode(0)),
789
+ type: PatternType.NULL_BYTE,
790
+ description: "Null byte injection",
791
+ example: "file.txt\\0.jpg",
792
+ },
793
+ {
794
+ pattern: /%00/,
795
+ type: PatternType.NULL_BYTE,
796
+ description: "URL-encoded null byte",
797
+ example: "file.txt%00.jpg",
798
+ },
799
+
800
+ // Dangerous shell characters
801
+ {
802
+ pattern: /[><]/,
803
+ type: PatternType.DANGEROUS_CHARACTERS,
804
+ description: "Shell redirection characters (< >)",
805
+ example: "cmd > /etc/passwd",
806
+ },
807
+ {
808
+ pattern: /\n|\r/,
809
+ type: PatternType.DANGEROUS_CHARACTERS,
810
+ description: "Newline characters (potential command injection)",
811
+ example: "cmd\\nmalicious",
812
+ },
813
+
814
+ // Script injection (for YAML context)
815
+ {
816
+ pattern: /<script[^>]*>/i,
817
+ type: PatternType.SCRIPT_INJECTION,
818
+ description: "Script tag injection",
819
+ example: "<script>alert(1)</script>",
820
+ },
821
+ {
822
+ pattern: /javascript:/i,
823
+ type: PatternType.SCRIPT_INJECTION,
824
+ description: "JavaScript protocol",
825
+ example: "javascript:alert(1)",
826
+ },
827
+ ];
828
+
829
+ /**
830
+ * Patterns that are only blocked in certain contexts
831
+ */
832
+ export const CONTEXT_SENSITIVE_PATTERNS = {
833
+ // Blocked only in shell commands
834
+ shellCommand: [
835
+ {
836
+ pattern: /\$/,
837
+ type: PatternType.ENVIRONMENT_EXPANSION,
838
+ description: "Dollar sign in shell context",
839
+ },
840
+ {
841
+ pattern: /!/,
842
+ type: PatternType.SHELL_INJECTION,
843
+ description: "History expansion character",
844
+ },
845
+ ],
846
+
847
+ // Blocked only in file paths
848
+ filePath: [
849
+ {
850
+ pattern: /:/,
851
+ type: PatternType.DANGEROUS_CHARACTERS,
852
+ description: "Colon in file path (Windows drive or ADS)",
853
+ },
854
+ {
855
+ pattern: /\*/,
856
+ type: PatternType.DANGEROUS_CHARACTERS,
857
+ description: "Wildcard in file path",
858
+ },
859
+ {
860
+ pattern: /\?/,
861
+ type: PatternType.DANGEROUS_CHARACTERS,
862
+ description: "Wildcard in file path",
863
+ },
864
+ ],
865
+
866
+ // Blocked only in app IDs
867
+ appId: [
868
+ {
869
+ pattern: /[^a-zA-Z0-9_.]/,
870
+ type: PatternType.DANGEROUS_CHARACTERS,
871
+ description: "Invalid character in app ID",
872
+ },
873
+ ],
874
+ };
875
+
876
+ /**
877
+ * Check if input contains any blocked patterns
878
+ *
879
+ * @param {string} input - Input string to check
880
+ * @param {string} context - Optional context for context-sensitive checks
881
+ * @returns {object} Result with detected status and pattern info
882
+ */
883
+ export function containsBlockedPattern(input, context = null) {
884
+ if (typeof input !== "string") {
885
+ return {
886
+ detected: false,
887
+ reason: "Input is not a string",
888
+ };
889
+ }
890
+
891
+ // Check global blocked patterns
892
+ for (const patternInfo of BLOCKED_PATTERNS) {
893
+ if (patternInfo.pattern.test(input)) {
894
+ logSecurityEvent("BLOCKED_PATTERN_DETECTED", {
895
+ input: input.substring(0, 100), // Truncate for logging
896
+ patternType: patternInfo.type,
897
+ description: patternInfo.description,
898
+ context,
899
+ });
900
+
901
+ return {
902
+ detected: true,
903
+ type: patternInfo.type,
904
+ description: patternInfo.description,
905
+ example: patternInfo.example,
906
+ pattern: patternInfo.pattern.toString(),
907
+ };
908
+ }
909
+ }
910
+
911
+ // Check context-sensitive patterns if context is provided
912
+ if (context && CONTEXT_SENSITIVE_PATTERNS[context]) {
913
+ for (const patternInfo of CONTEXT_SENSITIVE_PATTERNS[context]) {
914
+ if (patternInfo.pattern.test(input)) {
915
+ logSecurityEvent("CONTEXT_PATTERN_DETECTED", {
916
+ input: input.substring(0, 100),
917
+ patternType: patternInfo.type,
918
+ description: patternInfo.description,
919
+ context,
920
+ });
921
+
922
+ return {
923
+ detected: true,
924
+ type: patternInfo.type,
925
+ description: patternInfo.description,
926
+ context,
927
+ pattern: patternInfo.pattern.toString(),
928
+ };
929
+ }
930
+ }
931
+ }
932
+
933
+ return {
934
+ detected: false,
935
+ };
936
+ }
937
+
938
+ /**
939
+ * Assert that input does not contain blocked patterns
940
+ *
941
+ * @param {string} input - Input string to check
942
+ * @param {string} inputName - Name of the input for error messages
943
+ * @param {string} context - Optional context for context-sensitive checks
944
+ * @throws {SecurityError} If blocked pattern is detected
945
+ */
946
+ export function assertNoBlockedPatterns(
947
+ input,
948
+ inputName = "input",
949
+ context = null
950
+ ) {
951
+ const result = containsBlockedPattern(input, context);
952
+
953
+ if (result.detected) {
954
+ throw new SecurityError(
955
+ `Blocked pattern detected in ${inputName}: ${result.description}`,
956
+ SecurityErrorCode.BLOCKED_PATTERN,
957
+ {
958
+ inputName,
959
+ patternType: result.type,
960
+ description: result.description,
961
+ context,
962
+ }
963
+ );
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Sanitize input by removing or escaping blocked patterns
969
+ * Use with caution - validation is preferred over sanitization
970
+ *
971
+ * @param {string} input - Input string to sanitize
972
+ * @param {object} options - Sanitization options
973
+ * @returns {string} Sanitized string
974
+ */
975
+ export function sanitizeBlockedPatterns(input, options = {}) {
976
+ if (typeof input !== "string") {
977
+ return input;
978
+ }
979
+
980
+ let sanitized = input;
981
+
982
+ // Remove null bytes - using character code for linter compatibility
983
+ const nullByteRegex = new RegExp(String.fromCharCode(0), "g");
984
+ sanitized = sanitized.replace(nullByteRegex, "");
985
+ sanitized = sanitized.replace(/%00/g, "");
986
+
987
+ // Remove shell injection characters
988
+ if (options.removeShellChars !== false) {
989
+ sanitized = sanitized.replace(/[;&|`]/g, "");
990
+ }
991
+
992
+ // Remove path traversal
993
+ if (options.removePathTraversal !== false) {
994
+ sanitized = sanitized.replace(/\.\./g, "");
995
+ }
996
+
997
+ // Remove environment expansion (but keep $ for normal text)
998
+ if (options.removeEnvExpansion) {
999
+ sanitized = sanitized.replace(/\$\{[^}]+\}/g, "");
1000
+ sanitized = sanitized.replace(/\$\([^)]*\)/g, "");
1001
+ }
1002
+
1003
+ // Remove control characters
1004
+ if (options.removeControlChars !== false) {
1005
+ sanitized = sanitized.replace(/[\n\r]/g, " ");
1006
+ }
1007
+
1008
+ return sanitized;
1009
+ }
1010
+
1011
+ /**
1012
+ * Validate and sanitize a complete command before execution
1013
+ * This combines pattern detection with command allowlist checking
1014
+ *
1015
+ * @param {string} command - Command to validate
1016
+ * @param {string} type - Command type ('maestro' or 'adb')
1017
+ * @returns {object} Validation result with sanitized command
1018
+ * @throws {SecurityError} If validation fails
1019
+ */
1020
+ export function validateAndSanitizeCommand(command, type) {
1021
+ // First check for blocked patterns
1022
+ assertNoBlockedPatterns(command, `${type} command`, "shellCommand");
1023
+
1024
+ // Then validate against allowlists
1025
+ const allowlistResult = validateCommand(command, type);
1026
+
1027
+ return {
1028
+ valid: true,
1029
+ command: sanitizeInput(command),
1030
+ type,
1031
+ ...allowlistResult,
1032
+ };
1033
+ }
1034
+
1035
+ /**
1036
+ * Check if YAML content contains potentially dangerous patterns
1037
+ *
1038
+ * @param {string} yamlContent - YAML content to check
1039
+ * @returns {object} Result with detected issues
1040
+ */
1041
+ export function checkYamlSecurity(yamlContent) {
1042
+ if (typeof yamlContent !== "string") {
1043
+ return {
1044
+ safe: false,
1045
+ issues: ["YAML content must be a string"],
1046
+ };
1047
+ }
1048
+
1049
+ const issues = [];
1050
+ const warnings = [];
1051
+
1052
+ // Check for blocked patterns (excluding some that are valid in YAML)
1053
+ const nullBytePattern = new RegExp(String.fromCharCode(0));
1054
+ const dangerousPatterns = [
1055
+ { pattern: /\$\([^)]*\)/, message: "Command substitution $()" },
1056
+ { pattern: /`[^`]*`/, message: "Backtick command substitution" },
1057
+ { pattern: /<script/i, message: "Script tag" },
1058
+ { pattern: /javascript:/i, message: "JavaScript protocol" },
1059
+ { pattern: nullBytePattern, message: "Null byte" },
1060
+ ];
1061
+
1062
+ for (const { pattern, message } of dangerousPatterns) {
1063
+ if (pattern.test(yamlContent)) {
1064
+ issues.push(`Dangerous pattern detected: ${message}`);
1065
+ }
1066
+ }
1067
+
1068
+ // Check for suspicious YAML constructs
1069
+ if (yamlContent.includes("!!python")) {
1070
+ issues.push("Python tag detected - potential code execution");
1071
+ }
1072
+
1073
+ if (yamlContent.includes("!!ruby")) {
1074
+ issues.push("Ruby tag detected - potential code execution");
1075
+ }
1076
+
1077
+ // Warnings (not blocking but suspicious)
1078
+ if (yamlContent.includes("shell:") || yamlContent.includes("exec:")) {
1079
+ warnings.push("Shell/exec command found - review carefully");
1080
+ }
1081
+
1082
+ if (issues.length > 0) {
1083
+ logSecurityEvent("YAML_SECURITY_ISSUES", {
1084
+ issueCount: issues.length,
1085
+ issues,
1086
+ });
1087
+ }
1088
+
1089
+ return {
1090
+ safe: issues.length === 0,
1091
+ issues,
1092
+ warnings,
1093
+ };
1094
+ }
1095
+
1096
+ /**
1097
+ * Get all blocked patterns for documentation
1098
+ *
1099
+ * @returns {object} All blocked patterns organized by type
1100
+ */
1101
+ export function getBlockedPatterns() {
1102
+ const organized = {};
1103
+
1104
+ for (const patternInfo of BLOCKED_PATTERNS) {
1105
+ if (!organized[patternInfo.type]) {
1106
+ organized[patternInfo.type] = [];
1107
+ }
1108
+ organized[patternInfo.type].push({
1109
+ description: patternInfo.description,
1110
+ example: patternInfo.example,
1111
+ });
1112
+ }
1113
+
1114
+ return {
1115
+ patterns: organized,
1116
+ contextSensitive: Object.keys(CONTEXT_SENSITIVE_PATTERNS),
1117
+ patternTypes: Object.values(PatternType),
1118
+ };
1119
+ }
1120
+
1121
+ // ============================================
1122
+ // SECURITY EVENT LOGGING
1123
+ // ============================================
1124
+
1125
+ /**
1126
+ * Log a security event
1127
+ *
1128
+ * @param {string} event - Event type
1129
+ * @param {object} details - Event details
1130
+ */
1131
+ export function logSecurityEvent(event, details = {}) {
1132
+ if (process.env.LOG_SECURITY_EVENTS === "false") {
1133
+ return;
1134
+ }
1135
+
1136
+ logger.info(`[SECURITY] ${event}`, {
1137
+ event,
1138
+ ...details,
1139
+ timestamp: new Date().toISOString(),
1140
+ safeMode: isSafeModeEnabled(),
1141
+ });
1142
+ }
1143
+
1144
+ // ============================================
1145
+ // EXPORTS
1146
+ // ============================================
1147
+
1148
+ export default {
1149
+ // Constants
1150
+ SecurityMode,
1151
+ SecurityErrorCode,
1152
+ OperationType,
1153
+ PatternType,
1154
+
1155
+ // Command Allowlists
1156
+ MAESTRO_ALLOWED_COMMANDS,
1157
+ MAESTRO_ALLOWED_FLAGS,
1158
+ ADB_SAFE_MODE_COMMANDS,
1159
+ ADB_FULL_MODE_COMMANDS,
1160
+ ADB_BLOCKED_COMMANDS,
1161
+
1162
+ // Blocked Patterns
1163
+ BLOCKED_PATTERNS,
1164
+ CONTEXT_SENSITIVE_PATTERNS,
1165
+
1166
+ // Classes
1167
+ SecurityError,
1168
+
1169
+ // Safe Mode functions
1170
+ isSafeModeEnabled,
1171
+ getSecurityMode,
1172
+ getSecurityConfig,
1173
+
1174
+ // Input sanitization
1175
+ sanitizeInput,
1176
+ sanitizeFilePath,
1177
+ validateAppId,
1178
+ validateDeviceId,
1179
+
1180
+ // Operation permissions
1181
+ isOperationAllowed,
1182
+ assertOperationAllowed,
1183
+
1184
+ // Command validation
1185
+ isMaestroCommandAllowed,
1186
+ isAdbCommandAllowed,
1187
+ validateCommand,
1188
+ getAllowedCommands,
1189
+
1190
+ // Pattern detection
1191
+ containsBlockedPattern,
1192
+ assertNoBlockedPatterns,
1193
+ sanitizeBlockedPatterns,
1194
+ validateAndSanitizeCommand,
1195
+ checkYamlSecurity,
1196
+ getBlockedPatterns,
1197
+
1198
+ // Logging
1199
+ logSecurityEvent,
1200
+ };