diff --git a/packages/scanner/src/detectors/injection.ts b/packages/scanner/src/detectors/injection.ts index 56878b2..68e3ad1 100644 --- a/packages/scanner/src/detectors/injection.ts +++ b/packages/scanner/src/detectors/injection.ts @@ -1,11 +1,13 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import type { Finding } from '../types.js' +import { validatePath } from '../utils.js' /** * Load declared packages from package.json */ function loadPackageJsonDeps(targetDir: string): Set { + validatePath(targetDir) try { const pkg = JSON.parse(readFileSync(resolve(targetDir, 'package.json'), 'utf-8')) as any const declared = new Set() @@ -31,6 +33,7 @@ export function detectInjection( lockfilePackages: Array<{ name: string; version?: string }>, targetDir: string ): Finding[] { + validatePath(targetDir) const findings: Finding[] = [] const declared = loadPackageJsonDeps(targetDir) diff --git a/packages/scanner/src/parsers/npm.ts b/packages/scanner/src/parsers/npm.ts index c221899..a1d6b58 100644 --- a/packages/scanner/src/parsers/npm.ts +++ b/packages/scanner/src/parsers/npm.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import type { LockfileEntry } from '../types.js' +import { validatePath } from '../utils.js' interface NpmLockfile { lockfileVersion?: number @@ -27,6 +28,7 @@ export interface ParsedPackage { /** Parse package-lock.json (v2/v3) or package-lock.v3.json */ export function parseNpmLockfile(targetDir: string): ParsedPackage[] { + validatePath(targetDir) const paths = [ join(targetDir, 'package-lock.json'), join(targetDir, 'package-lock.v3.json'), diff --git a/packages/scanner/src/parsers/yarn.ts b/packages/scanner/src/parsers/yarn.ts index 124bc92..344f9a1 100644 --- a/packages/scanner/src/parsers/yarn.ts +++ b/packages/scanner/src/parsers/yarn.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import type { LockfileEntry } from '../types.js' +import { validatePath } from '../utils.js' export interface ParsedPackage extends LockfileEntry { name: string @@ -88,6 +89,7 @@ function parseYarnV2(content: string): ParsedPackage[] { * Main entrance for Yarn lockfile parsing */ export function parseYarnLockfile(targetDir: string): ParsedPackage[] { + validatePath(targetDir) const lockPath = join(targetDir, 'yarn.lock') let content: string try { diff --git a/packages/scanner/src/scan.ts b/packages/scanner/src/scan.ts index b85b6c5..8f39a72 100644 --- a/packages/scanner/src/scan.ts +++ b/packages/scanner/src/scan.ts @@ -3,6 +3,7 @@ import { basename, resolve } from 'node:path' import { parseYarnLockfile } from './parsers/yarn.js' import type { Finding, ScanResult } from './types.js' +import { validatePath } from './utils.js' // --------------------------------------------------------------------------- // Lockfile discovery @@ -18,6 +19,7 @@ const LOCKFILE_NAMES = [ ] function findLockfiles(target: string): string[] { + validatePath(target) const found: string[] = [] for (const name of LOCKFILE_NAMES) { const path = resolve(target, name) @@ -115,6 +117,7 @@ function detectInjection( packages: Array<{ name: string; version: string }>, target: string ): Finding[] { + validatePath(target) let pkgJson: any = {} try { pkgJson = JSON.parse(readFileSync(resolve(target, 'package.json'), 'utf8')) @@ -157,6 +160,7 @@ function detectInjection( // --------------------------------------------------------------------------- export async function scan(target: string): Promise { + validatePath(target) const start = Date.now() const findings: Finding[] = [] @@ -190,6 +194,7 @@ export async function scan(target: string): Promise { * CLI entry point */ export async function cli(target: string): Promise { + validatePath(target) console.log(`[worms-scan] Scanning: ${target}`) const result = await scan(target) diff --git a/packages/scanner/src/utils.ts b/packages/scanner/src/utils.ts new file mode 100644 index 0000000..c73c767 --- /dev/null +++ b/packages/scanner/src/utils.ts @@ -0,0 +1,18 @@ +/** + * Validates a path to prevent path traversal and other injection attacks. + * @param path - The path to validate + * @throws {Error} If the path is invalid + */ +export function validatePath(path: string): void { + if (typeof path !== 'string') { + throw new Error('Invalid path: must be a string'); + } + + if (path.includes('\0')) { + throw new Error('Invalid path: null byte detected'); + } + + if (!path || path.trim() === '') { + throw new Error('Invalid path: path cannot be empty'); + } +} diff --git a/packages/scanner/tests/security.test.ts b/packages/scanner/tests/security.test.ts new file mode 100644 index 0000000..ec05622 --- /dev/null +++ b/packages/scanner/tests/security.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test'; +import { validatePath } from '../src/utils'; +import { scan } from '../src/scan'; + +describe('Path Validation', () => { + it('should throw error for null bytes', () => { + expect(() => validatePath('/etc/passwd\0')).toThrow('Invalid path: null byte detected'); + }); + + it('should throw error for empty paths', () => { + expect(() => validatePath('')).toThrow('Invalid path: path cannot be empty'); + expect(() => validatePath(' ')).toThrow('Invalid path: path cannot be empty'); + }); + + it('should not throw for valid paths', () => { + expect(() => validatePath('/tmp/project')).not.toThrow(); + expect(() => validatePath('./local/dir')).not.toThrow(); + }); +}); + +describe('Scanner Security', () => { + it('should reject malicious paths in scan()', async () => { + try { + await scan('/etc/passwd\0'); + expect(true).toBe(false); // Should not reach here + } catch (e) { + expect(e.message).toBe('Invalid path: null byte detected'); + } + }); +});