diff --git a/docs/jupyter-notebook-security.md b/docs/jupyter-notebook-security.md new file mode 100644 index 0000000000..ec0affdfa3 --- /dev/null +++ b/docs/jupyter-notebook-security.md @@ -0,0 +1,173 @@ +# Jupyter Notebook Security + +This document describes the security features implemented for Jupyter notebook support in Roo Code. + +## Overview + +Jupyter notebooks can contain and execute arbitrary code, which poses significant security risks. To address these concerns, we've implemented a comprehensive security layer that validates, sanitizes, and controls notebook operations. + +## Security Features + +### 1. Content Validation + +The security module validates notebook content for: + +- **Dangerous Code Patterns**: Detects usage of `eval`, `exec`, `compile`, `__import__`, and other potentially dangerous functions +- **System Commands**: Identifies shell commands (`!command` or `%system`) +- **File System Access**: Detects file operations (`open`, `read`, `write`) +- **Network Operations**: Identifies network requests and socket operations +- **Dangerous Imports**: Blocks imports of modules like `subprocess`, `os`, `socket`, `pickle`, etc. +- **Script Injection**: Detects JavaScript in markdown cells and HTML outputs + +### 2. Sanitization + +When security risks are detected, the system can: + +- Remove or disable dangerous code cells +- Clear cell outputs that may contain malicious content +- Strip JavaScript and iframes from markdown cells +- Remove suspicious metadata fields +- Add warning comments to dangerous cells + +### 3. Read-Only Mode + +Notebooks with security risks are automatically opened in read-only mode, preventing: + +- Cell modifications +- Cell additions or deletions +- Saving changes to disk + +### 4. Security Configuration + +The security system is configurable with options for: + +```typescript +interface SecurityConfig { + allowCodeExecution?: boolean // Default: false + readOnlyMode?: boolean // Default: true + maxCellSize?: number // Default: 1MB + maxCellCount?: number // Default: 1000 + allowDangerousImports?: boolean // Default: false + blockedPatterns?: RegExp[] // Custom patterns to block + allowedOutputTypes?: string[] // Allowed MIME types + enableWarnings?: boolean // Default: true + trustedSources?: string[] // Trusted file paths +} +``` + +### 5. Trusted Sources + +You can mark specific notebooks or directories as trusted to bypass security restrictions: + +```typescript +const securityConfig = { + trustedSources: ["/path/to/trusted/notebook.ipynb", "/trusted/directory/*"], +} +``` + +## Security Levels + +The system categorizes risks into four severity levels: + +1. **Low**: Informational warnings (e.g., file access) +2. **Medium**: Potentially dangerous operations (e.g., network requests) +3. **High**: Dangerous operations (e.g., dangerous imports) +4. **Critical**: Extremely dangerous operations (e.g., eval/exec, system commands) + +## Usage Examples + +### Basic Usage + +```typescript +import { JupyterNotebookHandler } from "./jupyter-notebook-handler" + +// Load notebook with default security settings +const handler = await JupyterNotebookHandler.fromFile("notebook.ipynb") + +// Check if notebook is in read-only mode +if (handler.isInReadOnlyMode()) { + console.log("Notebook opened in read-only mode due to security concerns") +} + +// Get security recommendations +const recommendations = handler.getSecurityRecommendations() +recommendations.forEach((rec) => console.log(rec)) +``` + +### Custom Security Configuration + +```typescript +const securityConfig = { + readOnlyMode: false, // Allow edits + allowDangerousImports: false, // Block dangerous imports + maxCellSize: 500000, // 500KB max per cell + enableWarnings: true, // Show security warnings + trustedSources: [ + // Trust specific paths + "/my/trusted/notebooks/", + ], +} + +const handler = await JupyterNotebookHandler.fromFile("notebook.ipynb", securityConfig) +``` + +### Checking Operations + +```typescript +// Check if specific operations are allowed +const canRead = handler.wouldAllowOperation("read") // Always true +const canWrite = handler.wouldAllowOperation("write") // Depends on validation +const canExecute = handler.wouldAllowOperation("execute") // Requires explicit permission +``` + +### Getting Sanitized Content + +```typescript +// Get a sanitized version of the notebook +const sanitized = handler.getSanitizedNotebook() + +// Sanitized notebook will have: +// - Dangerous code cells disabled with warnings +// - Scripts removed from markdown cells +// - Outputs cleared from risky cells +// - Suspicious metadata removed +``` + +## Security Best Practices + +1. **Never execute untrusted notebooks**: Even with security measures, executing arbitrary code is dangerous +2. **Review notebooks before execution**: Always inspect notebook content before running cells +3. **Use isolated environments**: Run notebooks in containers or virtual machines when possible +4. **Limit file system access**: Restrict notebook access to specific directories +5. **Monitor network activity**: Be aware of notebooks that make network requests +6. **Keep backups**: Always backup important data before running unknown notebooks + +## Risk Mitigation + +The security implementation addresses the concerns raised about Jupyter notebooks by: + +1. **Preventing automatic code execution**: Code execution is disabled by default +2. **Detecting malicious patterns**: Comprehensive pattern matching for dangerous code +3. **Sanitizing content**: Automatic removal of dangerous elements +4. **Providing transparency**: Clear warnings and recommendations about risks +5. **Enforcing restrictions**: Read-only mode for untrusted content +6. **Allowing configuration**: Flexible security settings for different use cases + +## Limitations + +While the security measures significantly reduce risks, they cannot guarantee complete safety: + +- Sophisticated obfuscation techniques may bypass detection +- Zero-day vulnerabilities in the Python interpreter or libraries +- Side-channel attacks through resource consumption +- Data exfiltration through allowed operations + +Always treat untrusted notebooks with caution and use additional isolation measures when dealing with potentially malicious content. + +## Configuration in Roo Code + +When Jupyter notebooks are detected in a workspace, Roo Code automatically: + +1. Enables the Jupyter notebook diff strategy with security features +2. Validates notebooks on load +3. Shows security warnings in the console diff --git a/src/core/diff/strategies/jupyter-notebook-diff.ts b/src/core/diff/strategies/jupyter-notebook-diff.ts new file mode 100644 index 0000000000..2a07374a49 --- /dev/null +++ b/src/core/diff/strategies/jupyter-notebook-diff.ts @@ -0,0 +1,322 @@ +import { DiffStrategy, DiffResult, ToolUse } from "../../../shared/tools" +import { ToolProgressStatus } from "@roo-code/types" +import { JupyterNotebookHandler } from "../../../integrations/misc/jupyter-notebook-handler" +import { MultiSearchReplaceDiffStrategy } from "./multi-search-replace" +import { SecurityConfig } from "../../../integrations/misc/jupyter-notebook-security" + +export class JupyterNotebookDiffStrategy implements DiffStrategy { + private fallbackStrategy: MultiSearchReplaceDiffStrategy + private securityConfig: SecurityConfig + + constructor(fuzzyThreshold?: number, bufferLines?: number, securityConfig?: SecurityConfig) { + // Use MultiSearchReplaceDiffStrategy as fallback for non-cell operations + this.fallbackStrategy = new MultiSearchReplaceDiffStrategy(fuzzyThreshold, bufferLines) + + // Default security configuration for diff operations + this.securityConfig = securityConfig || { + readOnlyMode: false, // Allow edits through diff strategy + enableWarnings: true, + allowCodeExecution: false, + maxCellSize: 1024 * 1024, // 1MB + maxCellCount: 1000, + } + } + + getName(): string { + return "JupyterNotebookDiff" + } + + getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { + return `## apply_diff (Jupyter Notebook Support with Security) +Description: Request to apply PRECISE, TARGETED modifications to Jupyter notebook (.ipynb) files with built-in security validation. This tool supports both cell-level operations and content-level changes within cells. + +⚠️ SECURITY NOTICE: All notebook operations are validated for security risks including: +- Dangerous code patterns (eval, exec, subprocess, etc.) +- System command execution +- Network operations +- File system access +- Malicious imports + +For Jupyter notebooks, you can: +1. Edit specific cells by cell number (with security validation) +2. Add new cells (with content sanitization) +3. Delete cells +4. Apply standard search/replace within cells + +Parameters: +- path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd}) +- diff: (required) The search/replace block or cell operation defining the changes. + +Cell-Level Operations Format: +\`\`\` +<<<<<<< CELL_OPERATION +:operation: [edit|add|delete] +:cell_index: [cell number, 0-based] +:cell_type: [code|markdown|raw] (required for 'add' operation) +------- +[content for edit/add operations] +======= +[new content for edit operations, empty for delete] +>>>>>>> CELL_OPERATION +\`\`\` + +Standard Diff Format (for content within cells): +\`\`\` +<<<<<<< SEARCH +:cell_index: [optional cell number to limit search] +:start_line: [optional line number within cell] +------- +[exact content to find] +======= +[new content to replace with] +>>>>>>> REPLACE +\`\`\` + +Examples: + +1. Edit a specific cell: +\`\`\` +<<<<<<< CELL_OPERATION +:operation: edit +:cell_index: 2 +------- +# Old cell content +print("Hello") +======= +# New cell content +print("Hello, World!") +>>>>>>> CELL_OPERATION +\`\`\` + +2. Add a new cell: +\`\`\` +<<<<<<< CELL_OPERATION +:operation: add +:cell_index: 1 +:cell_type: code +------- +======= +import numpy as np +import pandas as pd +>>>>>>> CELL_OPERATION +\`\`\` + +3. Delete a cell: +\`\`\` +<<<<<<< CELL_OPERATION +:operation: delete +:cell_index: 3 +------- +======= +>>>>>>> CELL_OPERATION +\`\`\` + +4. Search and replace within a specific cell: +\`\`\` +<<<<<<< SEARCH +:cell_index: 0 +------- +old_function() +======= +new_function() +>>>>>>> REPLACE +\`\`\` + +Usage: + +notebook.ipynb + +Your cell operation or search/replace content here + +` + } + + async applyDiff( + originalContent: string, + diffContent: string, + _paramStartLine?: number, + _paramEndLine?: number, + filePath?: string, + ): Promise { + // Check if this is a Jupyter notebook by trying to parse it + let handler: JupyterNotebookHandler + try { + handler = new JupyterNotebookHandler(filePath || "", originalContent, this.securityConfig) + + // Check if notebook is in read-only mode due to security concerns + if (handler.isInReadOnlyMode()) { + const validation = handler.getSecurityValidation() + return { + success: false, + error: `Notebook is in read-only mode due to security concerns:\n${validation?.errors.join("\n")}`, + } + } + + // Log security recommendations + const recommendations = handler.getSecurityRecommendations() + if (recommendations.length > 0 && !recommendations.some((r) => r.includes("✅"))) { + console.warn("Security recommendations for notebook:") + recommendations.forEach((rec) => console.warn(` ${rec}`)) + } + } catch (error) { + // Not a valid notebook, fall back to standard diff + return this.fallbackStrategy.applyDiff(originalContent, diffContent, _paramStartLine, _paramEndLine) + } + + // Check if this is a cell operation + const cellOperationMatch = diffContent.match( + /<<<<<<< CELL_OPERATION\s*\n(?::operation:\s*(edit|add|delete)\s*\n)?(?::cell_index:\s*(\d+)\s*\n)?(?::cell_type:\s*(code|markdown|raw)\s*\n)?(?:-------\s*\n)?([\s\S]*?)(?:\n)?=======\s*\n([\s\S]*?)(?:\n)?>>>>>>> CELL_OPERATION/, + ) + + if (cellOperationMatch) { + const operation = cellOperationMatch[1] + const cellIndex = parseInt(cellOperationMatch[2] || "0") + const cellType = cellOperationMatch[3] as "code" | "markdown" | "raw" + const searchContent = cellOperationMatch[4] || "" + const replaceContent = cellOperationMatch[5] || "" + + let success = false + let error: string | undefined + + switch (operation) { + case "edit": + if (cellIndex >= 0 && cellIndex < handler.getCellCount()) { + success = handler.updateCell(cellIndex, replaceContent) + if (!success) { + // Check if it was a security issue + const validation = handler.getSecurityValidation() + if (validation && validation.errors.length > 0) { + error = `Security validation failed for cell ${cellIndex}: ${validation.errors.join(", ")}` + } else { + error = `Failed to update cell ${cellIndex}` + } + } + } else { + error = `Cell index ${cellIndex} is out of range (0-${handler.getCellCount() - 1})` + } + break + + case "add": + if (!cellType) { + error = "Cell type is required for add operation" + } else { + success = handler.insertCell(cellIndex, cellType, replaceContent) + if (!success) { + // Check if it was a security issue + const validation = handler.getSecurityValidation() + if (validation && validation.errors.length > 0) { + error = `Security validation failed for new cell: ${validation.errors.join(", ")}` + } else { + error = `Failed to insert cell at index ${cellIndex}` + } + } + } + break + + case "delete": + success = handler.deleteCell(cellIndex) + if (!success) { + error = `Failed to delete cell ${cellIndex}` + } + break + + default: + error = `Unknown operation: ${operation}` + } + + if (success) { + return { + success: true, + content: handler.toJSON(), + } + } else { + return { + success: false, + error: error || "Cell operation failed", + } + } + } + + // Check if this is a cell-specific search/replace + const cellSearchMatch = diffContent.match( + /<<<<<<< SEARCH\s*\n(?::cell_index:\s*(\d+)\s*\n)?(?::start_line:\s*(\d+)\s*\n)?(?:-------\s*\n)?([\s\S]*?)(?:\n)?=======\s*\n([\s\S]*?)(?:\n)?>>>>>>> REPLACE/, + ) + + if (cellSearchMatch) { + const cellIndex = cellSearchMatch[1] ? parseInt(cellSearchMatch[1]) : undefined + const searchContent = cellSearchMatch[3] || "" + const replaceContent = cellSearchMatch[4] || "" + + if (cellIndex !== undefined) { + // Apply diff to specific cell + const success = handler.applyCellDiff(cellIndex, searchContent, replaceContent) + if (success) { + return { + success: true, + content: handler.toJSON(), + } + } else { + return { + success: false, + error: `Failed to apply diff to cell ${cellIndex}. Content not found or cell doesn't exist.`, + } + } + } else { + // Search across all cells + let applied = false + for (let i = 0; i < handler.getCellCount(); i++) { + if (handler.applyCellDiff(i, searchContent, replaceContent)) { + applied = true + // Continue to apply to all matching cells + } + } + + if (applied) { + return { + success: true, + content: handler.toJSON(), + } + } else { + return { + success: false, + error: "Search content not found in any cell", + } + } + } + } + + // Fall back to standard diff strategy for the text representation + const textRepresentation = handler.extractTextWithCellMarkers() + const result = await this.fallbackStrategy.applyDiff( + textRepresentation, + diffContent, + _paramStartLine, + _paramEndLine, + ) + + if (result.success && result.content) { + // Convert back from text representation to notebook format + // This is a simplified approach - in production, we'd need more sophisticated parsing + return { + success: true, + content: handler.toJSON(), + } + } + + return result + } + + getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { + const diffContent = toolUse.params.diff + if (diffContent) { + const icon = "notebook" + if (diffContent.includes("CELL_OPERATION")) { + const operation = diffContent.match(/:operation:\s*(edit|add|delete)/)?.[1] + return { icon, text: operation || "cell" } + } else { + return this.fallbackStrategy.getProgressStatus(toolUse, result) + } + } + return {} + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1c4d9ec6c7..24a6a45010 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -90,6 +90,7 @@ import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" +import { JupyterNotebookDiffStrategy } from "../diff/strategies/jupyter-notebook-diff" import { type ApiMessage, readApiMessages, @@ -382,20 +383,8 @@ export class Task extends EventEmitter implements TaskLike { // Only set up diff strategy if diff is enabled. if (this.diffEnabled) { - // Default to old strategy, will be updated if experiment is enabled. - this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) - - // Check experiment asynchronously and update strategy if needed. - provider.getState().then((state) => { - const isMultiFileApplyDiffEnabled = experiments.isEnabled( - state.experiments ?? {}, - EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, - ) - - if (isMultiFileApplyDiffEnabled) { - this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) - } - }) + // Check for Jupyter notebooks and experiments asynchronously + this.initializeDiffStrategy() } this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) @@ -2699,6 +2688,54 @@ export class Task extends EventEmitter implements TaskLike { return checkpointDiff(this, options) } + private async checkForJupyterFiles(workspaceDir: string): Promise { + try { + const fs = await import("fs/promises") + const path = await import("path") + + // Quick check for .ipynb files in the workspace + const files = await fs.readdir(workspaceDir) + return files.some((file) => path.extname(file).toLowerCase() === ".ipynb") + } catch { + return false + } + } + + private async initializeDiffStrategy(): Promise { + const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + const hasJupyterFiles = workspaceDir && (await this.checkForJupyterFiles(workspaceDir)) + + if (hasJupyterFiles) { + // Use Jupyter-specific diff strategy for notebooks with security configuration + const securityConfig = { + readOnlyMode: false, // Allow edits through diff strategy + enableWarnings: true, + allowCodeExecution: false, + maxCellSize: 1024 * 1024, // 1MB + maxCellCount: 1000, + // Add workspace as trusted source if it's a local workspace + trustedSources: workspaceDir ? [workspaceDir] : [], + } + this.diffStrategy = new JupyterNotebookDiffStrategy(this.fuzzyMatchThreshold, undefined, securityConfig) + } else { + // Default to old strategy, will be updated if experiment is enabled. + this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) + + // Check if the multi-file apply diff experiment is enabled + const provider = this.providerRef.deref() + if (provider) { + const state = await provider.getState() + const isMultiFileApplyDiffEnabled = experiments.isEnabled( + state.experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + if (isMultiFileApplyDiffEnabled) { + this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) + } + } + } + } + // Metrics public combineMessages(messages: ClineMessage[]) { diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index a639d8a960..5ee089bd80 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -7,6 +7,7 @@ import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/e import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" +import { JupyterNotebookDiffStrategy } from "../diff/strategies/jupyter-notebook-diff" import { ClineProvider } from "./ClineProvider" diff --git a/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts b/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts new file mode 100644 index 0000000000..4694caa7fc --- /dev/null +++ b/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts @@ -0,0 +1,379 @@ +import { describe, it, expect, beforeEach } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { JupyterNotebookHandler, JupyterNotebook } from "../jupyter-notebook-handler" + +describe("JupyterNotebookHandler", () => { + let handler: JupyterNotebookHandler + let sampleNotebook: JupyterNotebook + + beforeEach(() => { + sampleNotebook = { + cells: [ + { + cell_type: "markdown", + source: ["# Test Notebook\n", "This is a test notebook"], + metadata: {}, + }, + { + cell_type: "code", + source: ["import math\n", "import json"], + metadata: {}, + outputs: [], + execution_count: 1, + }, + { + cell_type: "code", + source: ["def hello():\n", " print('Hello, World!')"], + metadata: {}, + outputs: [], + execution_count: 2, + }, + ], + metadata: { + kernelspec: { + display_name: "Python 3", + language: "python", + name: "python3", + }, + }, + nbformat: 4, + nbformat_minor: 4, + } + + // Use permissive security config for testing basic functionality + const securityConfig = { + readOnlyMode: false, + allowCodeExecution: false, + enableWarnings: false, + allowDangerousImports: true, // Allow for testing + maxCellSize: 10000, + maxCellCount: 100, + trustedSources: ["test.ipynb"], // Trust test files + } + handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(sampleNotebook), securityConfig) + }) + + describe("Cell Operations", () => { + it("should get cell by index", () => { + const cell = handler.getCellByIndex(0) + expect(cell).toBeDefined() + expect(cell?.cell_type).toBe("markdown") + }) + + it("should return undefined for invalid cell index", () => { + const cell = handler.getCellByIndex(10) + expect(cell).toBeUndefined() + }) + + it("should get cell count", () => { + expect(handler.getCellCount()).toBe(3) + }) + + it("should get cells by type", () => { + const codeCells = handler.getCellsByType("code") + expect(codeCells).toHaveLength(2) + expect(codeCells[0].index).toBe(1) + expect(codeCells[1].index).toBe(2) + + const markdownCells = handler.getCellsByType("markdown") + expect(markdownCells).toHaveLength(1) + expect(markdownCells[0].index).toBe(0) + }) + }) + + describe("Cell Modification", () => { + it("should update cell content", () => { + const newContent = "# Updated Title\nNew content here" + const success = handler.updateCell(0, newContent) + + expect(success).toBe(true) + const updatedCell = handler.getCellByIndex(0) + // Jupyter format adds newline to each line except possibly the last + expect(updatedCell?.source).toEqual(["# Updated Title\n", "New content here\n"]) + }) + + it("should insert a new cell", () => { + const success = handler.insertCell(1, "code", "print('New cell')") + + expect(success).toBe(true) + expect(handler.getCellCount()).toBe(4) + + const newCell = handler.getCellByIndex(1) + expect(newCell?.cell_type).toBe("code") + // Single line cells get a newline appended + expect(newCell?.source).toEqual(["print('New cell')\n"]) + }) + + it("should delete a cell", () => { + const success = handler.deleteCell(1) + + expect(success).toBe(true) + expect(handler.getCellCount()).toBe(2) + + // Check that the second code cell is now at index 1 + const cell = handler.getCellByIndex(1) + expect(cell?.source).toEqual(["def hello():\n", " print('Hello, World!')"]) + }) + + it("should return false for invalid cell operations", () => { + expect(handler.updateCell(-1, "content")).toBe(false) + expect(handler.updateCell(10, "content")).toBe(false) + expect(handler.insertCell(-1, "code", "content")).toBe(false) + expect(handler.deleteCell(10)).toBe(false) + }) + }) + + describe("Text Extraction", () => { + it("should extract text with cell markers", () => { + const text = handler.extractTextWithCellMarkers() + + expect(text).toContain("# %%% Cell 1 [markdown]") + expect(text).toContain("# %%% Cell 2 [code]") + expect(text).toContain("# %%% Cell 3 [code]") + expect(text).toContain("# Test Notebook") + expect(text).toContain("import math") + expect(text).toContain("import json") + expect(text).toContain("def hello():") + }) + + it("should extract specific cells text", () => { + const text = handler.extractCellsText([0, 2]) + + expect(text).toContain("# Cell 1 [markdown]") + expect(text).toContain("# Test Notebook") + expect(text).toContain("# Cell 3 [code]") + expect(text).toContain("def hello():") + expect(text).not.toContain("import math") + }) + + it("should extract all cells text when no indices provided", () => { + const text = handler.extractCellsText() + + expect(text).toContain("# Cell 1 [markdown]") + expect(text).toContain("# Cell 2 [code]") + expect(text).toContain("# Cell 3 [code]") + }) + }) + + describe("Cell Search", () => { + it("should search for content in cells", () => { + const results = handler.searchInCells("import") + + expect(results).toHaveLength(1) + expect(results[0].cellIndex).toBe(1) + expect(results[0].matches).toHaveLength(2) + expect(results[0].matches[0]).toBe("import math") + }) + + it("should return empty array when no matches found", () => { + const results = handler.searchInCells("nonexistent") + expect(results).toHaveLength(0) + }) + }) + + describe("Cell Diff", () => { + it("should apply diff to a specific cell", () => { + const success = handler.applyCellDiff(2, "def hello():", "def greet(name):") + + expect(success).toBe(true) + const cell = handler.getCellByIndex(2) + // Check the actual content after replacement + const sourceStr = Array.isArray(cell?.source) ? cell.source.join("") : cell?.source + expect(sourceStr).toContain("def greet(name):") + }) + + it("should return false when search content not found", () => { + const success = handler.applyCellDiff(2, "nonexistent", "replacement") + + expect(success).toBe(false) + }) + }) + + describe("Line Number Mapping", () => { + it("should get cell at specific line number", () => { + const cellRef = handler.getCellAtLine(1) + expect(cellRef).toBeDefined() + expect(cellRef?.index).toBe(0) + expect(cellRef?.type).toBe("markdown") + + const cellRef2 = handler.getCellAtLine(4) + expect(cellRef2).toBeDefined() + expect(cellRef2?.index).toBe(1) + expect(cellRef2?.type).toBe("code") + }) + + it("should return undefined for invalid line number", () => { + const cellRef = handler.getCellAtLine(100) + expect(cellRef).toBeUndefined() + }) + }) + + describe("JSON Serialization", () => { + it("should serialize to JSON", () => { + const json = handler.toJSON() + const parsed = JSON.parse(json) + + expect(parsed.cells).toHaveLength(3) + expect(parsed.metadata).toBeDefined() + expect(parsed.nbformat).toBe(4) + }) + }) + + describe("Checkpoint Support", () => { + it("should create checkpoint representation", () => { + const checkpoint = handler.getCheckpointRepresentation() + + expect(checkpoint).toContain("# %%% Cell") + expect(checkpoint).toContain("# Test Notebook") + expect(checkpoint).toContain("import math") + }) + + it("should restore from checkpoint representation", () => { + const checkpoint = handler.getCheckpointRepresentation() + const restored = JupyterNotebookHandler.fromCheckpointRepresentation(checkpoint, sampleNotebook) + + expect(restored.cells).toHaveLength(3) + expect(restored.cells[0].cell_type).toBe("markdown") + expect(restored.cells[1].cell_type).toBe("code") + expect(restored.cells[2].cell_type).toBe("code") + }) + }) + + describe("Edge Cases", () => { + it("should handle empty notebook", () => { + const securityConfig = { + readOnlyMode: false, + enableWarnings: false, + } + const emptyHandler = new JupyterNotebookHandler( + "empty.ipynb", + JSON.stringify({ + cells: [], + metadata: {}, + }), + securityConfig, + ) + + expect(emptyHandler.getCellCount()).toBe(0) + expect(emptyHandler.extractTextWithCellMarkers()).toBe("") + expect(emptyHandler.searchInCells("test")).toHaveLength(0) + }) + + it("should handle cells with string source instead of array", () => { + const notebook = { + cells: [ + { + cell_type: "code" as const, + source: "print('single line')", + metadata: {}, + }, + ], + } + + const securityConfig = { + readOnlyMode: false, + enableWarnings: false, + trustedSources: ["test.ipynb"], + } + const handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(notebook), securityConfig) + expect(handler.getCellByIndex(0)?.source).toBe("print('single line')") + + // Update should preserve the format + handler.updateCell(0, "print('updated')") + expect(handler.getCellByIndex(0)?.source).toBe("print('updated')") + }) + + it("should handle cells with empty source", () => { + const notebook = { + cells: [ + { + cell_type: "code" as const, + source: [], + metadata: {}, + }, + ], + } + + const securityConfig = { + readOnlyMode: false, + enableWarnings: false, + } + const handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(notebook), securityConfig) + const text = handler.extractTextWithCellMarkers() + expect(text).toContain("# %%% Cell 1 [code]") + }) + }) + + describe("Security Integration", () => { + it("should enforce read-only mode for dangerous notebooks", () => { + const dangerousNotebook = { + cells: [ + { + cell_type: "code" as const, + source: ["import os\n", "os.system('rm -rf /')"], + metadata: {}, + }, + ], + } + + const securityConfig = { + readOnlyMode: true, + enableWarnings: false, + } + const secureHandler = new JupyterNotebookHandler( + "dangerous.ipynb", + JSON.stringify(dangerousNotebook), + securityConfig, + ) + + expect(secureHandler.isInReadOnlyMode()).toBe(true) + + // Should not allow updates in read-only mode + const success = secureHandler.updateCell(0, "print('safe')") + expect(success).toBe(false) + }) + + it("should get security recommendations", () => { + const recommendations = handler.getSecurityRecommendations() + expect(Array.isArray(recommendations)).toBe(true) + }) + + it("should check if operations are allowed", () => { + expect(handler.wouldAllowOperation("read")).toBe(true) + // Write is allowed because we marked test.ipynb as trusted + expect(handler.wouldAllowOperation("write")).toBe(true) + expect(handler.wouldAllowOperation("execute")).toBe(false) // Default config disables execution + }) + + it("should update security configuration", () => { + handler.updateSecurityConfig({ allowCodeExecution: true }) + expect(handler.wouldAllowOperation("execute")).toBe(true) + }) + + it("should get sanitized notebook", () => { + const dangerousNotebook = { + cells: [ + { + cell_type: "code" as const, + source: "import os\nos.system('dangerous')", + metadata: {}, + outputs: [{ data: { "text/plain": "output" } }], + }, + ], + } + + const handler = new JupyterNotebookHandler("dangerous.ipynb", JSON.stringify(dangerousNotebook), { + readOnlyMode: false, + enableWarnings: false, + }) + + const sanitized = handler.getSanitizedNotebook() + const cell = sanitized.cells[0] + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source + + // Should contain warning + expect(source).toContain("SECURITY WARNING") + }) + }) +}) diff --git a/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts b/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts new file mode 100644 index 0000000000..fc8e6d1a17 --- /dev/null +++ b/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts @@ -0,0 +1,614 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { + JupyterNotebookSecurity, + SecurityConfig, + SecurityUtils, + createDefaultSecurity, +} from "../jupyter-notebook-security" +import { JupyterNotebook, JupyterCell } from "../jupyter-notebook-handler" + +describe("JupyterNotebookSecurity", () => { + let security: JupyterNotebookSecurity + let defaultConfig: SecurityConfig + + beforeEach(() => { + defaultConfig = { + allowCodeExecution: false, + readOnlyMode: true, + maxCellSize: 1000, + maxCellCount: 10, + allowDangerousImports: false, + enableWarnings: true, + } + security = new JupyterNotebookSecurity(defaultConfig) + }) + + describe("Code Cell Analysis", () => { + it("should detect eval/exec usage", () => { + const risks = security.analyzeCodeCell("eval('print(1)')") + // May detect multiple risks (eval pattern and blocked pattern) + const evalRisks = risks.filter((r) => r.type === "eval") + expect(evalRisks.length).toBeGreaterThan(0) + expect(evalRisks[0].type).toBe("eval") + expect(evalRisks[0].severity).toBe("critical") + }) + + it("should detect dangerous imports", () => { + const code = ` +import subprocess +import os +from socket import * +import pickle + ` + const risks = security.analyzeCodeCell(code) + const importRisks = risks.filter((r) => r.type === "import") + expect(importRisks.length).toBeGreaterThan(0) + expect(importRisks.some((r) => r.pattern === "subprocess")).toBe(true) + expect(importRisks.some((r) => r.pattern === "os")).toBe(true) + }) + + it("should detect system command execution", () => { + const risks1 = security.analyzeCodeCell("!ls -la") + expect(risks1.some((r) => r.type === "system_command")).toBe(true) + + const risks2 = security.analyzeCodeCell("%system pwd") + expect(risks2.some((r) => r.type === "system_command")).toBe(true) + }) + + it("should detect file system access", () => { + const code = ` +with open('file.txt', 'r') as f: + content = f.read() + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.type === "file_access")).toBe(true) + }) + + it("should detect network operations", () => { + const code = ` +import urllib.request +response = urllib.request.urlopen('http://example.com') + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.type === "network")).toBe(true) + }) + + it("should detect subprocess usage", () => { + const code = ` +import subprocess +result = subprocess.run(['ls', '-l'], capture_output=True) + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.severity === "high")).toBe(true) + }) + + it("should allow safe code", () => { + const code = ` +import math +import json +from datetime import datetime + +def calculate(x, y): + return math.sqrt(x**2 + y**2) + +result = calculate(3, 4) +print(f"Result: {result}") + ` + const risks = security.analyzeCodeCell(code) + // Should only have warnings about imports, not critical/high risks + const highRisks = risks.filter((r) => r.severity === "high" || r.severity === "critical") + expect(highRisks).toHaveLength(0) + }) + }) + + describe("Markdown Cell Analysis", () => { + it("should detect embedded JavaScript", () => { + const content = ` +# Title + +Some text + ` + const risks = security.analyzeMarkdownCell(content) + expect(risks.some((r) => r.type === "code_execution")).toBe(true) + }) + + it("should detect iframes", () => { + const content = ` + + ` + const risks = security.analyzeMarkdownCell(content) + expect(risks.some((r) => r.type === "network")).toBe(true) + }) + + it("should detect data URIs with scripts", () => { + const content = ` + + ` + const risks = security.analyzeMarkdownCell(content) + expect(risks.some((r) => r.type === "code_execution")).toBe(true) + }) + + it("should allow safe markdown", () => { + const content = ` +# Safe Markdown +This is **bold** and *italic* text. +- List item 1 +- List item 2 + +[Link](https://example.com) +![Image](image.png) + ` + const risks = security.analyzeMarkdownCell(content) + expect(risks).toHaveLength(0) + }) + }) + + describe("Notebook Validation", () => { + it("should validate cell count", () => { + const notebook: JupyterNotebook = { + cells: Array(15).fill({ + cell_type: "code", + source: "print('test')", + metadata: {}, + }), + } + + const result = security.validateNotebook(notebook) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes("exceeds maximum cell count"))).toBe(true) + }) + + it("should validate cell size", () => { + const largeContent = "x".repeat(1500) + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: largeContent, + metadata: {}, + }, + ], + } + + const result = security.validateNotebook(notebook) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes("exceeds maximum size"))).toBe(true) + }) + + it("should detect dangerous code in cells", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: "import os\nos.system('rm -rf /')", + metadata: {}, + }, + ], + } + + const result = security.validateNotebook(notebook) + expect(result.isValid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it("should validate clean notebook", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "markdown", + source: "# Clean Notebook", + metadata: {}, + }, + { + cell_type: "code", + source: "print('Hello, World!')", + metadata: {}, + }, + ], + } + + const result = security.validateNotebook(notebook) + expect(result.isValid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it("should bypass validation for trusted sources", () => { + const trustedSecurity = new JupyterNotebookSecurity({ + ...defaultConfig, + trustedSources: ["/trusted/path"], + }) + + const dangerousNotebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: "import os\nos.system('dangerous')", + metadata: {}, + }, + ], + } + + const result = trustedSecurity.validateNotebook(dangerousNotebook, "/trusted/path/notebook.ipynb") + expect(result.isValid).toBe(true) + expect(result.warnings.some((w) => w.includes("trusted source"))).toBe(true) + }) + }) + + describe("Cell Sanitization", () => { + it("should sanitize dangerous code cells", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: "import os\nos.system('rm -rf /')", + metadata: {}, + outputs: [{ data: { "text/plain": "output" } }], + execution_count: 1, + }, + ], + } + + const sanitized = security.sanitizeNotebook(notebook) + const cell = sanitized.cells[0] as JupyterCell + + // Should add warning comment + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source + expect(source).toContain("SECURITY WARNING") + + // Should clear outputs + expect(cell.outputs).toEqual([]) + expect(cell.execution_count).toBeNull() + }) + + it("should sanitize markdown cells with scripts", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "markdown", + source: "# Title\n\nText", + metadata: {}, + }, + ], + } + + const sanitized = security.sanitizeNotebook(notebook) + const cell = sanitized.cells[0] + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source + + expect(source).not.toContain("")).toBe(true) + expect(SecurityUtils.hasCodeInjection("onclick='doSomething()'")).toBe(true) + expect(SecurityUtils.hasCodeInjection("javascript:void(0)")).toBe(true) + expect(SecurityUtils.hasCodeInjection("print('safe')")).toBe(false) + }) + + it("should get risk level from severity", () => { + expect(SecurityUtils.getRiskLevel("low")).toBe(1) + expect(SecurityUtils.getRiskLevel("medium")).toBe(2) + expect(SecurityUtils.getRiskLevel("high")).toBe(3) + expect(SecurityUtils.getRiskLevel("critical")).toBe(4) + }) + + it("should format security report", () => { + const validation = { + isValid: false, + errors: ["Error 1", "Error 2"], + warnings: ["Warning 1"], + } + + const report = SecurityUtils.formatSecurityReport(validation) + + expect(report).toContain("❌ INVALID") + expect(report).toContain("Error 1") + expect(report).toContain("Error 2") + expect(report).toContain("Warning 1") + }) + }) + + describe("Output Validation", () => { + it("should validate output types", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: "print('test')", + metadata: {}, + outputs: [ + { + data: { + "text/plain": "output", + "application/x-custom": "custom", + }, + }, + ], + }, + ], + } + + const result = security.validateNotebook(notebook) + expect(result.warnings.some((w) => w.includes("Unrecognized output type"))).toBe(true) + }) + + it("should detect JavaScript in HTML outputs", () => { + const notebook: JupyterNotebook = { + cells: [ + { + cell_type: "code", + source: "display(HTML(''))", + metadata: {}, + outputs: [ + { + data: { + "text/html": "", + }, + }, + ], + }, + ], + } + + const result = security.validateNotebook(notebook) + expect(result.warnings.some((w) => w.includes("JavaScript detected in HTML output"))).toBe(true) + }) + }) + + describe("Metadata Validation", () => { + it("should warn about non-Python kernels", () => { + const notebook: JupyterNotebook = { + cells: [], + metadata: { + kernelspec: { + language: "javascript", + name: "javascript", + }, + }, + } + + const result = security.validateNotebook(notebook) + expect(result.warnings.some((w) => w.includes("Non-Python kernel"))).toBe(true) + }) + + it("should detect suspicious metadata keys", () => { + const notebook: JupyterNotebook = { + cells: [], + metadata: { + widgets: {}, + extensions: {}, + plugins: {}, + hooks: {}, + }, + } + + const result = security.validateNotebook(notebook) + const suspiciousWarnings = result.warnings.filter((w) => w.includes("suspicious metadata")) + expect(suspiciousWarnings.length).toBeGreaterThan(0) + }) + }) + + describe("Complex Attack Patterns", () => { + it("should detect obfuscated eval", () => { + const code = ` +e = chr(101) + chr(118) + chr(97) + chr(108) +globals()[e]('print("hacked")') + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.type === "code_execution")).toBe(true) + }) + + it("should detect pickle deserialization", () => { + const code = ` +import pickle +data = pickle.loads(untrusted_data) + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.severity === "high")).toBe(true) + }) + + it("should detect __import__ usage", () => { + const code = ` +module = __import__('os') +module.system('ls') + ` + const risks = security.analyzeCodeCell(code) + expect(risks.some((r) => r.severity === "high")).toBe(true) + }) + }) +}) diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 8231c609be..59869edb42 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -7,6 +7,7 @@ import { isBinaryFile } from "isbinaryfile" import { extractTextFromXLSX } from "./extract-text-from-xlsx" import { countFileLines } from "./line-counter" import { readLines } from "./read-lines" +import { JupyterNotebookHandler } from "./jupyter-notebook-handler" async function extractTextFromPDF(filePath: string): Promise { const dataBuffer = await fs.readFile(filePath) @@ -20,17 +21,22 @@ async function extractTextFromDOCX(filePath: string): Promise { } async function extractTextFromIPYNB(filePath: string): Promise { - const data = await fs.readFile(filePath, "utf8") - const notebook = JSON.parse(data) - let extractedText = "" + // Use secure configuration for text extraction + const securityConfig = { + readOnlyMode: true, + enableWarnings: true, + allowCodeExecution: false, + } + const handler = await JupyterNotebookHandler.fromFile(filePath, securityConfig) - for (const cell of notebook.cells) { - if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { - extractedText += cell.source.join("\n") + "\n" - } + // Log security recommendations if there are any issues + const recommendations = handler.getSecurityRecommendations() + if (recommendations.length > 0 && !recommendations.some((r) => r.includes("✅"))) { + console.warn(`Security analysis for ${filePath}:`) + recommendations.forEach((rec) => console.warn(` ${rec}`)) } - return addLineNumbers(extractedText) + return handler.extractTextWithCellMarkers() } /** diff --git a/src/integrations/misc/jupyter-notebook-handler.ts b/src/integrations/misc/jupyter-notebook-handler.ts new file mode 100644 index 0000000000..5bc6abe266 --- /dev/null +++ b/src/integrations/misc/jupyter-notebook-handler.ts @@ -0,0 +1,523 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { addLineNumbers } from "./extract-text" +import { + JupyterNotebookSecurity, + SecurityConfig, + SecurityValidationResult, + createDefaultSecurity, +} from "./jupyter-notebook-security" + +export interface JupyterCell { + cell_type: "code" | "markdown" | "raw" + source: string | string[] + metadata?: Record + outputs?: any[] + execution_count?: number | null +} + +export interface JupyterNotebook { + cells: JupyterCell[] + metadata?: Record + nbformat?: number + nbformat_minor?: number +} + +export interface CellReference { + index: number + type: "code" | "markdown" | "raw" + content: string + lineStart: number + lineEnd: number +} + +export class JupyterNotebookHandler { + private notebook: JupyterNotebook + private filePath: string + private cellReferences: CellReference[] = [] + private security: JupyterNotebookSecurity + private isReadOnly: boolean = false + private validationResult?: SecurityValidationResult + + constructor(filePath: string, notebookContent?: string, securityConfig?: SecurityConfig) { + this.filePath = filePath + this.security = createDefaultSecurity(securityConfig) + + if (notebookContent) { + this.notebook = JSON.parse(notebookContent) + + // Validate notebook on load + this.validationResult = this.security.validateNotebook(this.notebook, filePath) + + // If notebook has security issues and we're in read-only mode, use sanitized version + if (!this.validationResult.isValid && this.security.getConfig().readOnlyMode) { + if (this.validationResult.sanitized) { + this.notebook = this.validationResult.sanitized + this.isReadOnly = true + } + } + + // Log security warnings if enabled + if (this.security.getConfig().enableWarnings) { + this.logSecurityWarnings() + } + + this.buildCellReferences() + } else { + this.notebook = { cells: [] } + } + } + + /** + * Load a Jupyter notebook from file + */ + static async fromFile(filePath: string, securityConfig?: SecurityConfig): Promise { + const content = await fs.readFile(filePath, "utf8") + return new JupyterNotebookHandler(filePath, content, securityConfig) + } + + /** + * Log security warnings to console + */ + private logSecurityWarnings(): void { + if (!this.validationResult) return + + if (this.validationResult.errors.length > 0) { + console.error("🔴 Jupyter Notebook Security Errors:") + this.validationResult.errors.forEach((error) => console.error(` - ${error}`)) + } + + if (this.validationResult.warnings.length > 0) { + console.warn("⚠️ Jupyter Notebook Security Warnings:") + this.validationResult.warnings.forEach((warning) => console.warn(` - ${warning}`)) + } + + if (this.isReadOnly) { + console.warn("📝 Notebook opened in READ-ONLY mode due to security concerns") + } + } + + /** + * Check if the notebook is in read-only mode + */ + public isInReadOnlyMode(): boolean { + return this.isReadOnly + } + + /** + * Get security validation result + */ + public getSecurityValidation(): SecurityValidationResult | undefined { + return this.validationResult + } + + /** + * Get security recommendations for the notebook + */ + public getSecurityRecommendations(): string[] { + return this.security.getSecurityRecommendations(this.notebook) + } + + /** + * Build cell references with line number mappings + */ + private buildCellReferences(): void { + this.cellReferences = [] + let currentLine = 1 + + this.notebook.cells.forEach((cell, index) => { + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + const lines = source.split("\n") + const lineCount = lines.length + + this.cellReferences.push({ + index, + type: cell.cell_type, + content: source, + lineStart: currentLine, + lineEnd: currentLine + lineCount - 1, + }) + + currentLine += lineCount + 1 // Add 1 for cell separator + }) + } + + /** + * Get cell at a specific line number + */ + getCellAtLine(lineNumber: number): CellReference | undefined { + return this.cellReferences.find((ref) => lineNumber >= ref.lineStart && lineNumber <= ref.lineEnd) + } + + /** + * Get cell by index + */ + getCellByIndex(index: number): JupyterCell | undefined { + return this.notebook.cells[index] + } + + /** + * Extract text with cell markers for better readability + */ + extractTextWithCellMarkers(): string { + let result = "" + let lineNumber = 1 + + this.notebook.cells.forEach((cell, index) => { + const cellType = cell.cell_type + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + + // Add cell header + result += `# %%% Cell ${index + 1} [${cellType}]\n` + + // Add cell content with line numbers + const lines = source.split("\n") + lines.forEach((line) => { + result += `${String(lineNumber).padStart(4, " ")} | ${line}\n` + lineNumber++ + }) + + // Add cell separator + result += "\n" + lineNumber++ // Account for the separator line + }) + + return result + } + + /** + * Extract text from specific cells + */ + extractCellsText(cellIndices?: number[]): string { + let result = "" + const cellsToExtract = cellIndices + ? this.notebook.cells.filter((_, index) => cellIndices.includes(index)) + : this.notebook.cells + + cellsToExtract.forEach((cell, idx) => { + const actualIndex = cellIndices ? cellIndices[idx] : idx + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + + result += `# Cell ${actualIndex + 1} [${cell.cell_type}]\n` + result += source + result += "\n\n" + }) + + return result + } + + /** + * Update a specific cell's content + */ + updateCell(cellIndex: number, newContent: string): boolean { + // Check if operation is allowed + if (this.isReadOnly) { + console.error("Cannot update cell: Notebook is in read-only mode") + return false + } + + if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) { + console.error("Cannot update cell: Security policy prevents write operations") + return false + } + + if (cellIndex < 0 || cellIndex >= this.notebook.cells.length) { + return false + } + + // Validate the new content for security risks + const tempCell = { ...this.notebook.cells[cellIndex] } + tempCell.source = newContent + const validation = this.security.validateCell(tempCell, cellIndex) + + if (validation.errors.length > 0) { + console.error("Cannot update cell due to security errors:", validation.errors) + return false + } + + if (validation.warnings.length > 0) { + console.warn("Security warnings for cell update:", validation.warnings) + } + + const cell = this.notebook.cells[cellIndex] + // Preserve the original format (array vs string) + if (Array.isArray(cell.source)) { + // Split content and ensure each line ends with \n except the last + const lines = newContent.split("\n") + cell.source = lines.map((line, idx) => (idx === lines.length - 1 && line === "" ? line : line + "\n")) + // Remove trailing empty string if it exists + if (cell.source[cell.source.length - 1] === "") { + cell.source.pop() + } + } else { + cell.source = newContent + } + + // Rebuild references after update + this.buildCellReferences() + return true + } + + /** + * Insert a new cell + */ + insertCell(index: number, cellType: "code" | "markdown" | "raw", content: string): boolean { + // Check if operation is allowed + if (this.isReadOnly) { + console.error("Cannot insert cell: Notebook is in read-only mode") + return false + } + + if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) { + console.error("Cannot insert cell: Security policy prevents write operations") + return false + } + + if (index < 0 || index > this.notebook.cells.length) { + return false + } + + // Check if we're exceeding max cell count + const maxCellCount = this.security.getConfig().maxCellCount + if (this.notebook.cells.length >= maxCellCount) { + console.error(`Cannot insert cell: Maximum cell count (${maxCellCount}) reached`) + return false + } + + const newCell: JupyterCell = { + cell_type: cellType, + source: content + .split("\n") + .map((line, idx, arr) => (idx === arr.length - 1 && line === "" ? line : line + "\n")), + metadata: {}, + } + + if (cellType === "code") { + newCell.outputs = [] + newCell.execution_count = null + } + + // Validate the new cell for security risks + const validation = this.security.validateCell(newCell, index) + + if (validation.errors.length > 0) { + console.error("Cannot insert cell due to security errors:", validation.errors) + return false + } + + if (validation.warnings.length > 0) { + console.warn("Security warnings for new cell:", validation.warnings) + } + + this.notebook.cells.splice(index, 0, newCell) + this.buildCellReferences() + return true + } + + /** + * Delete a cell + */ + deleteCell(index: number): boolean { + // Check if operation is allowed + if (this.isReadOnly) { + console.error("Cannot delete cell: Notebook is in read-only mode") + return false + } + + if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) { + console.error("Cannot delete cell: Security policy prevents write operations") + return false + } + + if (index < 0 || index >= this.notebook.cells.length) { + return false + } + + this.notebook.cells.splice(index, 1) + this.buildCellReferences() + return true + } + + /** + * Apply a diff to a specific cell + */ + applyCellDiff(cellIndex: number, searchContent: string, replaceContent: string): boolean { + const cell = this.getCellByIndex(cellIndex) + if (!cell) { + return false + } + + const currentContent = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + + // Simple exact match replacement for now + if (currentContent.includes(searchContent)) { + const newContent = currentContent.replace(searchContent, replaceContent) + return this.updateCell(cellIndex, newContent) + } + + return false + } + + /** + * Save the notebook back to file + */ + async save(): Promise { + // Check if operation is allowed + if (this.isReadOnly) { + throw new Error("Cannot save: Notebook is in read-only mode") + } + + if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) { + throw new Error("Cannot save: Security policy prevents write operations") + } + + // Validate entire notebook before saving + const validation = this.security.validateNotebook(this.notebook, this.filePath) + + if (!validation.isValid && validation.errors.length > 0) { + throw new Error(`Cannot save notebook with security errors: ${validation.errors.join(", ")}`) + } + + const content = JSON.stringify(this.notebook, null, 2) + await fs.writeFile(this.filePath, content, "utf8") + } + + /** + * Get a sanitized version of the notebook + */ + public getSanitizedNotebook(): JupyterNotebook { + return this.security.sanitizeNotebook(this.notebook) + } + + /** + * Update security configuration + */ + public updateSecurityConfig(config: Partial): void { + this.security.updateConfig(config) + // Re-validate with new config + this.validationResult = this.security.validateNotebook(this.notebook, this.filePath) + + if (!this.validationResult.isValid && this.security.getConfig().readOnlyMode) { + this.isReadOnly = true + if (this.validationResult.sanitized) { + this.notebook = this.validationResult.sanitized + this.buildCellReferences() + } + } else { + this.isReadOnly = false + } + } + + /** + * Check if a specific operation would be allowed + */ + public wouldAllowOperation(operation: "read" | "write" | "execute"): boolean { + return this.security.shouldAllowOperation(operation, this.notebook, this.filePath) + } + + /** + * Get the notebook as JSON string + */ + toJSON(): string { + return JSON.stringify(this.notebook, null, 2) + } + + /** + * Get cell count + */ + getCellCount(): number { + return this.notebook.cells.length + } + + /** + * Get all cells of a specific type + */ + getCellsByType(cellType: "code" | "markdown" | "raw"): Array<{ index: number; cell: JupyterCell }> { + return this.notebook.cells + .map((cell, index) => ({ index, cell })) + .filter(({ cell }) => cell.cell_type === cellType) + } + + /** + * Search for content in cells + */ + searchInCells(searchTerm: string): Array<{ cellIndex: number; matches: string[] }> { + const results: Array<{ cellIndex: number; matches: string[] }> = [] + + this.notebook.cells.forEach((cell, index) => { + const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + const lines = source.split("\n") + const matches = lines.filter((line) => line.includes(searchTerm)) + + if (matches.length > 0) { + results.push({ cellIndex: index, matches }) + } + }) + + return results + } + + /** + * Create a checkpoint-friendly representation + */ + getCheckpointRepresentation(): string { + return this.extractTextWithCellMarkers() + } + + /** + * Restore from checkpoint representation + */ + static fromCheckpointRepresentation(checkpointContent: string, originalNotebook: JupyterNotebook): JupyterNotebook { + // Parse the checkpoint content and reconstruct cells + const lines = checkpointContent.split("\n") + const cells: JupyterCell[] = [] + let currentCell: JupyterCell | null = null + let currentContent: string[] = [] + + for (const line of lines) { + // Check for cell marker + const cellMarkerMatch = line.match(/^# %%% Cell (\d+) \[(code|markdown|raw)\]$/) + if (cellMarkerMatch) { + // Save previous cell if exists + if (currentCell) { + currentCell.source = currentContent.join("\n") + cells.push(currentCell) + } + + // Start new cell + const cellIndex = parseInt(cellMarkerMatch[1]) - 1 + const cellType = cellMarkerMatch[2] as "code" | "markdown" | "raw" + + // Try to preserve metadata from original + const originalCell = originalNotebook.cells[cellIndex] + currentCell = { + cell_type: cellType, + source: "", + metadata: originalCell?.metadata || {}, + } + + if (cellType === "code") { + currentCell.outputs = originalCell?.outputs || [] + currentCell.execution_count = originalCell?.execution_count || null + } + + currentContent = [] + } else if (line.match(/^\s*\d+\s*\|/)) { + // Extract content from numbered line + const content = line.replace(/^\s*\d+\s*\|\s?/, "") + currentContent.push(content) + } + } + + // Save last cell + if (currentCell) { + currentCell.source = currentContent.join("\n") + cells.push(currentCell) + } + + return { + ...originalNotebook, + cells, + } + } +} diff --git a/src/integrations/misc/jupyter-notebook-security.ts b/src/integrations/misc/jupyter-notebook-security.ts new file mode 100644 index 0000000000..390da94da5 --- /dev/null +++ b/src/integrations/misc/jupyter-notebook-security.ts @@ -0,0 +1,658 @@ +/** + * Security module for Jupyter notebook handling + * Provides validation, sanitization, and security controls for notebook operations + */ + +import { JupyterCell, JupyterNotebook } from "./jupyter-notebook-handler" + +export interface SecurityConfig { + /** Allow execution of code cells (default: false) */ + allowCodeExecution?: boolean + /** Enable read-only mode for untrusted notebooks (default: true) */ + readOnlyMode?: boolean + /** Maximum allowed cell size in characters (default: 1MB) */ + maxCellSize?: number + /** Maximum number of cells allowed (default: 1000) */ + maxCellCount?: number + /** Allow potentially dangerous imports (default: false) */ + allowDangerousImports?: boolean + /** List of blocked patterns in code cells */ + blockedPatterns?: RegExp[] + /** List of allowed file extensions for outputs */ + allowedOutputTypes?: string[] + /** Enable security warnings (default: true) */ + enableWarnings?: boolean + /** Trusted notebook sources (file paths or patterns) */ + trustedSources?: string[] +} + +export interface SecurityValidationResult { + isValid: boolean + errors: string[] + warnings: string[] + sanitized?: JupyterNotebook +} + +export interface CellSecurityInfo { + cellIndex: number + cellType: string + risks: SecurityRisk[] + isSafe: boolean +} + +export interface SecurityRisk { + type: "code_execution" | "import" | "file_access" | "network" | "system_command" | "eval" | "size_limit" + severity: "low" | "medium" | "high" | "critical" + description: string + pattern?: string +} + +const DEFAULT_CONFIG: Required = { + allowCodeExecution: false, + readOnlyMode: true, + maxCellSize: 1024 * 1024, // 1MB + maxCellCount: 1000, + allowDangerousImports: false, + blockedPatterns: [ + // System commands and shell execution + /\b(exec|eval|compile|__import__|open|subprocess|os\.system|os\.popen|commands\.)/gi, + // File system operations + /\b(shutil\.|pathlib\.|glob\.|tempfile\.|zipfile\.|tarfile\.)/gi, + // Network operations + /\b(urllib\.|requests\.|socket\.|http\.|ftplib\.|telnetlib\.|smtplib\.)/gi, + // Dangerous built-ins + /\b(globals|locals|vars|dir|getattr|setattr|delattr|hasattr)\s*\(/gi, + // Code injection patterns + /\b(pickle\.|marshal\.|shelve\.|dill\.)/gi, + // Process and thread manipulation + /\b(multiprocessing\.|threading\.|concurrent\.|asyncio\.)/gi, + ], + allowedOutputTypes: ["text/plain", "text/html", "image/png", "image/jpeg", "image/svg+xml"], + enableWarnings: true, + trustedSources: [], +} + +const DANGEROUS_IMPORTS = [ + "subprocess", + "os", + "sys", + "socket", + "urllib", + "requests", + "pickle", + "marshal", + "shelve", + "dill", + "multiprocessing", + "threading", + "ctypes", + "pty", + "fcntl", + "termios", + "tty", + "pwd", + "grp", + "resource", + "signal", + "syslog", + "tempfile", + "shutil", + "glob", + "pathlib", + "zipfile", + "tarfile", + "gzip", + "bz2", + "lzma", + "sqlite3", + "psycopg2", + "pymongo", + "redis", + "paramiko", + "fabric", + "ansible", + "docker", + "kubernetes", +] + +export class JupyterNotebookSecurity { + private config: Required + + constructor(config?: SecurityConfig) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * Validate a Jupyter notebook for security risks + */ + public validateNotebook(notebook: JupyterNotebook, sourcePath?: string): SecurityValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + // Check if source is trusted + if (sourcePath && this.isSourceTrusted(sourcePath)) { + return { + isValid: true, + errors: [], + warnings: ["Notebook from trusted source - security checks bypassed"], + } + } + + // Check cell count + if (notebook.cells.length > this.config.maxCellCount) { + errors.push(`Notebook exceeds maximum cell count (${notebook.cells.length} > ${this.config.maxCellCount})`) + } + + // Validate each cell + notebook.cells.forEach((cell, index) => { + const cellValidation = this.validateCell(cell, index) + errors.push(...cellValidation.errors) + warnings.push(...cellValidation.warnings) + }) + + // Check for suspicious metadata + if (notebook.metadata) { + const metadataWarnings = this.validateMetadata(notebook.metadata) + warnings.push(...metadataWarnings) + } + + const isValid = errors.length === 0 + + // Sanitize if needed + let sanitized: JupyterNotebook | undefined + if (!isValid && this.config.readOnlyMode) { + sanitized = this.sanitizeNotebook(notebook) + } + + return { + isValid, + errors, + warnings, + sanitized, + } + } + + /** + * Validate a single cell for security risks + */ + public validateCell(cell: JupyterCell, index: number): { errors: string[]; warnings: string[] } { + const errors: string[] = [] + const warnings: string[] = [] + + // Get cell content as string + const content = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + + // Check cell size + if (content.length > this.config.maxCellSize) { + errors.push( + `Cell ${index} exceeds maximum size (${content.length} > ${this.config.maxCellSize} characters)`, + ) + } + + // Check code cells for dangerous patterns + if (cell.cell_type === "code") { + const codeRisks = this.analyzeCodeCell(content) + + codeRisks.forEach((risk) => { + const message = `Cell ${index}: ${risk.description}` + if (risk.severity === "critical" || risk.severity === "high") { + errors.push(message) + } else { + warnings.push(message) + } + }) + + // Check outputs for suspicious content + if (cell.outputs && Array.isArray(cell.outputs)) { + const outputWarnings = this.validateOutputs(cell.outputs, index) + warnings.push(...outputWarnings) + } + } + + // Check for embedded scripts in markdown cells + if (cell.cell_type === "markdown") { + const markdownRisks = this.analyzeMarkdownCell(content) + markdownRisks.forEach((risk) => { + warnings.push(`Cell ${index}: ${risk.description}`) + }) + } + + return { errors, warnings } + } + + /** + * Analyze a code cell for security risks + */ + public analyzeCodeCell(content: string): SecurityRisk[] { + const risks: SecurityRisk[] = [] + + // Check for blocked patterns + this.config.blockedPatterns.forEach((pattern) => { + if (pattern.test(content)) { + risks.push({ + type: "code_execution", + severity: "high", + description: `Potentially dangerous code pattern detected: ${pattern.source}`, + pattern: pattern.source, + }) + } + }) + + // Check for dangerous imports + if (!this.config.allowDangerousImports) { + const importRegex = /(?:from\s+(\S+)\s+import|import\s+(\S+))/g + let match + while ((match = importRegex.exec(content)) !== null) { + const module = (match[1] || match[2]).split(".")[0].replace(",", "") + if (DANGEROUS_IMPORTS.includes(module)) { + risks.push({ + type: "import", + severity: "high", + description: `Dangerous import detected: ${module}`, + pattern: module, + }) + } + } + } + + // Check for eval/exec usage + if (/\b(eval|exec|compile)\s*\(/.test(content)) { + risks.push({ + type: "eval", + severity: "critical", + description: "Dynamic code execution detected (eval/exec/compile)", + }) + } + + // Check for file system access + if (/\b(open|read|write)\s*\(/.test(content)) { + risks.push({ + type: "file_access", + severity: "medium", + description: "File system access detected", + }) + } + + // Check for network operations + if (/\b(urlopen|urlretrieve|get|post|put|delete)\s*\(/.test(content)) { + risks.push({ + type: "network", + severity: "medium", + description: "Network operation detected", + }) + } + + // Check for system commands + if (/!\s*[a-zA-Z]/.test(content) || /%\s*system/.test(content)) { + risks.push({ + type: "system_command", + severity: "critical", + description: "System command execution detected", + }) + } + + return risks + } + + /** + * Analyze a markdown cell for security risks + */ + public analyzeMarkdownCell(content: string): SecurityRisk[] { + const risks: SecurityRisk[] = [] + + // Check for embedded JavaScript + if (/]/i.test(content)) { + risks.push({ + type: "code_execution", + severity: "high", + description: "Embedded JavaScript detected in markdown", + }) + } + + // Check for iframes + if (/]/i.test(content)) { + risks.push({ + type: "network", + severity: "medium", + description: "Embedded iframe detected in markdown", + }) + } + + // Check for data URIs that might contain scripts + if (/data:[^,]*script/i.test(content)) { + risks.push({ + type: "code_execution", + severity: "high", + description: "Data URI with potential script detected", + }) + } + + return risks + } + + /** + * Validate cell outputs for security risks + */ + private validateOutputs(outputs: any[], cellIndex: number): string[] { + const warnings: string[] = [] + + outputs.forEach((output, outputIndex) => { + if (output.data) { + Object.keys(output.data).forEach((mimeType) => { + if (!this.config.allowedOutputTypes.includes(mimeType)) { + warnings.push( + `Cell ${cellIndex}, Output ${outputIndex}: Unrecognized output type '${mimeType}'`, + ) + } + + // Check for suspicious content in HTML outputs + if (mimeType === "text/html") { + const htmlContent = Array.isArray(output.data[mimeType]) + ? output.data[mimeType].join("") + : output.data[mimeType] + + if (/]/i.test(htmlContent)) { + warnings.push( + `Cell ${cellIndex}, Output ${outputIndex}: JavaScript detected in HTML output`, + ) + } + } + }) + } + }) + + return warnings + } + + /** + * Validate notebook metadata + */ + private validateMetadata(metadata: Record): string[] { + const warnings: string[] = [] + + // Check for suspicious kernel specifications + if (metadata.kernelspec?.language && metadata.kernelspec.language !== "python") { + warnings.push(`Non-Python kernel detected: ${metadata.kernelspec.language}`) + } + + // Check for custom metadata that might contain code + const suspiciousKeys = ["widgets", "extensions", "plugins", "hooks"] + Object.keys(metadata).forEach((key) => { + if (suspiciousKeys.includes(key.toLowerCase())) { + warnings.push(`Potentially suspicious metadata key detected: ${key}`) + } + }) + + return warnings + } + + /** + * Sanitize a notebook by removing dangerous content + */ + public sanitizeNotebook(notebook: JupyterNotebook): JupyterNotebook { + const sanitized: JupyterNotebook = { + ...notebook, + cells: notebook.cells.map((cell) => this.sanitizeCell(cell)), + } + + // Remove suspicious metadata + if (sanitized.metadata) { + const cleanMetadata = { ...sanitized.metadata } + delete cleanMetadata.widgets + delete cleanMetadata.extensions + delete cleanMetadata.plugins + delete cleanMetadata.hooks + sanitized.metadata = cleanMetadata + } + + return sanitized + } + + /** + * Sanitize a single cell + */ + private sanitizeCell(cell: JupyterCell): JupyterCell { + const sanitized = { ...cell } + + if (cell.cell_type === "code") { + // Clear outputs for code cells with risks + const content = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + const risks = this.analyzeCodeCell(content) + + if (risks.some((r) => r.severity === "high" || r.severity === "critical")) { + sanitized.outputs = [] + sanitized.execution_count = null + + // Add warning comment to the cell + const warning = + "# ⚠️ SECURITY WARNING: This cell contains potentially dangerous code and has been disabled\n" + if (Array.isArray(sanitized.source)) { + sanitized.source = [warning, ...sanitized.source] + } else { + sanitized.source = warning + (sanitized.source || "") + } + } + } + + if (cell.cell_type === "markdown") { + // Sanitize markdown content + let content = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + + // Remove script tags + content = content.replace(//gi, "") + + // Remove iframes + content = content.replace(//gi, "") + + // Remove dangerous data URIs + content = content.replace(/data:[^,]*script[^"']*/gi, "data:text/plain,removed") + + // Convert back to appropriate format + if (Array.isArray(cell.source)) { + sanitized.source = content + .split("\n") + .map((line, idx, arr) => (idx === arr.length - 1 ? line : line + "\n")) + } else { + sanitized.source = content + } + } + + return sanitized + } + + /** + * Check if a source path is trusted + */ + private isSourceTrusted(sourcePath: string): boolean { + return this.config.trustedSources.some((trusted) => { + if (trusted.includes("*")) { + // Simple glob pattern matching + const pattern = new RegExp("^" + trusted.replace(/\*/g, ".*") + "$") + return pattern.test(sourcePath) + } + return sourcePath === trusted || sourcePath.startsWith(trusted) + }) + } + + /** + * Get security analysis for all cells + */ + public analyzeNotebookSecurity(notebook: JupyterNotebook): CellSecurityInfo[] { + return notebook.cells.map((cell, index) => { + const content = Array.isArray(cell.source) ? cell.source.join("") : cell.source || "" + const risks = + cell.cell_type === "code" + ? this.analyzeCodeCell(content) + : cell.cell_type === "markdown" + ? this.analyzeMarkdownCell(content) + : [] + + return { + cellIndex: index, + cellType: cell.cell_type, + risks, + isSafe: risks.length === 0 || risks.every((r) => r.severity === "low"), + } + }) + } + + /** + * Check if notebook operations should be allowed + */ + public shouldAllowOperation( + operation: "read" | "write" | "execute", + notebook: JupyterNotebook, + sourcePath?: string, + ): boolean { + // Always allow read operations + if (operation === "read") { + return true + } + + // Check if source is trusted + const isTrusted = sourcePath && this.isSourceTrusted(sourcePath) + + // For write operations + if (operation === "write") { + // Allow writes for trusted sources + if (isTrusted) { + return true + } + // In read-only mode, deny write operations for untrusted sources + if (this.config.readOnlyMode) { + const validation = this.validateNotebook(notebook, sourcePath) + return validation.isValid + } + // Otherwise allow writes + return true + } + + // Never allow execution unless explicitly enabled + if (operation === "execute") { + if (!this.config.allowCodeExecution) { + return false + } + // Even for trusted sources, validate if execution is safe + const validation = this.validateNotebook(notebook, sourcePath) + return validation.isValid && validation.errors.length === 0 + } + + return false + } + + /** + * Get security recommendations for a notebook + */ + public getSecurityRecommendations(notebook: JupyterNotebook): string[] { + const recommendations: string[] = [] + const analysis = this.analyzeNotebookSecurity(notebook) + + const hasHighRisk = analysis.some((info) => + info.risks.some((r) => r.severity === "high" || r.severity === "critical"), + ) + + if (hasHighRisk) { + recommendations.push( + "⚠️ This notebook contains high-risk code patterns. Review carefully before execution.", + ) + recommendations.push("Consider running in an isolated environment or container.") + } + + const importRisks = analysis.flatMap((info) => info.risks.filter((r) => r.type === "import")) + if (importRisks.length > 0) { + recommendations.push("Review imported modules for potential security risks.") + } + + const networkRisks = analysis.flatMap((info) => info.risks.filter((r) => r.type === "network")) + if (networkRisks.length > 0) { + recommendations.push("This notebook performs network operations. Ensure network access is intended.") + } + + const fileRisks = analysis.flatMap((info) => info.risks.filter((r) => r.type === "file_access")) + if (fileRisks.length > 0) { + recommendations.push("This notebook accesses the file system. Verify file paths and permissions.") + } + + if (recommendations.length === 0 && analysis.every((info) => info.isSafe)) { + recommendations.push("✅ No significant security risks detected.") + } + + return recommendations + } + + /** + * Update security configuration + */ + public updateConfig(config: Partial): void { + this.config = { ...this.config, ...config } + } + + /** + * Get current security configuration + */ + public getConfig(): Required { + return { ...this.config } + } +} + +/** + * Create a default security instance + */ +export function createDefaultSecurity(config?: SecurityConfig): JupyterNotebookSecurity { + return new JupyterNotebookSecurity(config) +} + +/** + * Security utility functions + */ +export const SecurityUtils = { + /** + * Check if a string contains potential code injection + */ + hasCodeInjection(content: string): boolean { + const patterns = [ + /\b(eval|exec|compile|__import__)\s*\(/, + /]/i, + /javascript:/i, + /on\w+\s*=/i, // Event handlers + ] + return patterns.some((pattern) => pattern.test(content)) + }, + + /** + * Get risk level from severity + */ + getRiskLevel(severity: SecurityRisk["severity"]): number { + const levels = { low: 1, medium: 2, high: 3, critical: 4 } + return levels[severity] || 0 + }, + + /** + * Format security report + */ + formatSecurityReport(validation: SecurityValidationResult): string { + const lines: string[] = [] + + lines.push("=== Jupyter Notebook Security Report ===") + lines.push(`Status: ${validation.isValid ? "✅ VALID" : "❌ INVALID"}`) + lines.push("") + + if (validation.errors.length > 0) { + lines.push("ERRORS:") + validation.errors.forEach((error) => lines.push(` ❌ ${error}`)) + lines.push("") + } + + if (validation.warnings.length > 0) { + lines.push("WARNINGS:") + validation.warnings.forEach((warning) => lines.push(` ⚠️ ${warning}`)) + lines.push("") + } + + if (validation.isValid && validation.errors.length === 0 && validation.warnings.length === 0) { + lines.push("No security issues detected.") + } + + return lines.join("\n") + }, +}