From 6808409c5d723c09ae925a149d5e6fb1500ed422 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:10:53 -0500 Subject: [PATCH] feat(drift): scan template literal bodies for anti-patterns (#102) Extracts backtick template strings from .ts/.tsx/.js/.mjs files and scans their bodies against drift patterns, attributing violations to virtual filenames (e.g. src/foo.ts[template:0]). Catches security anti-patterns inside code-factory functions that emit string templates. No signature changes to scanForDrift. Co-Authored-By: Claude Sonnet 4.6 --- .../src/__tests__/templateLiterals.test.ts | 156 ++++++++++++++++++ packages/drift/src/index.ts | 59 +++++++ 2 files changed, 215 insertions(+) create mode 100644 packages/drift/src/__tests__/templateLiterals.test.ts diff --git a/packages/drift/src/__tests__/templateLiterals.test.ts b/packages/drift/src/__tests__/templateLiterals.test.ts new file mode 100644 index 0000000..dc80850 --- /dev/null +++ b/packages/drift/src/__tests__/templateLiterals.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { extractTemplateLiterals, scanForDrift } from '../index'; +import type { Pattern } from '@stackbilt/types'; + +describe('extractTemplateLiterals', () => { + it('returns empty map for non-TS/JS files', () => { + const result = extractTemplateLiterals('const x = `hello world from template`;', 'config.yaml'); + expect(result).toEqual({}); + }); + + it('returns empty map for .txt files', () => { + const result = extractTemplateLiterals('`some long template content here`', 'readme.txt'); + expect(result).toEqual({}); + }); + + it('returns empty map when file has no template literals', () => { + const result = extractTemplateLiterals('const x = "hello"; const y = 42;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('skips template literals with 20 chars or fewer', () => { + // 15 chars — should be skipped + const result = extractTemplateLiterals('const x = `short15chars`;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('includes template literals longer than 20 chars', () => { + // 100+ chars — should be included + const body = 'this is a sufficiently long template literal body that exceeds the threshold'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/util.ts'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.values(result)[0]).toBe(body); + }); + + it('uses virtual filename format filename[template:N]', () => { + const body = 'this is a sufficiently long template literal body content'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/templates/hmac.ts'); + expect(Object.keys(result)[0]).toBe('src/templates/hmac.ts[template:0]'); + }); + + it('indexes multiple template literals sequentially', () => { + const body1 = 'first template literal body that is long enough to count'; + const body2 = 'second template literal body that is also long enough to count'; + const content = `const a = \`${body1}\`;\nconst b = \`${body2}\`;`; + const result = extractTemplateLiterals(content, 'src/util.ts'); + expect(Object.keys(result)).toContain('src/util.ts[template:0]'); + expect(Object.keys(result)).toContain('src/util.ts[template:1]'); + expect(result['src/util.ts[template:0]']).toBe(body1); + expect(result['src/util.ts[template:1]']).toBe(body2); + }); + + it('works for .js files', () => { + const body = 'function body that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`module.exports = \`${body}\`;`, 'lib/factory.js'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.js[template:0]'); + }); + + it('works for .mjs files', () => { + const body = 'export default template that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`export default \`${body}\`;`, 'lib/factory.mjs'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.mjs[template:0]'); + }); + + it('works for .tsx files', () => { + const body = 'react template literal body that is long enough to be extracted'; + const result = extractTemplateLiterals(`const style = \`${body}\`;`, 'src/Component.tsx'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('src/Component.tsx[template:0]'); + }); +}); + +describe('scanForDrift with template literal extraction', () => { + const makePattern = (name: string, antiPatterns: string | null): Pattern => ({ + id: '1', + name, + category: 'SECURITY', + blessedSolution: 'Use constant-time comparison', + rationale: null, + antiPatterns, + documentationUrl: null, + relatedLedgerId: null, + status: 'ACTIVE', + createdAt: '2025-01-01', + projectId: null, + }); + + it('detects timing attack inside a template literal (code-factory pattern)', () => { + // A function that returns a multi-line backtick string containing the vulnerable code + const content = [ + 'export function hmacVerifyFunction(): string {', + ' return `', + 'function verify(a, b) {', + ' return a === b;', + '}', + '`;', + '}', + ].join('\n'); + + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'src/templates/hmac.ts': content }; + const report = scanForDrift(files, [pattern]); + + // Should find a violation attributed to the virtual template filename + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toMatch(/^src\/templates\/hmac\.ts\[template:\d+\]$/); + expect(templateViolations[0].snippet).toContain('a === b'); + }); + + it('attributes template violations to virtual filename, not original file', () => { + const content = 'export const tmpl = `\nreturn inputA === inputB;\n`;'; + const pattern = makePattern('no-direct-compare', 'Avoid /inputA === inputB/'); + const files = { 'src/codegen/auth.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.startsWith('src/codegen/auth.ts[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toBe('src/codegen/auth.ts[template:0]'); + }); + + it('does not template-scan non-TS/JS files', () => { + // YAML file containing backtick-like content should not be template-scanned + const content = 'pattern: |\n return a === b;\n'; + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'config.yaml': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations).toHaveLength(0); + }); + + it('line numbers in template violations are 1-indexed relative to template body', () => { + // Template body: line 1 is "// begin generated code" (padding to exceed 20 chars), + // line 2 has the vulnerable pattern + const content = 'const gen = `\n// begin generated code\nreturn x === y;\n`;'; + const pattern = makePattern('no-direct-eq', 'Avoid /x === y/'); + const files = { 'src/gen.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + // line 1 is "// begin generated code" + // line 2 is "return x === y;" + expect(templateViolations[0].line).toBe(3); + }); +}); diff --git a/packages/drift/src/index.ts b/packages/drift/src/index.ts index 0c8faa2..c2de6f3 100644 --- a/packages/drift/src/index.ts +++ b/packages/drift/src/index.ts @@ -57,6 +57,32 @@ export function scanForDrift( } } } + + // Also scan extracted template literals as virtual files + const templateVirtualFiles = extractTemplateLiterals(content, filename); + for (const [vFilename, vContent] of Object.entries(templateVirtualFiles)) { + for (const pattern of patterns) { + if (pattern.antiPatterns) { + const rules = extractRules(pattern.antiPatterns); + + for (const rule of rules) { + const lines = vContent.split('\n'); + lines.forEach((line, index) => { + if (rule.test(line)) { + violations.push({ + file: vFilename, + line: index + 1, + snippet: line.trim().substring(0, 100), + patternName: pattern.name, + antiPattern: pattern.antiPatterns!, + severity: 'MAJOR' + }); + } + }); + } + } + } + } } const score = Math.max(0, 1.0 - (violations.length * 0.1)); @@ -70,6 +96,39 @@ export function scanForDrift( }; } +/** + * Extract template literal bodies from a source file. + * + * For .ts, .tsx, .js, and .mjs files, finds all backtick template literal + * bodies and returns them as a map of virtual filenames to extracted content. + * Virtual filenames have the format: `filename[template:N]` + * + * Handles nested backticks conservatively via non-greedy matching. + * Short template literals (≤20 chars) are skipped as trivially uninteresting. + * + * @param content - File content to extract from + * @param filename - Filename used to determine extension and virtual names + * @returns Map of virtual filename → extracted template body + */ +export function extractTemplateLiterals(content: string, filename: string): Record { + const supportedExtensions = ['.ts', '.tsx', '.js', '.mjs']; + const isSupported = supportedExtensions.some(ext => filename.endsWith(ext)); + if (!isSupported) return {}; + + const result: Record = {}; + // Match outermost backtick strings — simplified: find `...` blocks + const re = /`([\s\S]*?)`/g; + let match: RegExpExecArray | null; + let i = 0; + while ((match = re.exec(content)) !== null) { + const body = match[1]; + if (body.length > 20) { // skip trivially short template strings + result[`${filename}[template:${i++}]`] = body; + } + } + return result; +} + // ============================================================================ // Rule Extraction // ============================================================================