diff --git a/apps/api/src/scanner/ssrf-scan.job.spec.ts b/apps/api/src/scanner/ssrf-scan.job.spec.ts new file mode 100644 index 00000000..daed7d4a --- /dev/null +++ b/apps/api/src/scanner/ssrf-scan.job.spec.ts @@ -0,0 +1,122 @@ +/** + * Integration: SSRF scan job -> engine analyzer -> findings persistence. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { FindingsRepository } from '../findings/findings.repository'; +import { FindingsService } from '../findings/findings.service'; +import { MetricsCollector } from '../queue/metrics'; +import { + SsrfScanJobError, + SsrfScanJobDeps, + SsrfScanJobHandler, + SsrfScanJobPayload, + SsrfScanLogger, +} from './ssrf-scan.job'; + +const FIXTURES = path.resolve(__dirname, '../../../../tests/fixtures/ssrf'); + +function repoFiles(name: string): Array<{ path: string; content: string }> { + const root = path.join(FIXTURES, name); + const out: Array<{ path: string; content: string }> = []; + const walk = (dir: string) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full); + else out.push({ path: path.relative(root, full).split(path.sep).join('/'), content: fs.readFileSync(full, 'utf8') }); + } + }; + walk(root); + return out; +} + +function captureLogger(): SsrfScanLogger & { lines: string[] } { + const lines: string[] = []; + return { + lines, + info: (event, fields) => lines.push(JSON.stringify({ level: 'info', event, ...fields })), + warn: (event, fields) => lines.push(JSON.stringify({ level: 'warn', event, ...fields })), + }; +} + +function setup(overrides: SsrfScanJobDeps = {}) { + const repo = new FindingsRepository(); + const findings = new FindingsService(repo); + const logger = captureLogger(); + const metrics = new MetricsCollector(); + const handler = new SsrfScanJobHandler({ findings, logger, metrics, ...overrides }); + return { repo, findings, logger, metrics, handler }; +} + +const job = (over: Partial = {}): SsrfScanJobPayload => ({ + organizationId: 'org-a', + repositoryId: 'repo-1', + analysisJobId: 'job-1', + files: repoFiles('vulnerable-app'), + ...over, +}); + +async function expectJobError(p: Promise, code: string, retryable: boolean) { + const err = await p.then( + () => undefined, + (e) => e, + ); + expect(err).toBeInstanceOf(SsrfScanJobError); + expect(err).toEqual(expect.objectContaining({ code, retryable })); +} + +describe('SsrfScanJobHandler', () => { + it('analyzes a repository and persists findings under the job tenant', async () => { + const { handler, findings } = setup(); + const outcome = await handler.handle(job()); + + expect(outcome.status).toBe('completed'); + expect(outcome.filesReceived).toBeGreaterThanOrEqual(3); + expect(outcome.findingsDetected).toBeGreaterThan(0); + expect(outcome.findingsPersisted).toBe(outcome.findingsDetected); + expect(outcome.findingsAlreadyPersisted).toBe(0); + + const page = findings.list({ organizationId: 'org-a', repositoryId: 'repo-1', limit: 100 }); + expect(page.items.length).toBe(outcome.findingsPersisted); + expect(page.items.every((f) => f.organizationId === 'org-a' && f.analysisJobId === 'job-1')).toBe(true); + }); + + it('is idempotent on retried jobs', async () => { + const { handler } = setup(); + const first = await handler.handle(job()); + expect(first.findingsPersisted).toBeGreaterThan(0); + + const second = await handler.handle(job()); + expect(second.findingsPersisted).toBe(0); + expect(second.findingsAlreadyPersisted).toBe(first.findingsPersisted); + }); + + it('rejects payloads with missing tenant IDs', async () => { + const { handler } = setup(); + await expectJobError(handler.handle(job({ organizationId: '' })), 'INVALID_JOB', false); + await expectJobError(handler.handle(job({ repositoryId: '' })), 'INVALID_JOB', false); + await expectJobError(handler.handle(job({ analysisJobId: '' })), 'INVALID_JOB', false); + }); + + it('rejects invalid timeout values', async () => { + const { handler } = setup(); + await expectJobError(handler.handle(job({ timeoutMs: -5 })), 'INVALID_JOB', false); + }); + + it('rejects invalid analyzer configurations', async () => { + const { handler } = setup(); + await expectJobError( + handler.handle( + job({ + config: { + rules: { + 'ssrf-001': { enabled: true, severity: 'nonexistent-severity' as any }, + }, + }, + }), + ), + 'INVALID_CONFIG', + false, + ); + }); +}); diff --git a/apps/api/src/scanner/ssrf-scan.job.ts b/apps/api/src/scanner/ssrf-scan.job.ts new file mode 100644 index 00000000..8502c145 --- /dev/null +++ b/apps/api/src/scanner/ssrf-scan.job.ts @@ -0,0 +1,324 @@ +/** + * SSRF validation scan job (worker boundary). + * + * Runs the engine's SsrfAnalyzer over a repository snapshot and + * persists findings under the job's tenant. + */ + +import { runAnalyzerSafely } from '@engine/core/analyzer-runner'; +import type { AnalyzerConfig, Finding as EngineFinding } from '@engine/core/analyzer-interface'; +import { + SsrfAnalysisMetrics, + SsrfAnalysisMetricsSnapshot, + SsrfAnalyzer, + SsrfAnalyzerOptions, +} from '@engine/analyzers/ssrf'; +import { + FindingsService, + findingsService as defaultFindingsService, +} from '../findings/findings.service'; +import { FindingSeverity, MAX_PAGE_LIMIT } from '../findings/finding.types'; +import type { MetricsCollector } from '../queue/metrics'; + +export const SSRF_SCAN_JOB_NAME = 'security.ssrf'; +export const SSRF_SCAN_SCHEMA_VERSION = 1; +export const MAX_SSRF_SCAN_FILES = 5000; +export const MAX_SSRF_SCAN_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_ID_LENGTH = 256; +const RULE_PREFIX = 'ssrf-'; + +export interface SsrfScanFile { + path: string; + content: string; +} + +export type SsrfScanConfig = Pick< + AnalyzerConfig, + 'rules' | 'excludePaths' | 'includePaths' | 'maxFindings' | 'options' +>; + +export interface SsrfScanJobPayload { + schemaVersion?: number; + organizationId: string; + repositoryId: string; + analysisJobId: string; + files: SsrfScanFile[]; + config?: SsrfScanConfig; + timeoutMs?: number; +} + +export type SsrfScanErrorCode = + | 'INVALID_JOB' + | 'INVALID_CONFIG' + | 'ANALYZER_TIMEOUT' + | 'ANALYZER_FAILED' + | 'PERSISTENCE_FAILED'; + +export class SsrfScanJobError extends Error { + constructor( + readonly code: SsrfScanErrorCode, + message: string, + readonly retryable: boolean, + ) { + super(message); + this.name = 'SsrfScanJobError'; + } +} + +export interface SsrfScanOutcome { + status: 'completed' | 'partial'; + organizationId: string; + repositoryId: string; + analysisJobId: string; + analyzerVersion: string; + filesReceived: number; + filesAnalyzed: number; + filesSkipped: number; + findingsDetected: number; + findingsPersisted: number; + findingsAlreadyPersisted: number; + findingsByRule: Record; + fileErrors: Array<{ file: string; message: string }>; + durationMs: number; +} + +export interface SsrfScanLogger { + info(event: string, fields: Record): void; + warn(event: string, fields: Record): void; +} + +const consoleLogger: SsrfScanLogger = { + info: (event, fields) => console.log(JSON.stringify({ level: 'info', event, ...fields })), + warn: (event, fields) => console.warn(JSON.stringify({ level: 'warn', event, ...fields })), +}; + +export interface SsrfScanJobDeps { + findings?: FindingsService; + analyzerFactory?: (options: SsrfAnalyzerOptions) => SsrfAnalyzer; + metrics?: Pick; + logger?: SsrfScanLogger; + now?: () => number; +} + +export class SsrfScanJobHandler { + private readonly findings: FindingsService; + private readonly analyzerFactory: (options: SsrfAnalyzerOptions) => SsrfAnalyzer; + private readonly metrics?: Pick; + private readonly logger: SsrfScanLogger; + private readonly now: () => number; + + constructor(deps: SsrfScanJobDeps = {}) { + this.findings = deps.findings ?? defaultFindingsService; + this.analyzerFactory = deps.analyzerFactory ?? ((o) => new SsrfAnalyzer(o)); + this.metrics = deps.metrics; + this.logger = deps.logger ?? consoleLogger; + this.now = deps.now ?? (() => Date.now()); + } + + async handle(payload: SsrfScanJobPayload): Promise { + const started = this.now(); + validateSsrfPayload(payload); + const { organizationId, repositoryId, analysisJobId } = payload; + const logFields = { organizationId, repositoryId, analysisJobId }; + + const telemetry = new SsrfAnalysisMetrics(); + const analyzer = this.analyzerFactory({ observer: telemetry }); + const config: AnalyzerConfig = { ...(payload.config ?? {}) }; + + const configErrors = analyzer.validateConfig(config); + if (configErrors.length > 0) { + this.logger.warn('ssrf_scan.rejected', { ...logFields, code: 'INVALID_CONFIG', errorCount: configErrors.length }); + throw new SsrfScanJobError('INVALID_CONFIG', `Invalid configuration: ${configErrors.join(', ')}`, false); + } + + const files = new Map(payload.files.map((f) => [f.path, f.content])); + let outcome; + try { + outcome = await runAnalyzerSafely(analyzer, files, { ...config, timeoutMs: payload.timeoutMs }, this.now); + } finally { + await analyzer.dispose(); + } + + if (outcome.diagnostic) { + const { kind, elapsedMs, timeoutMs } = outcome.diagnostic; + this.metrics?.recordAnalyzerDuration(analyzer.getName(), elapsedMs); + this.logger.warn('ssrf_scan.analyzer_failed', { + ...logFields, + kind, + elapsedMs, + timeoutMs, + fileCount: files.size, + }); + throw new SsrfScanJobError( + kind === 'timeout' ? 'ANALYZER_TIMEOUT' : 'ANALYZER_FAILED', + kind === 'timeout' + ? `${analyzer.getName()} exceeded its ${timeoutMs}ms budget` + : `${analyzer.getName()} failed during analysis`, + true, + ); + } + + const result = outcome.result; + let persisted: { created: number; existing: number }; + try { + persisted = this.persist(payload, analyzer, result.findings); + } catch { + this.logger.warn('ssrf_scan.persistence_failed', { ...logFields, findingsDetected: result.findings.length }); + throw new SsrfScanJobError('PERSISTENCE_FAILED', 'failed to persist ssrf findings', true); + } + + const snapshot: SsrfAnalysisMetricsSnapshot = telemetry.snapshot(); + const findingsByRule: Record = {}; + for (const f of result.findings) findingsByRule[f.ruleId] = (findingsByRule[f.ruleId] ?? 0) + 1; + const fileErrors = (result.errors ?? []).map(({ file, message }) => ({ file, message })); + const durationMs = this.now() - started; + + this.metrics?.recordAnalyzerDuration(analyzer.getName(), durationMs); + + const scanOutcome: SsrfScanOutcome = { + status: fileErrors.length > 0 ? 'partial' : 'completed', + organizationId, + repositoryId, + analysisJobId, + analyzerVersion: analyzer.getVersion(), + filesReceived: files.size, + filesAnalyzed: result.filesAnalyzed, + filesSkipped: snapshot.filesSkipped, + findingsDetected: result.findings.length, + findingsPersisted: persisted.created, + findingsAlreadyPersisted: persisted.existing, + findingsByRule, + fileErrors, + durationMs, + }; + + this.logger.info('ssrf_scan.completed', { + ...logFields, + status: scanOutcome.status, + analyzerVersion: scanOutcome.analyzerVersion, + filesReceived: scanOutcome.filesReceived, + filesAnalyzed: scanOutcome.filesAnalyzed, + filesSkipped: scanOutcome.filesSkipped, + skippedByReason: snapshot.skippedByReason, + fileErrorCount: fileErrors.length, + findingsDetected: scanOutcome.findingsDetected, + findingsPersisted: scanOutcome.findingsPersisted, + findingsAlreadyPersisted: scanOutcome.findingsAlreadyPersisted, + findingsByRule, + durationMs, + }); + + return scanOutcome; + } + + private persist( + payload: SsrfScanJobPayload, + analyzer: SsrfAnalyzer, + engineFindings: EngineFinding[], + ): { created: number; existing: number } { + const { organizationId, repositoryId, analysisJobId } = payload; + const existingKeys = new Set(); + let cursor: string | undefined; + do { + const page = this.findings.list({ + organizationId, + repositoryId, + analysisJobId, + limit: MAX_PAGE_LIMIT, + cursor, + }); + for (const f of page.items) { + if (f.ruleId.startsWith(RULE_PREFIX)) { + existingKeys.add(persistenceKey(f.ruleId, f.filePath, f.line, f.title)); + } + } + cursor = page.nextCursor ?? undefined; + } while (cursor); + + let created = 0; + let existing = 0; + for (const f of engineFindings) { + const title = analyzer.getRule(f.ruleId)?.name ?? f.ruleId; + const key = persistenceKey(f.ruleId, f.location.file, f.location.startLine, title); + if (existingKeys.has(key)) { + existing++; + continue; + } + this.findings.create({ + organizationId, + repositoryId, + analysisJobId, + title, + description: f.message, + severity: f.severity as FindingSeverity, + ruleId: f.ruleId, + filePath: f.location.file, + line: f.location.startLine, + }); + existingKeys.add(key); + created++; + } + return { created, existing }; + } +} + +function persistenceKey(ruleId: string, filePath: string | undefined, line: number | undefined, title: string): string { + return [ruleId, filePath ?? '', line ?? 0, title].join('\u0000'); +} + +function invalid(message: string): never { + throw new SsrfScanJobError('INVALID_JOB', message, false); +} + +function checkId(value: unknown, field: string): void { + if (typeof value !== 'string' || value.trim() === '') invalid(`${field} is required`); + if ((value as string).length > MAX_ID_LENGTH) invalid(`${field} exceeds ${MAX_ID_LENGTH} characters`); + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(value as string)) invalid(`${field} contains control characters`); +} + +export function validateSsrfPayload(payload: SsrfScanJobPayload): void { + if (payload === null || typeof payload !== 'object') invalid('payload must be an object'); + if ( + payload.schemaVersion !== undefined && + payload.schemaVersion !== SSRF_SCAN_SCHEMA_VERSION + ) { + invalid(`unsupported schemaVersion ${String(payload.schemaVersion)}`); + } + checkId(payload.organizationId, 'organizationId'); + checkId(payload.repositoryId, 'repositoryId'); + checkId(payload.analysisJobId, 'analysisJobId'); + + if (!Array.isArray(payload.files)) invalid('files must be an array'); + if (payload.files.length > MAX_SSRF_SCAN_FILES) { + invalid(`files exceeds ${MAX_SSRF_SCAN_FILES} entries`); + } + const seen = new Set(); + payload.files.forEach((file, idx) => { + if (!file || typeof file.path !== 'string' || file.path.trim() === '') { + invalid(`files[${idx}].path is required`); + } + if (typeof file.content !== 'string') invalid(`files[${idx}].content must be a string`); + if (seen.has(file.path)) invalid(`files[${idx}].path is duplicated`); + seen.add(file.path); + }); + + if (payload.config !== undefined) { + if (payload.config === null || typeof payload.config !== 'object' || Array.isArray(payload.config)) { + invalid('config must be an object'); + } + const allowed = new Set(['rules', 'excludePaths', 'includePaths', 'maxFindings', 'options']); + for (const key of Object.keys(payload.config)) { + if (!allowed.has(key)) invalid(`config.${key} is not allowed for ${SSRF_SCAN_JOB_NAME} jobs`); + } + } + + if ( + payload.timeoutMs !== undefined && + (!Number.isInteger(payload.timeoutMs) || + payload.timeoutMs <= 0 || + payload.timeoutMs > MAX_SSRF_SCAN_TIMEOUT_MS) + ) { + invalid(`timeoutMs must be an integer between 1 and ${MAX_SSRF_SCAN_TIMEOUT_MS}`); + } +} diff --git a/docs/SSRF_ANALYSIS.md b/docs/SSRF_ANALYSIS.md new file mode 100644 index 00000000..6b226b4e --- /dev/null +++ b/docs/SSRF_ANALYSIS.md @@ -0,0 +1,46 @@ +# Server-Side Request Forgery (SSRF) Analysis + +GasGuard's `SsrfAnalyzer` finds Server-Side Request Forgery vulnerabilities +([CWE-918](https://cwe.mitre.org/data/definitions/918.html)) across Node.js / TypeScript +and Python services. These include unvalidated outbound HTTP calls using user input, +weak or bypassable host checks, access to cloud metadata services, and disabled TLS verification. + +| Layer | Location | +| --- | --- | +| Analyzer (engine) | `libs/engine/analyzers/ssrf/` | +| Worker job + persistence | `apps/api/src/scanner/ssrf-scan.job.ts` | +| Runtime protection | `src/security/ssrf.ts` | +| Fixtures (representative repos) | `tests/fixtures/ssrf/` | + +## Rules + +| Rule | Default severity | Detects | +| --- | --- | --- | +| `ssrf-001` Unvalidated HTTP request to user-controlled URL | high | Outbound HTTP requests (`fetch`, `axios`, `http.get`, `requests.get`, `urllib`) using request-derived URLs without safe allowlist validation. | +| `ssrf-002` Weak or bypassable SSRF URL validation | medium | Protocol-only prefix checks (`startsWith("http://")`) and unparsed substring checks (`indexOf`, `includes`) on URLs that can be bypassed by attacker subdomains or query parameters. | +| `ssrf-003` Access to cloud metadata or loopback address | critical | Outbound requests or URL configurations targeting internal loopback (`127.0.0.1`, `localhost`) or cloud metadata IP endpoints (`169.254.169.254`, `metadata.google.internal`). | +| `ssrf-004` Disabled TLS certificate verification in HTTP client | high | Insecure HTTP client options explicitly disabling TLS verification (`rejectUnauthorized: false`, `verify=False`). | + +## Runtime Protection + +GasGuard provides `validateUrlForSsrf` and `preventSsrf` in `src/security/ssrf.ts` to enforce: +- Scheme restrictions (`http:`, `https:`) +- DNS resolution of hostnames before issuing requests +- Rejection of private IPv4 (RFC 1918), link-local/metadata (`169.254.0.0/16`), loopback (`127.0.0.0/8`), carrier-grade NAT, and IPv6 unique local/link-local/loopback addresses +- Rejection of DNS rebinding attacks via custom agent wrapping + +## Configuration Options + +Options can be supplied under `AnalyzerConfig.options.ssrf`: +```json +{ + "options": { + "ssrf": { + "safeValidators": ["validateWebhookUrl"], + "additionalSinks": ["myHttpClient.send"], + "additionalSources": ["event.body.target"], + "proximityLines": 10 + } + } +} +``` diff --git a/libs/engine/analyzers/index.ts b/libs/engine/analyzers/index.ts index 137fbec6..5cfbd868 100644 --- a/libs/engine/analyzers/index.ts +++ b/libs/engine/analyzers/index.ts @@ -1,4 +1,4 @@ export { SolidityAnalyzer } from "./solidity-analyzer"; export { RustAnalyzer } from "./rust-analyzer"; export * from "./unsafe-redirect"; -export * from "./unsafe-deserialization"; +export * from "./ssrf"; diff --git a/libs/engine/analyzers/ssrf/config.ts b/libs/engine/analyzers/ssrf/config.ts new file mode 100644 index 00000000..91efbb6f --- /dev/null +++ b/libs/engine/analyzers/ssrf/config.ts @@ -0,0 +1,148 @@ +/** + * Configuration surface for the SSRF analyzer (CWE-918). + * + * Options are read from `AnalyzerConfig.options.ssrf`. + */ + +export const SSRF_OPTIONS_KEY = "ssrf"; + +export interface SsrfOptions { + /** + * Extra validator function names considered safe guards against SSRF. + * Merged with {@link DEFAULT_SAFE_SSRF_VALIDATORS}. + */ + safeValidators?: string[]; + /** + * Extra HTTP request sinks as dotted call names (e.g. `"myClient.send"`). + */ + additionalSinks?: string[]; + /** + * Extra request-controlled input sources (e.g. `"event.queryStringParameters"`). + */ + additionalSources?: string[]; + /** + * How many lines around a request are searched for validation/sanitization guards. + */ + proximityLines?: number; + /** Files larger than this (UTF-8 bytes) are skipped with an error entry. */ + maxFileBytes?: number; +} + +export interface ResolvedSsrfOptions { + safeValidators: string[]; + additionalSinks: string[]; + additionalSources: string[]; + proximityLines: number; + maxFileBytes: number; +} + +export const DEFAULT_SAFE_SSRF_VALIDATORS: readonly string[] = [ + "validateUrlForSsrf", + "preventSsrf", + "isSafeUrl", + "isPublicUrl", + "isValidUrl", + "validateWebhookUrl", + "isSafeWebhook", + "validateHost", + "isAllowedHost", + "isAllowedUrl", + "isSafeDomain", + "createSafeAgent", + "createSsrfSafeAgent", +]; + +export const DEFAULT_PROXIMITY_LINES = 10; +export const MAX_PROXIMITY_LINES = 100; +export const DEFAULT_MAX_FILE_BYTES = 1024 * 1024; // 1 MB +export const MAX_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB + +export const SUPPORTED_SSRF_EXTENSIONS: readonly string[] = [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".py", +]; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const DOTTED_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/; + +export function validateSsrfOptions(raw: unknown): string[] { + if (raw === undefined || raw === null) return []; + if (typeof raw !== "object" || Array.isArray(raw)) { + return [`options.${SSRF_OPTIONS_KEY} must be an object`]; + } + + const errors: string[] = []; + const opts = raw as Record; + + const checkList = (key: string, pattern: RegExp, expected: string) => { + const value = opts[key]; + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push(`options.${SSRF_OPTIONS_KEY}.${key} must be an array of strings`); + return; + } + for (const item of value) { + if (typeof item !== "string" || !pattern.test(item.trim())) { + errors.push( + `options.${SSRF_OPTIONS_KEY}.${key} entry ${JSON.stringify(item)} must be ${expected}`, + ); + } + } + }; + + checkList("safeValidators", IDENTIFIER, "an identifier"); + checkList("additionalSinks", DOTTED_PATH, "a dotted call name"); + checkList("additionalSources", DOTTED_PATH, "a dotted member path"); + + const checkInt = (key: string, min: number, max: number) => { + const value = opts[key]; + if (value === undefined) return; + if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { + errors.push( + `options.${SSRF_OPTIONS_KEY}.${key} must be an integer between ${min} and ${max}`, + ); + } + }; + + checkInt("proximityLines", 1, MAX_PROXIMITY_LINES); + checkInt("maxFileBytes", 1, MAX_MAX_FILE_BYTES); + + return errors; +} + +export function resolveSsrfOptions(raw: SsrfOptions | undefined): ResolvedSsrfOptions { + const opts = raw ?? {}; + return { + safeValidators: Array.from( + new Set([...DEFAULT_SAFE_SSRF_VALIDATORS, ...(opts.safeValidators ?? [])]), + ), + additionalSinks: [...(opts.additionalSinks ?? [])], + additionalSources: [...(opts.additionalSources ?? [])], + proximityLines: opts.proximityLines ?? DEFAULT_PROXIMITY_LINES, + maxFileBytes: opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, + }; +} + +export function fileExtension(filePath: string): string { + const base = filePath.replace(/\\/g, "/").split("/").pop() ?? ""; + const dot = base.lastIndexOf("."); + return dot <= 0 ? "" : base.slice(dot).toLowerCase(); +} + +export function isDeclarationFile(filePath: string): boolean { + return /\.d\.(?:ts|mts|cts)$/i.test(filePath); +} + +export function isSupportedSsrfFile(filePath: string): boolean { + return ( + SUPPORTED_SSRF_EXTENSIONS.includes(fileExtension(filePath)) && + !isDeclarationFile(filePath) + ); +} diff --git a/libs/engine/analyzers/ssrf/index.ts b/libs/engine/analyzers/ssrf/index.ts new file mode 100644 index 00000000..4b06ca8e --- /dev/null +++ b/libs/engine/analyzers/ssrf/index.ts @@ -0,0 +1,4 @@ +export * from "./config"; +export * from "./lexer"; +export * from "./telemetry"; +export * from "./ssrf-analyzer"; diff --git a/libs/engine/analyzers/ssrf/lexer.spec.ts b/libs/engine/analyzers/ssrf/lexer.spec.ts new file mode 100644 index 00000000..a97db321 --- /dev/null +++ b/libs/engine/analyzers/ssrf/lexer.spec.ts @@ -0,0 +1,51 @@ +import { LineIndex, maskComments, maskStringContents, readCallArgs } from "./lexer"; + +describe("ssrf lexer", () => { + describe("maskComments", () => { + it("masks JS single-line comments", () => { + const src = "const x = 1; // comment\nconst y = 2;"; + const masked = maskComments(src); + expect(masked.length).toBe(src.length); + expect(masked).not.toContain("// comment"); + expect(masked).toContain("const x = 1;"); + expect(masked).toContain("const y = 2;"); + }); + + it("masks JS block comments", () => { + const src = "/* line 1\nline 2 */const a = 1;"; + const masked = maskComments(src); + expect(masked.length).toBe(src.length); + expect(masked.includes("/*")).toBe(false); + expect(masked.includes("const a = 1;")).toBe(true); + }); + + it("masks Python # comments", () => { + const src = "url = req.url # get url\nrequests.get(url)"; + const masked = maskComments(src); + expect(masked.length).toBe(src.length); + expect(masked.includes("# get url")).toBe(false); + expect(masked.includes("requests.get(url)")).toBe(true); + }); + }); + + describe("readCallArgs", () => { + it("extracts multiple nested arguments", () => { + const src = 'fetch(req.query.url, { method: "POST", headers: { a: 1 } })'; + const openParen = src.indexOf("(") + 1; + const res = readCallArgs(src, openParen); + expect(res.args).toHaveLength(2); + expect(res.args[0].text).toBe("req.query.url"); + expect(res.args[1].text).toContain('{ method: "POST"'); + }); + }); + + describe("LineIndex", () => { + it("maps character offsets to line and column", () => { + const src = "line1\nline2\nline3"; + const idx = new LineIndex(src); + expect(idx.position(0)).toEqual({ line: 1, column: 1 }); + expect(idx.position(6)).toEqual({ line: 2, column: 1 }); + expect(idx.position(12)).toEqual({ line: 3, column: 1 }); + }); + }); +}); diff --git a/libs/engine/analyzers/ssrf/lexer.ts b/libs/engine/analyzers/ssrf/lexer.ts new file mode 100644 index 00000000..28085849 --- /dev/null +++ b/libs/engine/analyzers/ssrf/lexer.ts @@ -0,0 +1,303 @@ +/** + * Minimal, dependency-free lexical helpers for the SSRF analyzer. + * + * Provides comment masking, string masking, argument extraction, and line indexing + * for JavaScript, TypeScript, and Python source files. + */ + +const REGEX_PRECEDERS = new Set("(,=:[!&|?{};+-*%<>~^".split("")); +const REGEX_PRECEDING_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|case|do|else|in|of|void|yield|await|delete|throw|new)$/; + +/** + * Replace comment bodies with spaces, keeping newlines and other characters in place. + * Supports JS/TS (// and /* *\/) and Python (#) comments. + */ +export function maskComments(code: string): string { + const out = code.split(""); + const n = code.length; + let i = 0; + let lastSignificant = ""; + + const blank = (from: number, to: number) => { + for (let k = from; k < to && k < n; k++) { + if (out[k] !== "\n" && out[k] !== "\r") out[k] = " "; + } + }; + + while (i < n) { + const ch = code[i]; + const next = code[i + 1]; + + // Single-line comment in JS/TS + if (ch === "/" && next === "/") { + const end = code.indexOf("\n", i); + const stop = end === -1 ? n : end; + blank(i, stop); + i = stop; + continue; + } + + // Multi-line comment in JS/TS + if (ch === "/" && next === "*") { + const end = code.indexOf("*/", i + 2); + const stop = end === -1 ? n : end + 2; + blank(i, stop); + i = stop; + continue; + } + + // Single-line comment in Python + if (ch === "#") { + const end = code.indexOf("\n", i); + const stop = end === -1 ? n : end; + blank(i, stop); + i = stop; + continue; + } + + // Strings / quotes + if (ch === "'" || ch === '"' || ch === "`") { + // Check for Python triple-quote + if ( + (ch === "'" || ch === '"') && + code[i + 1] === ch && + code[i + 2] === ch + ) { + const triple = ch + ch + ch; + const end = code.indexOf(triple, i + 3); + const stop = end === -1 ? n : end + 3; + i = stop; + lastSignificant = ch; + continue; + } + i = skipQuoted(code, i); + lastSignificant = ch; + continue; + } + + // Regex in JS/TS + if (ch === "/") { + const startsRegex = + lastSignificant === "" || + REGEX_PRECEDERS.has(lastSignificant) || + REGEX_PRECEDING_KEYWORDS.test(code.slice(Math.max(0, i - 12), i).trimEnd()); + if (startsRegex) { + i = skipRegexLiteral(code, i); + lastSignificant = "/"; + continue; + } + } + + if (!/\s/.test(ch)) lastSignificant = ch; + i++; + } + + return out.join(""); +} + +/** Index just past the closing quote of the string/template starting at `start`. */ +export function skipQuoted(code: string, start: number): number { + const quote = code[start]; + let i = start + 1; + while (i < code.length) { + const ch = code[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === quote) return i + 1; + // Unterminated single/double-quoted strings end at the line break. + if (ch === "\n" && quote !== "`") return i; + i++; + } + return code.length; +} + +function skipRegexLiteral(code: string, start: number): number { + let i = start + 1; + let inClass = false; + while (i < code.length) { + const ch = code[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "\n") return i; + if (inClass) { + if (ch === "]") inClass = false; + } else if (ch === "[") { + inClass = true; + } else if (ch === "/") { + i++; + while (i < code.length && /[a-z]/i.test(code[i])) i++; + return i; + } + i++; + } + return code.length; +} + +/** + * Blank the contents of string literals (except ${...} template interpolations) + * so identifier scanning does not pick up words inside strings. + */ +export function maskStringContents(expr: string): string { + const out = expr.split(""); + let i = 0; + while (i < expr.length) { + const ch = expr[i]; + if (ch === "'" || ch === '"') { + const end = skipQuoted(expr, i); + for (let k = i + 1; k < end - 1; k++) out[k] = " "; + i = end; + continue; + } + if (ch === "`") { + let k = i + 1; + let depth = 0; + while (k < expr.length) { + const c = expr[k]; + if (depth === 0 && c === "\\") { + out[k] = " "; + if (k + 1 < expr.length) out[k + 1] = " "; + k += 2; + continue; + } + if (depth === 0 && c === "`") break; + if (depth === 0 && c === "$" && expr[k + 1] === "{") { + depth = 1; + k += 2; + continue; + } + if (depth > 0) { + if (c === "{") depth++; + else if (c === "}") depth--; + k++; + continue; + } + out[k] = " "; + k++; + } + i = k + 1; + continue; + } + i++; + } + return out.join(""); +} + +const OPENERS: Record = { "(": ")", "[": "]", "{": "}" }; +const CLOSERS = new Set([")", "]", "}"]); + +/** + * Read call arguments starting just after an opening parenthesis. + */ +export function readCallArgs( + text: string, + start: number, + maxLength = 4000, +): { args: Array<{ text: string; offset: number }>; end: number } { + const args: Array<{ text: string; offset: number }> = []; + let depth = 0; + let argStart = start; + let i = start; + const limit = Math.min(text.length, start + maxLength); + + while (i < limit) { + const ch = text[i]; + if (ch === "'" || ch === '"' || ch === "`") { + i = skipQuoted(text, i); + continue; + } + if (OPENERS[ch]) { + depth++; + } else if (CLOSERS.has(ch)) { + if (depth === 0) { + pushArg(args, text, argStart, i); + return { args, end: i }; + } + depth--; + } else if (ch === "," && depth === 0) { + pushArg(args, text, argStart, i); + argStart = i + 1; + } + i++; + } + pushArg(args, text, argStart, i); + return { args, end: i }; +} + +function pushArg( + args: Array<{ text: string; offset: number }>, + text: string, + from: number, + to: number, +): void { + const raw = text.slice(from, to); + const lead = raw.length - raw.trimStart().length; + const trimmed = raw.trim(); + if (trimmed) args.push({ text: trimmed, offset: from + lead }); +} + +/** + * Read an expression until a top-level delimiter. + */ +export function readExpression(text: string, start: number, maxLength = 2000): string { + let depth = 0; + let i = start; + const limit = Math.min(text.length, start + maxLength); + let sawContent = false; + + while (i < limit) { + const ch = text[i]; + if (ch === "'" || ch === '"' || ch === "`") { + i = skipQuoted(text, i); + sawContent = true; + continue; + } + if (OPENERS[ch]) { + depth++; + } else if (CLOSERS.has(ch)) { + if (depth === 0) break; + depth--; + } else if (depth === 0 && (ch === ";" || ch === ",")) { + break; + } else if (depth === 0 && ch === "\n" && sawContent) { + const soFar = text.slice(start, i).trimEnd(); + if (!/[+\-*/%&|?:=<>(,]$/.test(soFar)) break; + } + if (!/\s/.test(ch)) sawContent = true; + i++; + } + return text.slice(start, i).trim(); +} + +/** Maps character offsets to 1-based line/column positions. */ +export class LineIndex { + private readonly starts: number[] = [0]; + + constructor(text: string) { + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") this.starts.push(i + 1); + } + } + + get lineCount(): number { + return this.starts.length; + } + + position(offset: number): { line: number; column: number } { + let lo = 0; + let hi = this.starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.starts[mid] <= offset) lo = mid; + else hi = mid - 1; + } + return { line: lo + 1, column: offset - this.starts[lo] + 1 }; + } +} + +export function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/libs/engine/analyzers/ssrf/ssrf-analyzer.spec.ts b/libs/engine/analyzers/ssrf/ssrf-analyzer.spec.ts new file mode 100644 index 00000000..1626253c --- /dev/null +++ b/libs/engine/analyzers/ssrf/ssrf-analyzer.spec.ts @@ -0,0 +1,213 @@ +import { Language, Severity } from "../../core/analyzer-interface"; +import { SSRF_RULES, SsrfAnalyzer } from "./ssrf-analyzer"; +import { SsrfAnalysisMetrics } from "./telemetry"; + +describe("SsrfAnalyzer", () => { + let analyzer: SsrfAnalyzer; + + beforeEach(() => { + analyzer = new SsrfAnalyzer(); + }); + + describe("metadata and capabilities", () => { + it("reports correct name and version", () => { + expect(analyzer.getName()).toBe("ssrf"); + expect(analyzer.getVersion()).toBe("1.0.0"); + }); + + it("supports JS, TS, and Python languages", () => { + expect(analyzer.supportsLanguage(Language.JAVASCRIPT)).toBe(true); + expect(analyzer.supportsLanguage(Language.TYPESCRIPT)).toBe(true); + expect(analyzer.supportsLanguage("js")).toBe(true); + expect(analyzer.supportsLanguage("ts")).toBe(true); + expect(analyzer.supportsLanguage("python")).toBe(true); + expect(analyzer.supportsLanguage("py")).toBe(true); + expect(analyzer.supportsLanguage("solidity")).toBe(false); + }); + + it("exposes all 4 SSRF rules", () => { + const rules = analyzer.getRules(); + expect(rules).toHaveLength(4); + const ruleIds = rules.map((r) => r.id); + expect(ruleIds).toContain(SSRF_RULES.UNVALIDATED_REQUEST); + expect(ruleIds).toContain(SSRF_RULES.WEAK_VALIDATION); + expect(ruleIds).toContain(SSRF_RULES.CLOUD_METADATA_LOOPBACK); + expect(ruleIds).toContain(SSRF_RULES.DISABLED_TLS_VERIFICATION); + }); + + it("can look up a single rule by id", () => { + const rule = analyzer.getRule(SSRF_RULES.UNVALIDATED_REQUEST); + expect(rule).toBeDefined(); + expect(rule?.severity).toBe(Severity.HIGH); + expect(analyzer.getRule("non-existent")).toBeUndefined(); + }); + }); + + describe("Rule ssrf-001: Unvalidated HTTP request", () => { + it("flags fetch with query input", async () => { + const code = ` + app.get('/proxy', async (req, res) => { + const target = req.query.url; + const resp = await fetch(target); + res.send(await resp.text()); + }); + `; + const result = await analyzer.analyze(code, "src/proxy.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.UNVALIDATED_REQUEST); + expect(f.length).toBeGreaterThan(0); + expect(f[0].severity).toBe(Severity.HIGH); + }); + + it("flags axios with body input", async () => { + const code = ` + export async function handler(req: any) { + const webhookUrl = req.body.url; + return axios.post(webhookUrl, { data: 123 }); + } + `; + const result = await analyzer.analyze(code, "src/webhook.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.UNVALIDATED_REQUEST); + expect(f.length).toBeGreaterThan(0); + }); + + it("flags Python requests with request.args", async () => { + const code = ` + @app.route('/fetch') + def fetch_url(): + target_url = request.args.get('target') + return requests.get(target_url).text + `; + const result = await analyzer.analyze(code, "app.py"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.UNVALIDATED_REQUEST); + expect(f.length).toBeGreaterThan(0); + }); + + it("does not flag fetch when target is protected by validateUrlForSsrf", async () => { + const code = ` + app.get('/proxy', async (req, res) => { + const target = req.query.url; + await validateUrlForSsrf(target); + const resp = await fetch(target); + res.send(await resp.text()); + }); + `; + const result = await analyzer.analyze(code, "src/proxy.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.UNVALIDATED_REQUEST); + expect(f.length).toBe(0); + }); + + it("does not flag hardcoded safe URLs", async () => { + const code = ` + export async function getStatus() { + return fetch("https://api.github.com/status"); + } + `; + const result = await analyzer.analyze(code, "src/status.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.UNVALIDATED_REQUEST); + expect(f.length).toBe(0); + }); + }); + + describe("Rule ssrf-002: Weak URL validation", () => { + it("flags protocol-only startsWith checks", async () => { + const code = ` + function checkUrl(url: string) { + if (!url.startsWith("https://")) { + throw new Error("Invalid URL"); + } + } + `; + const result = await analyzer.analyze(code, "src/validator.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.WEAK_VALIDATION); + expect(f.length).toBeGreaterThan(0); + expect(f[0].severity).toBe(Severity.MEDIUM); + }); + + it("flags unparsed substring indexOf check on domain", async () => { + const code = ` + function isAllowed(url: string) { + return url.indexOf("internal-domain.com") !== -1; + } + `; + const result = await analyzer.analyze(code, "src/check.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.WEAK_VALIDATION); + expect(f.length).toBeGreaterThan(0); + }); + }); + + describe("Rule ssrf-003: Cloud metadata & loopback access", () => { + it("flags AWS/GCP metadata IP 169.254.169.254", async () => { + const code = ` + const metadataUrl = "http://169.254.169.254/latest/meta-data/"; + fetch(metadataUrl); + `; + const result = await analyzer.analyze(code, "src/meta.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.CLOUD_METADATA_LOOPBACK); + expect(f.length).toBeGreaterThan(0); + expect(f[0].severity).toBe(Severity.CRITICAL); + }); + + it("flags metadata.google.internal access", async () => { + const code = ` + const gcpMeta = "http://metadata.google.internal/computeMetadata/v1/"; + `; + const result = await analyzer.analyze(code, "src/gcp.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.CLOUD_METADATA_LOOPBACK); + expect(f.length).toBeGreaterThan(0); + }); + + it("flags loopback 127.0.0.1", async () => { + const code = ` + const internalApi = "http://127.0.0.1:8080/admin"; + `; + const result = await analyzer.analyze(code, "src/admin.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.CLOUD_METADATA_LOOPBACK); + expect(f.length).toBeGreaterThan(0); + }); + }); + + describe("Rule ssrf-004: Disabled TLS verification", () => { + it("flags rejectUnauthorized: false in Node https agent", async () => { + const code = ` + const agent = new https.Agent({ + rejectUnauthorized: false + }); + `; + const result = await analyzer.analyze(code, "src/client.ts"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.DISABLED_TLS_VERIFICATION); + expect(f.length).toBeGreaterThan(0); + expect(f[0].severity).toBe(Severity.HIGH); + }); + + it("flags verify=False in Python requests", async () => { + const code = ` + response = requests.get('https://example.com', verify=False) + `; + const result = await analyzer.analyze(code, "client.py"); + const f = result.findings.filter((x) => x.ruleId === SSRF_RULES.DISABLED_TLS_VERIFICATION); + expect(f.length).toBeGreaterThan(0); + }); + }); + + describe("options and metrics observer", () => { + it("tracks metrics when an observer is passed", async () => { + const metrics = new SsrfAnalysisMetrics(); + const obsAnalyzer = new SsrfAnalyzer({ observer: metrics }); + + await obsAnalyzer.analyze( + "const u = 'http://169.254.169.254';", + "test.ts", + ); + + const snapshot = metrics.snapshot(); + expect(snapshot.filesAnalyzed).toBe(1); + expect(snapshot.totalFindings).toBeGreaterThan(0); + }); + + it("skips declaration files without findings", async () => { + const result = await analyzer.analyze("declare const x: any;", "types.d.ts"); + expect(result.filesAnalyzed).toBe(0); + expect(result.findings).toHaveLength(0); + }); + }); +}); diff --git a/libs/engine/analyzers/ssrf/ssrf-analyzer.ts b/libs/engine/analyzers/ssrf/ssrf-analyzer.ts new file mode 100644 index 00000000..cabbb159 --- /dev/null +++ b/libs/engine/analyzers/ssrf/ssrf-analyzer.ts @@ -0,0 +1,687 @@ +import { + AnalyzerCapabilities, + AnalysisResult, + AnalyzerConfig, + BaseAnalyzer, + Finding, + Language, + Rule, + Severity, +} from "../../core/analyzer-interface"; +import { + fileExtension, + isDeclarationFile, + isSupportedSsrfFile, + resolveSsrfOptions, + ResolvedSsrfOptions, + SSRF_OPTIONS_KEY, + SsrfOptions, + validateSsrfOptions, +} from "./config"; +import { + escapeRegExp, + LineIndex, + maskComments, + maskStringContents, + readCallArgs, +} from "./lexer"; +import { + notifySafely, + SsrfAnalysisObserver, + SsrfSkipReason, +} from "./telemetry"; + +export const SSRF_RULES = { + UNVALIDATED_REQUEST: "ssrf-001", + WEAK_VALIDATION: "ssrf-002", + CLOUD_METADATA_LOOPBACK: "ssrf-003", + DISABLED_TLS_VERIFICATION: "ssrf-004", +} as const; + +interface SourcePattern { + kind: string; + re: RegExp; +} + +const BUILTIN_SOURCES: SourcePattern[] = [ + { + kind: "request-input", + re: /\b(?:req|request|ctx\.request|ctx|event)\s*\.\s*(?:query|body|params|headers|queryStringParameters)\b/, + }, + { + kind: "search-params", + re: /\b(?:[\w$]*[sS]earchParams|query|params)\s*\.\s*(?:get|getAll)\s*\(/, + }, + { + kind: "python-request", + re: /\brequest\s*\.\s*(?:args|form|values|json|data|GET|POST)\b/, + }, +]; + +const METADATA_OR_LOOPBACK_RE = + /\b(?:169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200|fd00:ec2::254|127\.0\.0\.1|localhost|0\.0\.0\.0|::1)\b/i; + +const RULES: readonly Rule[] = [ + { + id: SSRF_RULES.UNVALIDATED_REQUEST, + name: "Unvalidated HTTP request to user-controlled URL", + description: + "Performs an HTTP request using an unvalidated or user-controlled URL target, allowing Server-Side Request Forgery (SSRF).", + severity: Severity.HIGH, + category: "security", + enabled: true, + tags: ["security", "cwe-918", "ssrf"], + documentationUrl: "https://cwe.mitre.org/data/definitions/918.html", + }, + { + id: SSRF_RULES.WEAK_VALIDATION, + name: "Weak or bypassable SSRF URL validation", + description: + "Uses a weak validation pattern (such as protocol startsWith or unparsed substring checks) that does not protect against SSRF.", + severity: Severity.MEDIUM, + category: "security", + enabled: true, + tags: ["security", "cwe-918", "ssrf", "validation-bypass"], + documentationUrl: "https://cwe.mitre.org/data/definitions/918.html", + }, + { + id: SSRF_RULES.CLOUD_METADATA_LOOPBACK, + name: "Access to cloud metadata or loopback address", + description: + "Request target accesses internal loopback or cloud metadata services (e.g. 169.254.169.254), leading to credential exposure.", + severity: Severity.CRITICAL, + category: "security", + enabled: true, + tags: ["security", "cwe-918", "ssrf", "cloud-metadata"], + documentationUrl: "https://cwe.mitre.org/data/definitions/918.html", + }, + { + id: SSRF_RULES.DISABLED_TLS_VERIFICATION, + name: "Disabled TLS certificate verification in HTTP client", + description: + "HTTP client options explicitly disable TLS verification (e.g. rejectUnauthorized: false or verify=False).", + severity: Severity.HIGH, + category: "security", + enabled: true, + tags: ["security", "tls", "ssrf", "cwe-295"], + documentationUrl: "https://cwe.mitre.org/data/definitions/295.html", + }, +]; + +export interface SsrfAnalyzerOptions { + observer?: SsrfAnalysisObserver; +} + +export class SsrfAnalyzer extends BaseAnalyzer { + static readonly NAME = "ssrf"; + static readonly VERSION = "1.0.0"; + + private readonly observer?: SsrfAnalysisObserver; + + constructor(options: SsrfAnalyzerOptions = {}) { + super(); + this.observer = options.observer; + } + + getName(): string { + return SsrfAnalyzer.NAME; + } + + getVersion(): string { + return SsrfAnalyzer.VERSION; + } + + supportsLanguage(language: Language | string): boolean { + return ( + language === Language.JAVASCRIPT || + language === Language.TYPESCRIPT || + language === "js" || + language === "ts" || + language === "python" || + language === "py" + ); + } + + getSupportedLanguages(): Language[] { + return [Language.JAVASCRIPT, Language.TYPESCRIPT]; + } + + getRules(): Rule[] { + return RULES.map((r) => ({ ...r, tags: r.tags ? [...r.tags] : undefined })); + } + + getRule(ruleId: string): Rule | undefined { + return this.getRules().find((r) => r.id === ruleId); + } + + getCapabilities(): AnalyzerCapabilities { + return { + ...super.getCapabilities(), + securityAnalysis: true, + cwe: ["CWE-918", "CWE-295"], + fileExtensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".py", + ], + }; + } + + validateConfig(config: AnalyzerConfig): string[] { + const errors = super.validateConfig(config); + const severities = new Set(Object.values(Severity)); + for (const [ruleId, rc] of Object.entries(config.rules ?? {})) { + if (rc === null || (typeof rc !== "boolean" && typeof rc !== "object")) { + errors.push(`Rule ${ruleId} config must be a boolean or an object`); + } else if ( + typeof rc === "object" && + rc.severity !== undefined && + !severities.has(rc.severity) + ) { + errors.push(`Rule ${ruleId} has unknown severity: ${String(rc.severity)}`); + } + } + if ( + config.maxFindings !== undefined && + (!Number.isInteger(config.maxFindings) || config.maxFindings < 0) + ) { + errors.push("maxFindings must be a non-negative integer"); + } + errors.push(...validateSsrfOptions(config.options?.[SSRF_OPTIONS_KEY])); + return errors; + } + + private assertValidConfig(config?: AnalyzerConfig): AnalyzerConfig { + const cfg = config ?? this.config; + const errors = this.validateConfig(cfg); + if (errors.length > 0) { + throw new Error(`Invalid configuration: ${errors.join(", ")}`); + } + return cfg; + } + + async analyze( + code: string, + filePath: string, + config?: AnalyzerConfig, + ): Promise { + const startTime = Date.now(); + const cfg = this.assertValidConfig(config); + if (!this.initialized) await this.initialize(config); + const opts = resolveSsrfOptions( + cfg.options?.[SSRF_OPTIONS_KEY] as SsrfOptions | undefined, + ); + + const skip = (reason: SsrfSkipReason, message?: string): AnalysisResult => { + const file = typeof filePath === "string" ? filePath : ""; + notifySafely(() => this.observer?.onFileSkipped?.({ file, reason })); + return { + findings: [], + filesAnalyzed: 0, + analysisTime: Date.now() - startTime, + analyzerVersion: SsrfAnalyzer.VERSION, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + errors: message ? [{ file, message }] : undefined, + }; + }; + + if (typeof code !== "string" || typeof filePath !== "string") { + return skip("invalid-input", "code and filePath must be strings"); + } + + if (isDeclarationFile(filePath)) { + return skip("declaration-file"); + } + + if (!isSupportedSsrfFile(filePath)) { + return skip("unsupported-file-type"); + } + + const byteLength = Buffer.byteLength(code, "utf8"); + if (byteLength > opts.maxFileBytes) { + return skip( + "file-too-large", + `File size (${byteLength} bytes) exceeds limit (${opts.maxFileBytes} bytes)`, + ); + } + + if (code.includes("\0")) { + return skip("binary-content", "Binary content detected (null byte present)"); + } + + const findings = this.scanFile(code, filePath, cfg, opts); + const durationMs = Date.now() - startTime; + + const findingsByRule: Record = {}; + for (const f of findings) { + findingsByRule[f.ruleId] = (findingsByRule[f.ruleId] ?? 0) + 1; + } + + notifySafely(() => + this.observer?.onFileAnalyzed?.({ + file: filePath, + durationMs, + findingsByRule, + }), + ); + + return { + findings, + filesAnalyzed: 1, + analysisTime: durationMs, + analyzerVersion: SsrfAnalyzer.VERSION, + summary: this.computeSummary(findings), + }; + } + + private scanFile( + code: string, + filePath: string, + config: AnalyzerConfig, + opts: ResolvedSsrfOptions, + ): Finding[] { + const lines = new LineIndex(code); + const masked = maskComments(code); + const findings: Finding[] = []; + + const isRuleActive = (ruleId: string): boolean => { + const entry = config.rules?.[ruleId]; + if (entry === false) return false; + if (typeof entry === "object" && entry.enabled === false) return false; + return true; + }; + + const ruleSeverity = (ruleId: string, defaultSev: Severity): Severity => { + const entry = config.rules?.[ruleId]; + if (typeof entry === "object" && entry.severity) return entry.severity; + return defaultSev; + }; + + // 1. Scan for Cloud Metadata & Loopback access (ssrf-003) + if (isRuleActive(SSRF_RULES.CLOUD_METADATA_LOOPBACK)) { + const metaMatches = this.findMetadataAccess(masked, lines, filePath); + for (const m of metaMatches) { + findings.push({ + ruleId: SSRF_RULES.CLOUD_METADATA_LOOPBACK, + message: `Request target references cloud metadata or loopback address (${m.target}), which can expose internal infrastructure or credentials.`, + severity: ruleSeverity( + SSRF_RULES.CLOUD_METADATA_LOOPBACK, + Severity.CRITICAL, + ), + location: { + file: filePath, + startLine: m.line, + endLine: m.line, + startColumn: m.column, + }, + suggestedFix: { + description: + "Disallow requests to internal, loopback (127.0.0.1, localhost) and metadata IP ranges (169.254.169.254).", + }, + }); + } + } + + // 2. Scan for Disabled TLS Verification (ssrf-004) + if (isRuleActive(SSRF_RULES.DISABLED_TLS_VERIFICATION)) { + const tlsMatches = this.findDisabledTlsVerification(masked, lines, filePath); + for (const m of tlsMatches) { + findings.push({ + ruleId: SSRF_RULES.DISABLED_TLS_VERIFICATION, + message: `HTTP client options disable TLS certificate validation (${m.matched}), exposing connections to man-in-the-middle attacks.`, + severity: ruleSeverity( + SSRF_RULES.DISABLED_TLS_VERIFICATION, + Severity.HIGH, + ), + location: { + file: filePath, + startLine: m.line, + endLine: m.line, + startColumn: m.column, + }, + suggestedFix: { + description: + "Remove rejectUnauthorized: false or verify=False to enforce TLS certificate verification.", + }, + }); + } + } + + // 3. Scan for Weak / Bypassable URL Validation (ssrf-002) + if (isRuleActive(SSRF_RULES.WEAK_VALIDATION)) { + const weakMatches = this.findWeakValidation(masked, lines, filePath); + for (const m of weakMatches) { + findings.push({ + ruleId: SSRF_RULES.WEAK_VALIDATION, + message: `Weak SSRF validation pattern (${m.reason}): '${m.snippet}' can be bypassed by attacker-crafted domains or query parameters.`, + severity: ruleSeverity(SSRF_RULES.WEAK_VALIDATION, Severity.MEDIUM), + location: { + file: filePath, + startLine: m.line, + endLine: m.line, + startColumn: m.column, + }, + suggestedFix: { + description: + "Parse the URL and validate the hostname against a strict allowlist using validateUrlForSsrf.", + }, + }); + } + } + + // 4. Scan for Unvalidated HTTP Requests (ssrf-001) + if (isRuleActive(SSRF_RULES.UNVALIDATED_REQUEST)) { + const taintMap = this.collectTaint(masked, opts); + const sinkCalls = this.findHttpSinks(masked, lines, opts); + + for (const sink of sinkCalls) { + // If within proximity lines there is a call to a safe validator, skip finding + if (this.hasValidatorInProximity(masked, lines, sink.line, opts)) { + continue; + } + + // Check if sink target argument is tainted by user input or carries url param + const isTainted = this.isArgumentTainted(sink.targetArg, taintMap, masked); + if (isTainted) { + findings.push({ + ruleId: SSRF_RULES.UNVALIDATED_REQUEST, + message: `Unvalidated HTTP request to '${sink.targetArg}' may allow Server-Side Request Forgery (SSRF). User input is passed directly to an outbound request.`, + severity: ruleSeverity(SSRF_RULES.UNVALIDATED_REQUEST, Severity.HIGH), + location: { + file: filePath, + startLine: sink.line, + endLine: sink.line, + startColumn: sink.column, + }, + suggestedFix: { + description: + "Validate the target URL with validateUrlForSsrf or preventSsrf before making the request.", + }, + }); + } + } + } + + // Sort findings deterministically by line, then column, then ruleId + findings.sort((a, b) => { + if (a.location.startLine !== b.location.startLine) { + return a.location.startLine - b.location.startLine; + } + if ((a.location.startColumn ?? 0) !== (b.location.startColumn ?? 0)) { + return (a.location.startColumn ?? 0) - (b.location.startColumn ?? 0); + } + return a.ruleId.localeCompare(b.ruleId); + }); + + if (config.maxFindings !== undefined && findings.length > config.maxFindings) { + return findings.slice(0, config.maxFindings); + } + + return findings; + } + + private findMetadataAccess( + code: string, + lines: LineIndex, + _filePath: string, + ): Array<{ target: string; line: number; column: number }> { + const results: Array<{ target: string; line: number; column: number }> = []; + const re = new RegExp(METADATA_OR_LOOPBACK_RE.source, "gi"); + let match: RegExpExecArray | null; + + while ((match = re.exec(code)) !== null) { + const pos = lines.position(match.index); + results.push({ + target: match[0], + line: pos.line, + column: pos.column, + }); + } + return results; + } + + private findDisabledTlsVerification( + code: string, + lines: LineIndex, + _filePath: string, + ): Array<{ matched: string; line: number; column: number }> { + const results: Array<{ matched: string; line: number; column: number }> = []; + const tlsPatterns = [ + /\brejectUnauthorized\s*:\s*false\b/g, + /\bverify\s*=\s*False\b/g, + /\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0['"]?/g, + ]; + + for (const pat of tlsPatterns) { + let match: RegExpExecArray | null; + while ((match = pat.exec(code)) !== null) { + const pos = lines.position(match.index); + results.push({ + matched: match[0], + line: pos.line, + column: pos.column, + }); + } + } + return results; + } + + private findWeakValidation( + code: string, + lines: LineIndex, + _filePath: string, + ): Array<{ reason: string; snippet: string; line: number; column: number }> { + const results: Array<{ + reason: string; + snippet: string; + line: number; + column: number; + }> = []; + + // Protocol startsWith check (e.g. url.startsWith("http://") or startsWith("https://")) + const protocolRe = + /\b(?:startsWith|\.startsWith|startswith)\s*\(\s*\(?['"`]https?:\/\/['"`]/g; + let pMatch: RegExpExecArray | null; + while ((pMatch = protocolRe.exec(code)) !== null) { + const pos = lines.position(pMatch.index); + results.push({ + reason: "protocol prefix check allows arbitrary hosts", + snippet: pMatch[0], + line: pos.line, + column: pos.column, + }); + } + + // Substring host check without URL parsing (e.g. url.indexOf("example.com") !== -1 or url.includes("example.com")) + const substringRe = + /\.(?:indexOf|includes)\s*\(\s*['"`](?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}['"`]\s*\)/g; + let sMatch: RegExpExecArray | null; + while ((sMatch = substringRe.exec(code)) !== null) { + // If code preceding this line within 3 lines contains new URL( or urlparse, it might be parsing + const pos = lines.position(sMatch.index); + results.push({ + reason: "unparsed substring check can match subdomains or path query parameters", + snippet: sMatch[0], + line: pos.line, + column: pos.column, + }); + } + + return results; + } + + private collectTaint(code: string, opts: ResolvedSsrfOptions): Set { + const tainted = new Set(); + const sources = [...BUILTIN_SOURCES]; + + for (const src of opts.additionalSources) { + sources.push({ + kind: "custom", + re: new RegExp(`\\b${escapeRegExp(src)}\\b`), + }); + } + + // Direct variable assignments: const url = req.query.url / request.args.get("url") + const assignRe = + /(?:(?:const|let|var)\s+([\w$]+)\s*=\s*([^;\n]+)|([\w$]+)\s*=\s*([^;\n]+))/g; + let match: RegExpExecArray | null; + + while ((match = assignRe.exec(code)) !== null) { + const varName = match[1] || match[3]; + const rhs = match[2] || match[4]; + if (!varName || !rhs) continue; + + for (const src of sources) { + if (src.re.test(rhs)) { + tainted.add(varName); + break; + } + } + + // Propagate if rhs contains an already tainted identifier + for (const t of tainted) { + const idRe = new RegExp(`\\b${escapeRegExp(t)}\\b`); + if (idRe.test(rhs)) { + tainted.add(varName); + break; + } + } + } + + return tainted; + } + + private findHttpSinks( + code: string, + lines: LineIndex, + opts: ResolvedSsrfOptions, + ): Array<{ targetArg: string; line: number; column: number }> { + const sinks: Array<{ targetArg: string; line: number; column: number }> = []; + + const sinkPatterns = [ + // fetch(url, ...) + /\bfetch\s*\(/g, + // axios.get(url, ...) or axios(url, ...) + /\baxios(?:\.(?:get|post|put|delete|patch|head|options|request))?\s*\(/g, + // http.get(url, ...) or https.request(url, ...) + /\b(?:https?|http)\s*\.(?:get|request)\s*\(/g, + // got(url, ...) + /\bgot(?:\.(?:get|post|put|delete))?\s*\(/g, + // superagent.get(url) + /\bsuperagent\s*\.(?:get|post|put|del)\s*\(/g, + // needle.get(url) + /\bneedle\s*\.(?:get|post)\s*\(/g, + // urllib.request(url) + /\burllib\s*\.request\s*\(/g, + // Python: requests.get(url, ...) + /\brequests\s*\.(?:get|post|put|delete|patch|head|options|request)\s*\(/g, + // Python: urllib.request.urlopen(url) + /\burllib\.request\.urlopen\s*\(/g, + // Python: httpx.get(url) + /\bhttpx\s*\.(?:get|post|request)\s*\(/g, + // Python: aiohttp session + /\b(?:session|client)\s*\.(?:get|post)\s*\(/g, + ]; + + for (const customSink of opts.additionalSinks) { + sinkPatterns.push(new RegExp(`\\b${escapeRegExp(customSink)}\\s*\\(`, "g")); + } + + for (const pat of sinkPatterns) { + let match: RegExpExecArray | null; + while ((match = pat.exec(code)) !== null) { + const openParenIndex = match.index + match[0].length; + const callArgs = readCallArgs(code, openParenIndex); + if (callArgs.args.length > 0) { + const firstArg = callArgs.args[0].text; + const pos = lines.position(match.index); + sinks.push({ + targetArg: firstArg, + line: pos.line, + column: pos.column, + }); + } + } + } + + return sinks; + } + + private hasValidatorInProximity( + code: string, + lines: LineIndex, + sinkLine: number, + opts: ResolvedSsrfOptions, + ): boolean { + const startLine = Math.max(1, sinkLine - opts.proximityLines); + const endLine = sinkLine; + + // Split code lines + const allLines = code.split("\n"); + const window = allLines.slice(startLine - 1, endLine).join("\n"); + + for (const val of opts.safeValidators) { + const valRe = new RegExp(`\\b${escapeRegExp(val)}\\s*\\(`, "i"); + if (valRe.test(window)) { + return true; + } + } + + return false; + } + + private isArgumentTainted( + arg: string, + taintSet: Set, + code: string, + ): boolean { + const trimmed = arg.trim(); + + // Constant string literals starting with http/https are safe unless loopback/metadata + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return false; + } + + // Check direct request source patterns inside the argument + for (const src of BUILTIN_SOURCES) { + if (src.re.test(trimmed)) return true; + } + + // Check if the argument references any tainted variable + for (const t of taintSet) { + const tRe = new RegExp(`\\b${escapeRegExp(t)}\\b`); + if (tRe.test(trimmed)) return true; + } + + // Common dangerous parameter names passed directly to fetch: + // e.g. targetUrl, webhookUrl, remoteUrl, userUrl, callbackUrl + if ( + /\b(?:targetUrl|webhookUrl|remoteUrl|apiUrl|feedUrl|userUrl|destUrl|requestedUrl|externalUrl)\b/i.test( + trimmed, + ) + ) { + return true; + } + + return false; + } + + private computeSummary(findings: Finding[]) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + for (const f of findings) { + if (f.severity in summary) { + summary[f.severity]++; + } + } + return summary; + } +} diff --git a/libs/engine/analyzers/ssrf/telemetry.ts b/libs/engine/analyzers/ssrf/telemetry.ts new file mode 100644 index 00000000..5a90638d --- /dev/null +++ b/libs/engine/analyzers/ssrf/telemetry.ts @@ -0,0 +1,82 @@ +/** + * Telemetry hooks for the SSRF analyzer. + * + * Events carry only file paths, rule ids, counts, durations and skip reasons. + * They never carry source text, target URLs, or matched literals. + */ + +export type SsrfSkipReason = + | "unsupported-file-type" + | "declaration-file" + | "file-too-large" + | "binary-content" + | "invalid-input" + | "excluded-by-config"; + +export interface SsrfFileAnalyzedEvent { + file: string; + durationMs: number; + findingsByRule: Record; +} + +export interface SsrfFileSkippedEvent { + file: string; + reason: SsrfSkipReason; +} + +export interface SsrfAnalysisObserver { + onFileAnalyzed?(event: SsrfFileAnalyzedEvent): void; + onFileSkipped?(event: SsrfFileSkippedEvent): void; +} + +export interface SsrfAnalysisMetricsSnapshot { + filesAnalyzed: number; + filesSkipped: number; + skippedByReason: Partial>; + findingsByRule: Record; + totalFindings: number; + totalDurationMs: number; +} + +/** In-process counters; export via metrics backend. */ +export class SsrfAnalysisMetrics implements SsrfAnalysisObserver { + private filesAnalyzed = 0; + private filesSkipped = 0; + private totalDurationMs = 0; + private readonly skippedByReason: Partial> = {}; + private readonly findingsByRule: Record = {}; + + onFileAnalyzed(event: SsrfFileAnalyzedEvent): void { + this.filesAnalyzed++; + this.totalDurationMs += event.durationMs; + for (const [ruleId, count] of Object.entries(event.findingsByRule)) { + this.findingsByRule[ruleId] = (this.findingsByRule[ruleId] ?? 0) + count; + } + } + + onFileSkipped(event: SsrfFileSkippedEvent): void { + this.filesSkipped++; + this.skippedByReason[event.reason] = (this.skippedByReason[event.reason] ?? 0) + 1; + } + + snapshot(): SsrfAnalysisMetricsSnapshot { + const findingsByRule = { ...this.findingsByRule }; + return { + filesAnalyzed: this.filesAnalyzed, + filesSkipped: this.filesSkipped, + skippedByReason: { ...this.skippedByReason }, + findingsByRule, + totalFindings: Object.values(findingsByRule).reduce((a, b) => a + b, 0), + totalDurationMs: this.totalDurationMs, + }; + } +} + +/** Invoke an observer hook safely. */ +export function notifySafely(invoke: () => void): void { + try { + invoke(); + } catch { + // Telemetry must never affect analysis results. + } +} diff --git a/src/security/ssrf.spec.ts b/src/security/ssrf.spec.ts new file mode 100644 index 00000000..df5a3550 --- /dev/null +++ b/src/security/ssrf.spec.ts @@ -0,0 +1,78 @@ +import { + isMetadataOrLoopback, + validateUrlForSsrf, + preventSsrf, + createSsrfSafeAgent, +} from './ssrf'; + +describe('SSRF Runtime Security Module', () => { + describe('isMetadataOrLoopback', () => { + it('identifies cloud metadata hostnames', () => { + expect(isMetadataOrLoopback('169.254.169.254')).toBe(true); + expect(isMetadataOrLoopback('metadata.google.internal')).toBe(true); + expect(isMetadataOrLoopback('instance-data')).toBe(true); + }); + + it('identifies loopback addresses', () => { + expect(isMetadataOrLoopback('localhost')).toBe(true); + expect(isMetadataOrLoopback('127.0.0.1')).toBe(true); + expect(isMetadataOrLoopback('::1')).toBe(true); + expect(isMetadataOrLoopback('0.0.0.0')).toBe(true); + }); + + it('allows external domains', () => { + expect(isMetadataOrLoopback('api.stellar.org')).toBe(false); + expect(isMetadataOrLoopback('github.com')).toBe(false); + }); + }); + + describe('validateUrlForSsrf', () => { + it('blocks private IP literals', async () => { + const res = await validateUrlForSsrf('http://192.168.1.100/admin'); + expect(res.valid).toBe(false); + expect(res.reason).toContain('PRIVATE_IP_ADDRESS'); + }); + + it('blocks metadata URLs', async () => { + const res = await validateUrlForSsrf('http://169.254.169.254/latest/meta-data/'); + expect(res.valid).toBe(false); + expect(res.reason).toContain('RESTRICTED_HOST'); + }); + + it('blocks non-HTTP protocols', async () => { + const res = await validateUrlForSsrf('file:///etc/passwd'); + expect(res.valid).toBe(false); + expect(res.reason).toContain('DISALLOWED_PROTOCOL'); + }); + + it('enforces allowlist when specified', async () => { + const allowList = ['api.example.com']; + const denied = await validateUrlForSsrf('https://evil.com/webhook', { allowList, skipDns: true }); + expect(denied.valid).toBe(false); + expect(denied.reason).toContain('HOST_NOT_IN_ALLOWLIST'); + + const allowed = await validateUrlForSsrf('https://api.example.com/data', { allowList, skipDns: true }); + expect(allowed.valid).toBe(true); + }); + }); + + describe('preventSsrf legacy helper', () => { + it('blocks localhost and private IPs', () => { + expect(preventSsrf('http://localhost:8080')).toBe(false); + expect(preventSsrf('http://10.0.0.1/status')).toBe(false); + expect(preventSsrf('https://stellar.org')).toBe(true); + }); + + it('handles empty or malformed inputs', () => { + expect(preventSsrf('')).toBe(false); + expect(preventSsrf('invalid-url')).toBe(false); + }); + }); + + describe('createSsrfSafeAgent', () => { + it('returns an agent instance with lookup protection', () => { + const agent = createSsrfSafeAgent('https'); + expect(agent).toBeDefined(); + }); + }); +}); diff --git a/src/security/ssrf.ts b/src/security/ssrf.ts index 87914f22..6e9d6396 100644 --- a/src/security/ssrf.ts +++ b/src/security/ssrf.ts @@ -1 +1,148 @@ -export function preventSsrf() {}\n \ No newline at end of file +import * as dns from 'dns'; +import * as http from 'http'; +import * as https from 'https'; +import { isPrivateIP, createSafeAgent } from './dns'; + +export const METADATA_IP = '169.254.169.254'; +export const METADATA_HOSTNAMES = new Set([ + 'instance-data', + 'metadata.google.internal', + 'metadata.platform.internal', + '169.254.169.254', +]); + +export interface SsrfValidationOptions { + allowList?: string[]; + allowedProtocols?: string[]; + allowPrivate?: boolean; + skipDns?: boolean; + dnsLookup?: (hostname: string) => Promise>; +} + +export interface SsrfValidationResult { + valid: boolean; + reason?: string; + resolvedIp?: string; +} + +/** + * Checks if a hostname or IP address targets cloud metadata services or internal loopback. + */ +export function isMetadataOrLoopback(target: string): boolean { + const lower = target.toLowerCase().trim(); + if (METADATA_HOSTNAMES.has(lower)) return true; + if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === '0.0.0.0') { + return true; + } + return false; +} + +/** + * Validates a target URL string against SSRF attack vectors. + * Resolves DNS to ensure the destination does not point to a private, loopback, or metadata address. + */ +export async function validateUrlForSsrf( + urlStr: string, + options: SsrfValidationOptions = {}, +): Promise { + const allowedProtocols = options.allowedProtocols ?? ['http:', 'https:']; + let parsed: URL; + + try { + parsed = new URL(urlStr); + } catch { + return { valid: false, reason: 'INVALID_URL' }; + } + + if (!allowedProtocols.includes(parsed.protocol)) { + return { + valid: false, + reason: `DISALLOWED_PROTOCOL: ${parsed.protocol} is not permitted`, + }; + } + + const hostname = parsed.hostname; + + if (isMetadataOrLoopback(hostname)) { + return { + valid: false, + reason: `RESTRICTED_HOST: ${hostname} is blocked as a metadata or loopback address`, + }; + } + + if (options.allowList && options.allowList.length > 0) { + const isAllowed = options.allowList.some((allowed) => { + const norm = allowed.trim().toLowerCase(); + return norm === hostname.toLowerCase() || hostname.toLowerCase().endsWith(`.${norm}`); + }); + if (!isAllowed) { + return { valid: false, reason: `HOST_NOT_IN_ALLOWLIST: ${hostname}` }; + } + } + + // If private IPs are permitted, skip DNS validation + if (options.allowPrivate) { + return { valid: true }; + } + + // Check if hostname is an immediate literal IP + if (isPrivateIP(hostname)) { + return { + valid: false, + reason: `PRIVATE_IP_ADDRESS: ${hostname}`, + resolvedIp: hostname, + }; + } + + if (options.skipDns) { + return { valid: true }; + } + + // Resolve DNS to verify underlying IP address + try { + const lookupFn = options.dnsLookup ?? ((h: string) => dns.promises.lookup(h, { all: true })); + const addresses = await lookupFn(hostname); + for (const addr of addresses) { + if (isPrivateIP(addr.address)) { + return { + valid: false, + reason: `DNS_RESOLVED_PRIVATE_IP: ${hostname} resolved to ${addr.address}`, + resolvedIp: addr.address, + }; + } + } + return { valid: true, resolvedIp: addresses[0]?.address }; + } catch (err: any) { + return { + valid: false, + reason: `DNS_RESOLUTION_FAILED: ${err.message || String(err)}`, + }; + } +} + +/** + * Legacy SSRF prevention helper maintained for backward compatibility. + */ +export function preventSsrf(url?: string, allowlist?: string[]): boolean { + if (!url || typeof url !== 'string') return false; + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + if (isMetadataOrLoopback(parsed.hostname) || isPrivateIP(parsed.hostname)) { + return false; + } + if (allowlist && allowlist.length > 0) { + return allowlist.includes(parsed.hostname); + } + return true; + } catch { + return false; + } +} + +/** + * Returns an HTTP or HTTPS Agent configured with anti-rebinding DNS validation. + */ +export function createSsrfSafeAgent(protocol: 'http' | 'https'): http.Agent | https.Agent { + return createSafeAgent(protocol); +} \ No newline at end of file diff --git a/tests/fixtures/ssrf/README.md b/tests/fixtures/ssrf/README.md new file mode 100644 index 00000000..9c31abf9 --- /dev/null +++ b/tests/fixtures/ssrf/README.md @@ -0,0 +1,3 @@ +# SSRF Analyzer Fixtures + +Fixtures for verifying Server-Side Request Forgery (SSRF - CWE-918) detection, weak validation patterns, cloud metadata checks, and disabled TLS options. diff --git a/tests/fixtures/ssrf/safe-app/src/safe-client.ts b/tests/fixtures/ssrf/safe-app/src/safe-client.ts new file mode 100644 index 00000000..8ee33cde --- /dev/null +++ b/tests/fixtures/ssrf/safe-app/src/safe-client.ts @@ -0,0 +1,11 @@ +import { validateUrlForSsrf } from "../../../../src/security/ssrf"; + +export async function safeFetch(req: any) { + const target = req.query.url; + await validateUrlForSsrf(target); + return fetch(target); +} + +export async function fetchStaticEndpoint() { + return fetch("https://api.github.com/status"); +} diff --git a/tests/fixtures/ssrf/ssrf.repositories.spec.ts b/tests/fixtures/ssrf/ssrf.repositories.spec.ts new file mode 100644 index 00000000..1934635e --- /dev/null +++ b/tests/fixtures/ssrf/ssrf.repositories.spec.ts @@ -0,0 +1,57 @@ +import * as fs from "fs"; +import * as path from "path"; +import { SsrfAnalyzer } from "../../../libs/engine/analyzers/ssrf"; + +const FIXTURES_DIR = path.resolve(__dirname); + +function loadFixtureFiles(subDir: string): Array<{ path: string; content: string }> { + const dir = path.join(FIXTURES_DIR, subDir); + const results: Array<{ path: string; content: string }> = []; + + const walk = (current: string) => { + for (const item of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, item.name); + if (item.isDirectory()) { + walk(full); + } else { + const rel = path.relative(dir, full).replace(/\\/g, "/"); + results.push({ path: rel, content: fs.readFileSync(full, "utf8") }); + } + } + }; + + walk(dir); + return results; +} + +describe("SSRF repository fixtures", () => { + let analyzer: SsrfAnalyzer; + + beforeEach(() => { + analyzer = new SsrfAnalyzer(); + }); + + it("detects expected vulnerabilities in vulnerable-app", async () => { + const files = loadFixtureFiles("vulnerable-app"); + const fileMap = new Map(files.map((f) => [f.path, f.content])); + + const result = await analyzer.analyzeMultiple(fileMap); + + expect(result.filesAnalyzed).toBeGreaterThanOrEqual(3); + expect(result.findings.length).toBeGreaterThan(0); + + const ruleIds = new Set(result.findings.map((f) => f.ruleId)); + expect(ruleIds.has("ssrf-001")).toBe(true); + expect(ruleIds.has("ssrf-002")).toBe(true); + expect(ruleIds.has("ssrf-003")).toBe(true); + expect(ruleIds.has("ssrf-004")).toBe(true); + }); + + it("produces 0 findings on safe-app", async () => { + const files = loadFixtureFiles("safe-app"); + const fileMap = new Map(files.map((f) => [f.path, f.content])); + + const result = await analyzer.analyzeMultiple(fileMap); + expect(result.findings).toHaveLength(0); + }); +}); diff --git a/tests/fixtures/ssrf/vulnerable-app/service.py b/tests/fixtures/ssrf/vulnerable-app/service.py new file mode 100644 index 00000000..7a2ca200 --- /dev/null +++ b/tests/fixtures/ssrf/vulnerable-app/service.py @@ -0,0 +1,8 @@ +import requests +from flask import request + +def proxy_request(): + target = request.args.get('url') + # Vulnerable request with disabled TLS verification + res = requests.get(target, verify=False) + return res.text diff --git a/tests/fixtures/ssrf/vulnerable-app/src/internal.ts b/tests/fixtures/ssrf/vulnerable-app/src/internal.ts new file mode 100644 index 00000000..a872b330 --- /dev/null +++ b/tests/fixtures/ssrf/vulnerable-app/src/internal.ts @@ -0,0 +1,9 @@ +import https from "https"; + +export async function fetchMetadata() { + const metadataEndpoint = "http://169.254.169.254/latest/meta-data/"; + const agent = new https.Agent({ + rejectUnauthorized: false, + }); + return fetch(metadataEndpoint, { agent } as any); +} diff --git a/tests/fixtures/ssrf/vulnerable-app/src/webhook.ts b/tests/fixtures/ssrf/vulnerable-app/src/webhook.ts new file mode 100644 index 00000000..9c6b4fa8 --- /dev/null +++ b/tests/fixtures/ssrf/vulnerable-app/src/webhook.ts @@ -0,0 +1,18 @@ +import axios from "axios"; + +export async function handleWebhook(req: any, res: any) { + const target = req.query.url; + + // Weak validation check + if (target.startsWith("https://")) { + console.log("Looks like https"); + } + + const response = await fetch(target); + return res.json(await response.json()); +} + +export async function triggerNotification(req: any) { + const webhookUrl = req.body.url; + return axios.post(webhookUrl, { event: "notification" }); +}