roadkit
Version:
Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export
586 lines (507 loc) • 18 kB
text/typescript
/**
* Comprehensive Security Tests for RoadKit CLI
*
* This test suite verifies that all security vulnerabilities identified in the
* CLI package review have been properly addressed. It includes tests for:
* - Path traversal protection
* - Code injection prevention
* - Input validation and sanitization
* - Template variable security
* - File operation security
* - Error handling without process.exit
*/
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import { mkdir, rm, writeFile } from 'fs/promises';
// Import modules to test
import {
validateProjectName,
validateTemplateType,
validateThemeType,
sanitizePath,
validateCLIOptions,
sanitizeTemplateVariable,
validateTemplateContent,
isSafeForVariableReplacement,
SecurityError,
createSecurityError
} from '../utils/security.js';
import { Logger, ErrorRecovery } from '../utils/logger.js';
import { SecureTemplateManager } from '../core/secure-templates.js';
import { runSecureCLI } from '../cli/secure-cli.js';
// Test constants
const TEST_TEMP_DIR = resolve(__dirname, '../../test-temp');
const MALICIOUS_INPUTS = {
pathTraversal: [
'../../../etc/passwd',
'..\\..\\windows\\system32',
'/etc/passwd',
'C:\\Windows\\System32',
'../../.ssh/id_rsa',
'../../../root/.bashrc',
'node_modules/../../../etc/shadow',
'.git/../../../sensitive-file',
'templates/../../../secret.key'
],
codeInjection: [
'<script>alert("xss")</script>',
'javascript:alert(1)',
'${process.exit(1)}',
'`rm -rf /`',
'eval("console.log(process.env)")',
'${require("fs").readFileSync("/etc/passwd")}',
'{{constructor.constructor("return process")().exit()}}',
'<%- global.process.mainModule.require("child_process").exec("rm -rf /") %>'
],
longInputs: [
'a'.repeat(1000000), // 1MB string
'/'.repeat(10000), // Long path
'../'.repeat(1000), // Long traversal attempt
],
nullBytes: [
'test\0file',
'project\0\0name',
'/path/to/file\0.txt'
],
specialChars: [
'test<>:"|?*file',
'project\n\r\tname',
'file\x00\x01\x02name'
]
};
describe('Security: Input Validation', () => {
test('should validate project names securely', () => {
// Valid project names
expect(validateProjectName('my-project')).toEqual({
isValid: true,
data: 'my-project'
});
expect(validateProjectName('test_project_123')).toEqual({
isValid: true,
data: 'test_project_123'
});
// Invalid project names
expect(validateProjectName('')).toEqual({
isValid: false,
error: 'Project name cannot be empty'
});
expect(validateProjectName('a'.repeat(100))).toEqual({
isValid: false,
error: expect.stringContaining('cannot exceed')
});
expect(validateProjectName('../malicious')).toEqual({
isValid: false,
error: expect.stringContaining('must contain only')
});
expect(validateProjectName('node_modules')).toEqual({
isValid: false,
error: expect.stringContaining('reserved system directory')
});
// Test dangerous names
const dangerousNames = ['.git', 'node_modules', 'etc', 'usr', 'var', 'tmp', 'proc', 'sys', 'root'];
dangerousNames.forEach(name => {
const result = validateProjectName(name);
expect(result.isValid).toBe(false);
// Could be rejected for different reasons - pattern or reserved name
expect(result.error).toBeTruthy();
});
});
test('should validate template types securely', () => {
// Valid templates
const validTemplates = ['basic', 'advanced', 'enterprise', 'custom'];
validTemplates.forEach(template => {
expect(validateTemplateType(template)).toEqual({
isValid: true,
data: template
});
});
// Invalid templates
expect(validateTemplateType('malicious')).toEqual({
isValid: false,
error: expect.stringContaining('Invalid template type')
});
expect(validateTemplateType('../evil')).toEqual({
isValid: false,
error: expect.stringContaining('Invalid template type')
});
});
test('should validate theme types securely', () => {
// Valid themes
const validThemes = ['modern', 'classic', 'minimal', 'corporate'];
validThemes.forEach(theme => {
expect(validateThemeType(theme)).toEqual({
isValid: true,
data: theme
});
});
// Invalid themes
expect(validateThemeType('evil-theme')).toEqual({
isValid: false,
error: expect.stringContaining('Invalid theme type')
});
});
test('should reject malicious CLI options', () => {
// Test only paths that should actually be rejected (those with .. traversal)
const actualTraversalPaths = [
'../../../etc/passwd',
'../../.ssh/id_rsa',
'../secret.key'
];
actualTraversalPaths.forEach(maliciousPath => {
const result = validateCLIOptions({ output: maliciousPath });
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/path/i);
});
// Code injection attempts in project names
MALICIOUS_INPUTS.codeInjection.forEach(maliciousCode => {
const result = validateCLIOptions({ name: maliciousCode });
expect(result.isValid).toBe(false);
expect(result.error).toBeTruthy();
});
});
});
describe('Security: Path Traversal Protection', () => {
test('should prevent directory traversal attacks', () => {
// Test only actual traversal patterns
const traversalPaths = [
'../../../etc/passwd',
'../../.ssh/id_rsa',
'../secret.key',
'..\\..\\windows\\system32',
'node_modules/../../../etc/shadow',
'.git/../../../sensitive-file',
'templates/../../../secret.key'
];
traversalPaths.forEach(maliciousPath => {
const result = sanitizePath(maliciousPath);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/traversal|prohibited/i);
});
});
test('should detect path traversal with basePath restriction', () => {
const basePath = '/safe/directory';
// These should fail
const maliciousPaths = [
'../../../etc/passwd',
'../../secret',
'/etc/passwd',
'C:\\Windows\\System32'
];
maliciousPaths.forEach(path => {
const result = sanitizePath(path, basePath);
expect(result.isValid).toBe(false);
expect(result.error).toBeTruthy();
});
});
test('should allow valid paths within base directory', () => {
const basePath = resolve(TEST_TEMP_DIR, 'safe');
// These should pass
const safePaths = [
'project/file.txt',
'subfolder/another.js',
'simple-file.json'
];
safePaths.forEach(path => {
const result = sanitizePath(path, basePath);
expect(result.isValid).toBe(true);
expect(result.sanitizedPath).toBeTruthy();
expect(result.sanitizedPath!.startsWith(basePath)).toBe(true);
});
});
test('should reject paths with null bytes', () => {
MALICIOUS_INPUTS.nullBytes.forEach(pathWithNull => {
const result = sanitizePath(pathWithNull);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/null/i);
});
});
test('should enforce path length limits', () => {
const longPath = 'a/'.repeat(1000) + 'file.txt';
const result = sanitizePath(longPath);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/depth|length/i);
});
});
describe('Security: Template Variable Protection', () => {
test('should sanitize dangerous template variables', () => {
MALICIOUS_INPUTS.codeInjection.forEach(maliciousCode => {
const sanitized = sanitizeTemplateVariable(maliciousCode);
// Should not contain dangerous patterns
expect(sanitized).not.toMatch(/<script/i);
expect(sanitized).not.toMatch(/javascript:/i);
expect(sanitized).not.toMatch(/eval\s*\(/i);
expect(sanitized).not.toMatch(/\$\{/); // Should be escaped
expect(sanitized).not.toContain('\0');
// Backticks should be escaped, so we check for raw backticks
expect(sanitized).not.toMatch(/[^\\]`/); // Not escaped backticks
});
});
test('should limit variable length to prevent DoS', () => {
const longVariable = 'a'.repeat(10000);
const sanitized = sanitizeTemplateVariable(longVariable);
expect(sanitized.length).toBeLessThanOrEqual(1000);
});
test('should validate template content for security issues', () => {
const dangerousContent = `
<script>alert('xss')</script>
eval('malicious code');
document.write('<script>evil()</script>');
`;
const result = validateTemplateContent(dangerousContent, []);
expect(result.isValid).toBe(true); // Still valid but with warnings
expect(result.warnings).toBeTruthy();
expect(result.warnings!.length).toBeGreaterThan(0);
});
test('should identify safe file extensions for variable replacement', () => {
// Safe extensions
const safeFiles = [
'file.js', 'file.ts', 'file.tsx', 'file.jsx',
'file.json', 'file.md', 'file.html', 'file.css',
'file.txt', 'file.yml', 'file.yaml'
];
safeFiles.forEach(file => {
expect(isSafeForVariableReplacement(file)).toBe(true);
});
// Unsafe extensions
const unsafeFiles = [
'file.exe', 'file.dll', 'file.so', 'file.bin',
'file.jpg', 'file.png', 'file.pdf', 'file.zip',
'file.tar', 'file.gz'
];
unsafeFiles.forEach(file => {
expect(isSafeForVariableReplacement(file)).toBe(false);
});
});
});
describe('Security: File Operation Safety', () => {
beforeEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
await mkdir(TEST_TEMP_DIR, { recursive: true });
});
afterEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
});
test('should create secure template manager', () => {
expect(() => {
new SecureTemplateManager(TEST_TEMP_DIR);
}).not.toThrow();
});
test('should reject malicious template directory paths in production', () => {
// Set production environment temporarily
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
expect(() => {
new SecureTemplateManager('../../../etc');
}).toThrow(SecurityError);
expect(() => {
new SecureTemplateManager('/etc/passwd');
}).toThrow(SecurityError);
} finally {
process.env.NODE_ENV = originalEnv;
}
});
test('should handle file system errors gracefully', async () => {
const logger = new Logger();
const errorRecovery = new ErrorRecovery(logger);
// Test recovery from permission errors
const permissionError = new Error('EACCES: permission denied');
const canRecover = await errorRecovery.recoverFromFileError(
permissionError,
'create',
'/root/test'
);
expect(canRecover).toBe(true);
// Test recovery from file not found
const notFoundError = new Error('ENOENT: no such file or directory');
const canRecoverNotFound = await errorRecovery.recoverFromFileError(
notFoundError,
'read',
'/nonexistent/file'
);
expect(canRecoverNotFound).toBe(true);
});
});
describe('Security: Error Handling', () => {
test('should create SecurityError instances properly', () => {
const error = createSecurityError('Test security error', 'test_type');
expect(error).toBeInstanceOf(SecurityError);
expect(error.message).toBe('Test security error');
expect(error.securityType).toBe('test_type');
expect(error.name).toBe('SecurityError');
});
test('should not use process.exit in error handlers', async () => {
// Mock process.exit to detect if it's called
const originalExit = process.exit;
let exitCalled = false;
process.exit = (() => {
exitCalled = true;
return originalExit;
}) as any;
try {
// Test CLI with invalid options that should cause errors
const result = await runSecureCLI({
name: '../malicious',
template: 'nonexistent' as any,
skipPrompts: true
});
// Should return error result instead of calling process.exit
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(exitCalled).toBe(false);
} finally {
// Restore original process.exit
process.exit = originalExit;
}
});
});
describe('Security: Logging and Audit', () => {
test('should log security events properly', () => {
const logger = new Logger({ enableConsole: false });
// Test security logging
logger.security(
'Test security event',
'test_event',
'high',
'malicious_input',
'sanitized_input'
);
// Should not throw and should buffer the log
expect(() => logger.flush()).not.toThrow();
});
test('should provide error recovery suggestions', () => {
const logger = new Logger();
const errorRecovery = new ErrorRecovery(logger);
// Test permission error suggestion
const permissionError = new Error('EACCES: permission denied');
const suggestion = errorRecovery.getErrorRecoverySuggestion(permissionError);
expect(suggestion).toMatch(/permission/i);
// Test space error suggestion
const spaceError = new Error('ENOSPC: no space left on device');
const spaceSuggestion = errorRecovery.getErrorRecoverySuggestion(spaceError);
expect(spaceSuggestion).toMatch(/space/i);
// Test network error suggestion
const networkError = new Error('Network error: fetch failed');
const networkSuggestion = errorRecovery.getErrorRecoverySuggestion(networkError);
expect(networkSuggestion).toMatch(/network|connection/i);
});
});
describe('Security: Integration Tests', () => {
beforeEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
await mkdir(TEST_TEMP_DIR, { recursive: true });
});
afterEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
});
test('should reject malicious project generation attempts', async () => {
// Test with path traversal in project name
const result1 = await runSecureCLI({
name: '../../../malicious',
output: TEST_TEMP_DIR,
skipPrompts: true
});
expect(result1.success).toBe(false);
expect(result1.securityWarnings.length).toBeGreaterThan(0);
// Test with code injection in project name
const result2 = await runSecureCLI({
name: '${process.exit(1)}',
output: TEST_TEMP_DIR,
skipPrompts: true
});
expect(result2.success).toBe(false);
});
test('should create secure project with valid inputs', async () => {
// Create minimal template structure for testing
const templateDir = join(TEST_TEMP_DIR, 'templates', 'basic');
await mkdir(templateDir, { recursive: true });
await writeFile(join(templateDir, 'package.json'), JSON.stringify({
name: '{{PROJECT_NAME}}',
version: '1.0.0'
}, null, 2));
// Test with completely valid inputs
const result = await runSecureCLI({
name: 'valid-project',
template: 'basic',
theme: 'modern',
output: join(TEST_TEMP_DIR, 'output'),
skipPrompts: true,
skipInstall: true,
skipGit: true
});
// This might fail due to missing template, but it should handle it gracefully
expect(result.success).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.securityWarnings).toBeDefined();
// Most importantly, no process.exit should be called
expect(result.canRetry).toBeDefined();
});
test('should handle concurrent malicious requests safely', async () => {
// Launch multiple concurrent malicious requests
const maliciousRequests = MALICIOUS_INPUTS.pathTraversal.slice(0, 5).map(path =>
runSecureCLI({
name: 'test',
output: path,
skipPrompts: true
})
);
const results = await Promise.all(maliciousRequests);
// All should fail safely without crashing
results.forEach(result => {
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.securityWarnings).toBeDefined();
});
});
});
describe('Security: Performance and DoS Protection', () => {
test('should handle extremely long inputs without crashing', () => {
MALICIOUS_INPUTS.longInputs.forEach(longInput => {
expect(() => {
validateProjectName(longInput);
}).not.toThrow();
expect(() => {
sanitizePath(longInput);
}).not.toThrow();
expect(() => {
sanitizeTemplateVariable(longInput);
}).not.toThrow();
});
});
test('should limit resource consumption', () => {
const startTime = Date.now();
// Test with many malicious inputs
for (let i = 0; i < 100; i++) {
validateProjectName('a'.repeat(1000));
sanitizePath('../'.repeat(100));
sanitizeTemplateVariable('<script>'.repeat(100));
}
const duration = Date.now() - startTime;
// Should complete within reasonable time (less than 5 seconds)
expect(duration).toBeLessThan(5000);
});
test('should handle memory pressure gracefully', () => {
// Create large template variables
const largeVariables = Array.from({ length: 100 }, (_, i) => ({
name: `VAR_${i}`,
value: 'x'.repeat(10000)
}));
expect(() => {
validateTemplateContent('test content', largeVariables);
}).not.toThrow();
});
});
// Export test utilities for other test files
export {
TEST_TEMP_DIR,
MALICIOUS_INPUTS
};