PersonaElementManager.tsโข14.3 kB
/**
* PersonaElementManager - Implementation of IElementManager for PersonaElement
* Handles CRUD operations and lifecycle management for personas implementing IElement
*
* SECURITY FIXES IMPLEMENTED (PR #319):
* 1. CRITICAL: Fixed race conditions in file operations by using FileLockManager for atomic reads/writes
* 2. CRITICAL: Fixed dynamic require() statements by using static imports
* 3. HIGH: Fixed unvalidated YAML parsing vulnerability by using SecureYamlParser
* 4. MEDIUM: All user inputs are now validated and sanitized
* 5. MEDIUM: Audit logging added for security operations
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { IElementManager, ElementValidationResult } from '../types/elements/index.js';
import { ElementType } from '../portfolio/types.js';
import { PersonaElement, PersonaElementMetadata } from './PersonaElement.js';
import { PortfolioManager } from '../portfolio/PortfolioManager.js';
import { logger } from '../utils/logger.js';
import { ErrorHandler, ErrorCategory } from '../utils/ErrorHandler.js';
import { ValidationErrorCodes, SystemErrorCodes } from '../utils/errorCodes.js';
import { validatePath, validateFilename } from '../security/InputValidator.js';
import { ensureDirectory } from '../utils/filesystem.js';
import { FileLockManager } from '../security/fileLockManager.js';
import { SecureYamlParser } from '../security/secureYamlParser.js';
import { SecurityMonitor } from '../security/securityMonitor.js';
export class PersonaElementManager implements IElementManager<PersonaElement> {
private portfolioManager: PortfolioManager;
private personasDir: string;
constructor(portfolioManager?: PortfolioManager) {
this.portfolioManager = portfolioManager || PortfolioManager.getInstance();
this.personasDir = this.portfolioManager.getElementDir(ElementType.PERSONA);
}
/**
* Load a persona from file
* SECURITY FIX #1: Uses FileLockManager.atomicReadFile() instead of fs.readFile()
* to prevent race conditions and ensure atomic file operations
*/
async load(filePath: string): Promise<PersonaElement> {
try {
// Resolve full path if relative
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath);
// Validate path security
if (!this.validatePath(fullPath)) {
// SECURITY FIX #206: Don't expose user paths in error messages
logger.error('Invalid or unsafe path', { path: filePath });
throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH);
}
// CRITICAL FIX: Use atomic file read to prevent race conditions
// Previously: const content = await fs.readFile(fullPath, 'utf-8');
// Now: Uses FileLockManager with proper encoding object format
const content = await FileLockManager.atomicReadFile(fullPath, { encoding: 'utf-8' });
// Create a new PersonaElement and deserialize
const persona = new PersonaElement({}, '', path.basename(fullPath));
persona.deserialize(content);
logger.debug(`Loaded persona: ${persona.metadata.name} from ${filePath}`);
return persona;
} catch (error) {
logger.error(`Failed to load persona from ${filePath}: ${error}`);
throw ErrorHandler.wrapError(error, 'Failed to load persona', ErrorCategory.SYSTEM_ERROR);
}
}
/**
* Save a persona to file
* SECURITY FIX #1: Uses FileLockManager.atomicWriteFile() instead of fs.writeFile()
* to prevent race conditions and ensure atomic file operations
*/
async save(element: PersonaElement, filePath: string): Promise<void> {
try {
// Ensure personas directory exists
await ensureDirectory(this.personasDir);
// Resolve full path if relative
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath);
// Validate path security
if (!this.validatePath(fullPath)) {
// SECURITY FIX #206: Don't expose user paths in error messages
logger.error('Invalid or unsafe path', { path: filePath });
throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH);
}
// Serialize the persona
const content = element.serialize();
// CRITICAL FIX: Use atomic file write to prevent corruption during interruptions
// Previously: await fs.writeFile(fullPath, content, 'utf-8');
// Now: Uses FileLockManager with proper encoding object format
// This prevents partial writes and data corruption if the process is interrupted
await FileLockManager.atomicWriteFile(fullPath, content, { encoding: 'utf-8' });
// Update filename in element
element.filename = path.basename(fullPath);
logger.debug(`Saved persona: ${element.metadata.name} to ${filePath}`);
} catch (error) {
logger.error(`Failed to save persona to ${filePath}: ${error}`);
throw ErrorHandler.wrapError(error, 'Failed to save persona', ErrorCategory.SYSTEM_ERROR);
}
}
/**
* Delete a persona file
*/
async delete(filePath: string): Promise<void> {
try {
// Resolve full path if relative
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath);
// Validate path security
if (!this.validatePath(fullPath)) {
// SECURITY FIX #206: Don't expose user paths in error messages
logger.error('Invalid or unsafe path', { path: filePath });
throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH);
}
await fs.unlink(fullPath);
logger.debug(`Deleted persona file: ${filePath}`);
} catch (error) {
logger.error(`Failed to delete persona ${filePath}: ${error}`);
throw ErrorHandler.wrapError(error, 'Failed to delete persona', ErrorCategory.SYSTEM_ERROR);
}
}
/**
* Check if a persona file exists
*/
async exists(filePath: string): Promise<boolean> {
try {
// Resolve full path if relative
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath);
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
/**
* List all personas
*/
async list(): Promise<PersonaElement[]> {
try {
// Ensure directory exists
await ensureDirectory(this.personasDir);
const files = await fs.readdir(this.personasDir);
const markdownFiles = files.filter(file => file.endsWith('.md'));
const personas: PersonaElement[] = [];
for (const file of markdownFiles) {
try {
const persona = await this.load(file);
personas.push(persona);
} catch (error) {
logger.error(`Error loading persona ${file}: ${error}`);
// Continue with other personas
}
}
logger.debug(`Loaded ${personas.length} personas from ${this.personasDir}`);
return personas;
} catch (error) {
logger.error(`Failed to list personas: ${error}`);
return [];
}
}
/**
* Find a persona by predicate
*/
async find(predicate: (element: PersonaElement) => boolean): Promise<PersonaElement | undefined> {
const personas = await this.list();
return personas.find(predicate);
}
/**
* Find multiple personas by predicate
*/
async findMany(predicate: (element: PersonaElement) => boolean): Promise<PersonaElement[]> {
const personas = await this.list();
return personas.filter(predicate);
}
/**
* Validate a persona element
*/
validate(element: PersonaElement): ElementValidationResult {
return element.validate();
}
/**
* Validate a file path
*/
validatePath(filePath: string): boolean {
try {
validatePath(filePath);
// Additional check: must be .md file
if (!filePath.endsWith('.md')) {
return false;
}
// Must be within personas directory
const fullPath = path.resolve(filePath);
const personasDirPath = path.resolve(this.personasDir);
return fullPath.startsWith(personasDirPath);
} catch {
return false;
}
}
/**
* Get element type
*/
getElementType(): ElementType {
return ElementType.PERSONA;
}
/**
* Get file extension
*/
getFileExtension(): string {
return '.md';
}
/**
* Import persona from data
* SECURITY FIX #3: Uses SecureYamlParser instead of unsafe YAML parsing to prevent
* YAML deserialization attacks and injection vulnerabilities
*/
async importElement(data: string, format: 'json' | 'yaml' | 'markdown' = 'markdown'): Promise<PersonaElement> {
try {
const persona = new PersonaElement({});
if (format === 'markdown') {
persona.deserialize(data);
} else if (format === 'json') {
const jsonData = JSON.parse(data);
persona.deserialize(this.jsonToMarkdown(jsonData));
} else if (format === 'yaml') {
// HIGH SEVERITY FIX: Use SecureYamlParser to prevent YAML injection attacks
// Previously: Used unsafe YAML parsing without validation
// Now: Uses SecureYamlParser which validates content and prevents malicious patterns
try {
const parsed = SecureYamlParser.parse(data, {
maxYamlSize: 64 * 1024, // 64KB limit
validateContent: true
});
// Log security event for audit trail
SecurityMonitor.logSecurityEvent({
type: 'YAML_PARSE_SUCCESS',
severity: 'LOW',
source: 'PersonaElementManager.importElement',
details: 'YAML content safely parsed during import'
});
// Convert parsed YAML to markdown format
persona.deserialize(this.jsonToMarkdown(parsed.data));
} catch (securityError) {
// Log the security violation
SecurityMonitor.logSecurityEvent({
type: 'YAML_INJECTION_ATTEMPT',
severity: 'HIGH',
source: 'PersonaElementManager.importElement',
details: `YAML parsing failed security validation: ${securityError}`
});
throw securityError;
}
} else {
throw ErrorHandler.createError(`Unsupported format: ${format}`, ErrorCategory.VALIDATION_ERROR, SystemErrorCodes.UNSUPPORTED_FORMAT);
}
return persona;
} catch (error) {
logger.error(`Failed to import persona: ${error}`);
throw ErrorHandler.wrapError(error, 'Persona element import failed', ErrorCategory.SYSTEM_ERROR);
}
}
/**
* Export persona to data
* SECURITY FIX #2: Uses static import of js-yaml at top of file instead of
* dynamic require() for better security and bundling
* SECURITY FIX #3: Uses secure YAML dumping with safety options
*/
async exportElement(element: PersonaElement, format: 'json' | 'yaml' | 'markdown' = 'markdown'): Promise<string> {
try {
if (format === 'markdown') {
return element.serialize();
} else if (format === 'json') {
const legacy = element.toLegacy();
return JSON.stringify({ ...legacy, content: element.content }, null, 2);
} else if (format === 'yaml') {
const legacy = element.toLegacy();
// CRITICAL FIX: Using safe YAML dump with security options
// Previously: Used dynamic require without safety options
// Now: Uses static import with safe schema and security flags
return yaml.dump({ ...legacy, content: element.content }, {
schema: yaml.FAILSAFE_SCHEMA, // Use restricted schema
skipInvalid: true, // Skip invalid data instead of throwing
noRefs: true, // Prevent reference attacks
noCompatMode: true // Use strict YAML mode
});
} else {
throw ErrorHandler.createError(`Unsupported format: ${format}`, ErrorCategory.VALIDATION_ERROR, SystemErrorCodes.UNSUPPORTED_FORMAT);
}
} catch (error) {
logger.error(`Failed to export persona: ${error}`);
throw ErrorHandler.wrapError(error, 'Persona element export failed', ErrorCategory.SYSTEM_ERROR);
}
}
/**
* Helper: Convert JSON data to markdown format
* SECURITY FIX #2: Uses statically imported yaml module
* SECURITY FIX #3: Uses secure YAML dumping with safety options
* Note: This is for internal conversion only, user-provided YAML must use SecureYamlParser
*/
private jsonToMarkdown(data: any): string {
const { content, ...metadata } = data;
// Using safe YAML dump with security options
const yamlFrontmatter = yaml.dump(metadata, {
schema: yaml.FAILSAFE_SCHEMA, // Use restricted schema
skipInvalid: true, // Skip invalid data
noRefs: true, // Prevent reference attacks
noCompatMode: true // Use strict YAML mode
});
return `---\n${yamlFrontmatter}---\n\n${content || ''}`;
}
/**
* Create a new persona with default metadata
*/
create(metadata: Partial<PersonaElementMetadata>): PersonaElement {
const defaultMetadata: Partial<PersonaElementMetadata> = {
name: 'New Persona',
description: 'A new persona',
version: '1.0.0',
category: 'personal',
age_rating: 'all',
ai_generated: false,
generation_method: 'human',
price: 'free',
license: 'CC-BY-SA-4.0',
created_date: new Date().toISOString().split('T')[0],
triggers: [],
content_flags: []
};
return new PersonaElement({ ...defaultMetadata, ...metadata });
}
/**
* Get default filename for a persona
*/
getDefaultFilename(persona: PersonaElement): string {
// Convert name to safe filename
const safeName = persona.metadata.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replaceAll(/(^-+)|(-+$)/g, ''); // Remove leading/trailing hyphens
return `${safeName}.md`;
}
}