From b7567aedf55e0e26231363978d6092c067750fd0 Mon Sep 17 00:00:00 2001 From: waleedcodes <110061477+waleedcodes@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:20:08 +0500 Subject: [PATCH 1/3] feat(risk): scaffold RiskEngine with AST visitor and evidence trace builder --- src/rules/riskEngine.ts | 145 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/rules/riskEngine.ts diff --git a/src/rules/riskEngine.ts b/src/rules/riskEngine.ts new file mode 100644 index 0000000..044eaae --- /dev/null +++ b/src/rules/riskEngine.ts @@ -0,0 +1,145 @@ +import * as fs from 'node:fs'; +import * as ts from 'typescript'; +import { EvidenceTraceStep, FileDiff, RiskFinding, RiskSeverity } from '../types'; + +export class RiskEngine { + /** + * Runs deterministic static risk detection on changed files and their diffs. + */ + public static analyzeFile( + filePath: string, + content?: string, + fileDiff?: FileDiff + ): RiskFinding[] { + if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) { + return []; + } + + const sourceText = content ?? (fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''); + if (!sourceText) return []; + + const isJsx = filePath.endsWith('.tsx') || filePath.endsWith('.jsx'); + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + isJsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + + const findings: RiskFinding[] = []; + const changedLinesSet = fileDiff ? new Set(fileDiff.addedLines) : null; + + const getLineAndColumn = (pos: number) => { + const lc = sourceFile.getLineAndCharacterOfPosition(pos); + return { line: lc.line + 1, column: lc.character + 1 }; + }; + + const getSnippet = (line: number): string => { + const lines = sourceText.split('\n'); + const start = Math.max(0, line - 2); + const end = Math.min(lines.length, line + 1); + return lines.slice(start, end).join('\n'); + }; + + const isLineChanged = (line: number): boolean => { + if (!changedLinesSet) return true; + return changedLinesSet.has(line); + }; + + const isTest = this.isTestFile(filePath); + + // 1. Text & Regex analysis for Hardcoded Secrets (skip test fixtures) + if (!isTest) { + findings.push(...this.checkHardcodedSecrets(filePath, sourceText, isLineChanged, getSnippet)); + } + + // 2. AST Traversals for semantic rules + const isApiRoute = + /[\\/]app[\\/].*[\\/]route\.(?:ts|js|tsx|jsx)$/.test(filePath) || + /[\\/]pages[\\/]api[\\/].*\.(?:ts|js|tsx|jsx)$/.test(filePath) || + filePath.includes('api/') || + filePath.includes('routes/'); + + const visit = (node: ts.Node) => { + // Rule: SQL Injection Detection + if (ts.isCallExpression(node)) { + const text = node.expression.getText(sourceFile); + const isDbQuery = + /(\bquery|\$queryRawUnsafe|\bexecute|\braw|\bexecSql)$/i.test(text) || + /\bdb\..*query/i.test(text); + + if (isDbQuery && node.arguments.length > 0) { + const firstArg = node.arguments[0]; + const { line, column } = getLineAndColumn(firstArg.getStart(sourceFile)); + + if (isLineChanged(line)) { + // Check template literal with expressions: `SELECT * FROM users WHERE id = ${id}` + if (ts.isTemplateExpression(firstArg) && firstArg.templateSpans.length > 0) { + const snippet = getSnippet(line); + findings.push({ + id: `PC-SQL-${filePath}-${line}`, + ruleId: 'PC-SEC-001', + category: 'security', + title: 'Potential SQL Injection (Template Literal)', + severity: 'HIGH', + description: + 'Database query uses unescaped string interpolation with dynamic variables, exposing the endpoint to SQL injection.', + file: filePath, + line, + column, + snippet, + evidenceTrace: [ + { + file: filePath, + line, + description: `Call to ${text} constructed with template literal expression: ${firstArg.getText(sourceFile)}` + }, + { + file: filePath, + line, + description: 'No parameterized query or escaping mechanism detected' + } + ], + recommendation: + 'Use parameterized queries (e.g. db.query("SELECT ... WHERE id = $1", [id])) or an ORM prepared statement.' + }); + } else if ( + ts.isBinaryExpression(firstArg) && + firstArg.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + // String concatenation: "SELECT * FROM users WHERE id = " + id + const snippet = getSnippet(line); + findings.push({ + id: `PC-SQL-${filePath}-${line}`, + ruleId: 'PC-SEC-001', + category: 'security', + title: 'Potential SQL Injection (String Concatenation)', + severity: 'HIGH', + description: + 'Database query constructs SQL via direct string concatenation, creating a severe injection vulnerability.', + file: filePath, + line, + column, + snippet, + evidenceTrace: [ + { + file: filePath, + line, + description: `Call to ${text} using string concatenation: ${firstArg.getText(sourceFile)}` + } + ], + recommendation: + 'Avoid string concatenation in SQL queries. Use prepared statements or parameterized queries.' + }); + } + } + } + } + + // Rule: Dangerous eval / Function / child_process.exec + if (ts.isCallExpression(node)) { + const text = node.expression.getText(sourceFile); + if (text === 'eval' || text === 'new Function' || text.endsWith('.exec') || text.endsWith('.execSync')) { + const { line, column } = getLineAndColumn(node.getStart(sourceFile)); + if (isLineChanged(line) && node.arguments.length > 0) { From 1d50bd9155962829105c834d44fde2669027afa5 Mon Sep 17 00:00:00 2001 From: waleedcodes <110061477+waleedcodes@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:20:08 +0500 Subject: [PATCH 2/3] feat(risk): implement SQL injection and missing authorization checks --- src/rules/riskEngine.ts | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/rules/riskEngine.ts b/src/rules/riskEngine.ts index 044eaae..73aa8d1 100644 --- a/src/rules/riskEngine.ts +++ b/src/rules/riskEngine.ts @@ -143,3 +143,149 @@ export class RiskEngine { if (text === 'eval' || text === 'new Function' || text.endsWith('.exec') || text.endsWith('.execSync')) { const { line, column } = getLineAndColumn(node.getStart(sourceFile)); if (isLineChanged(line) && node.arguments.length > 0) { + const firstArg = node.arguments[0]; + // If argument is not a simple string literal, it's dynamic + if (!ts.isStringLiteral(firstArg)) { + findings.push({ + id: `PC-EVAL-${filePath}-${line}`, + ruleId: 'PC-SEC-004', + category: 'security', + title: 'Dangerous Code/Command Execution', + severity: 'HIGH', + description: `Invoking ${text} with dynamic arguments can lead to arbitrary code or command execution.`, + file: filePath, + line, + column, + snippet: getSnippet(line), + evidenceTrace: [ + { + file: filePath, + line, + description: `Invocation of ${text}(${firstArg.getText(sourceFile)})` + } + ], + recommendation: + 'Avoid eval() or dynamic shell execution. Use safe alternative APIs or execFile with static arguments.' + }); + } + } + } + } + + // Rule: Unsafe User Input SSRF / Open Redirect + if (ts.isCallExpression(node)) { + const text = node.expression.getText(sourceFile); + if ( + text === 'fetch' || + text === 'axios' || + text.startsWith('axios.') || + text.endsWith('res.redirect') || + text.endsWith('redirect') + ) { + if (node.arguments.length > 0) { + const argText = node.arguments[0].getText(sourceFile); + const { line, column } = getLineAndColumn(node.getStart(sourceFile)); + if ( + isLineChanged(line) && + (argText.includes('req.body') || + argText.includes('req.query') || + argText.includes('req.params') || + argText.includes('params.')) + ) { + findings.push({ + id: `PC-SSRF-${filePath}-${line}`, + ruleId: 'PC-SEC-005', + category: 'security', + title: 'Unsafe User-Controlled URL / Potential SSRF', + severity: 'MEDIUM', + description: + 'User request parameter is passed directly into a network request or redirect without explicit validation or allowlisting.', + file: filePath, + line, + column, + snippet: getSnippet(line), + evidenceTrace: [ + { + file: filePath, + line, + description: `Call to ${text}(${argText}) with user-controlled input` + } + ], + recommendation: + 'Validate target URLs against an explicit allowlist and enforce URL protocol and host checks.' + }); + } + } + } + } + + // Rule: Missing Error Handling on Async Calls + if (ts.isAwaitExpression(node) && !isTest) { + const { line, column } = getLineAndColumn(node.getStart(sourceFile)); + if (isLineChanged(line)) { + // Check if this await is inside a try-catch block + let parent: ts.Node | undefined = node.parent; + let insideTry = false; + while (parent) { + if (ts.isTryStatement(parent)) { + insideTry = true; + break; + } + if ( + ts.isFunctionDeclaration(parent) || + ts.isArrowFunction(parent) || + ts.isMethodDeclaration(parent) + ) { + break; + } + parent = parent.parent; + } + + if (!insideTry) { + // Also check if expression has .catch + const exprText = node.expression.getText(sourceFile); + if (!exprText.includes('.catch(')) { + findings.push({ + id: `PC-ERR-${filePath}-${line}`, + ruleId: 'PC-REL-006', + category: 'reliability', + title: 'Unhandled Asynchronous Operation', + severity: 'LOW', + description: + 'Async operation is awaited without an enclosing try/catch block or promise catch handler.', + file: filePath, + line, + column, + snippet: getSnippet(line), + evidenceTrace: [ + { + file: filePath, + line, + description: `await ${exprText} executed without try/catch wrapper` + } + ], + recommendation: + 'Wrap async database and network calls in try/catch to handle potential failures gracefully.' + }); + } + } + } + } + + // Rule: Unhandled Null/Undefined Database Result + if (ts.isVariableDeclaration(node) && !isTest) { + this.checkUnhandledNullDbResult(node, sourceFile, filePath, isLineChanged, getSnippet, findings); + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + + // Rule: Missing Authorization in API routes performing DB queries + if (isApiRoute) { + const authFindings = this.checkApiRouteAuthorization( + filePath, + sourceFile, + sourceText, + isLineChanged, From 19f153d4b3399813d6307d81a936c7d58aa77e5a Mon Sep 17 00:00:00 2001 From: waleedcodes <110061477+waleedcodes@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:20:08 +0500 Subject: [PATCH 3/3] feat(risk): implement hardcoded secret scanner and SSRF detector --- src/rules/riskEngine.ts | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/rules/riskEngine.ts b/src/rules/riskEngine.ts index 73aa8d1..c8f3ee6 100644 --- a/src/rules/riskEngine.ts +++ b/src/rules/riskEngine.ts @@ -289,3 +289,76 @@ export class RiskEngine { sourceFile, sourceText, isLineChanged, + getSnippet + ); + findings.push(...authFindings); + } + + // Deduplicate findings by ID + const uniqueMap = new Map(); + for (const f of findings) { + uniqueMap.set(f.id, f); + } + + return Array.from(uniqueMap.values()); + } + + private static checkHardcodedSecrets( + filePath: string, + sourceText: string, + isLineChanged: (line: number) => boolean, + getSnippet: (line: number) => string + ): RiskFinding[] { + const findings: RiskFinding[] = []; + const lines = sourceText.split('\n'); + + const patterns = [ + { + regex: /(?:AKIA[0-9A-Z]{16})/, + name: 'AWS Access Key ID', + ruleId: 'PC-SEC-002', + severity: 'HIGH' as RiskSeverity + }, + { + regex: /-----BEGIN(?:[A-Z\s]+)?PRIVATE KEY-----/, + name: 'Private Encryption Key', + ruleId: 'PC-SEC-002', + severity: 'HIGH' as RiskSeverity + }, + { + regex: /(?:ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{50,})/, + name: 'GitHub Personal Access Token', + ruleId: 'PC-SEC-002', + severity: 'HIGH' as RiskSeverity + }, + { + regex: /(?:sk-[a-zA-Z0-9]{32,})/, + name: 'OpenAI API Secret Key', + ruleId: 'PC-SEC-002', + severity: 'HIGH' as RiskSeverity + }, + { + regex: /(?:password|apiKey|api_key|client_secret|private_key)\s*[:=]\s*["']([A-Za-z0-9+/=_\-!@#$%^&*]{16,})["']/i, + name: 'Hardcoded Secret Credential', + ruleId: 'PC-SEC-002', + severity: 'HIGH' as RiskSeverity + } + ]; + + lines.forEach((lineText, idx) => { + const lineNum = idx + 1; + if (!isLineChanged(lineNum)) return; + + // Skip process.env or commented-out lines + if (lineText.includes('process.env.') || /^\s*\/\//.test(lineText)) return; + + for (const pattern of patterns) { + if (pattern.regex.test(lineText)) { + findings.push({ + id: `PC-SECRET-${filePath}-${lineNum}`, + ruleId: pattern.ruleId, + category: 'security', + title: `Hardcoded Secret Detected (${pattern.name})`, + severity: pattern.severity, + description: `Potential hardcoded secret or credential found in source code: ${pattern.name}.`, + file: filePath,