From c012ca30e6abd752df5442d63fb4a199af2d8f1d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 18:42:54 -0400 Subject: [PATCH] feat: add PowerShell parameter detection engine Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/scriptParameterDetector.ts | 280 ++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 frontend/src/lib/scriptParameterDetector.ts diff --git a/frontend/src/lib/scriptParameterDetector.ts b/frontend/src/lib/scriptParameterDetector.ts new file mode 100644 index 00000000..5043a4fb --- /dev/null +++ b/frontend/src/lib/scriptParameterDetector.ts @@ -0,0 +1,280 @@ +import type { ScriptParameter, ParameterCandidate } from '@/types' + +/** + * PowerShell variable names to skip — these are PS internals, not user inputs. + */ +const SKIP_VARIABLES = new Set([ + '$ErrorActionPreference', + '$WarningPreference', + '$VerbosePreference', + '$DebugPreference', + '$InformationPreference', + '$ConfirmPreference', + '$ProgressPreference', + '$PSDefaultParameterValues', + '$PSModuleAutoLoadingPreference', + '$OFS', + '$FormatEnumerationLimit', + '$MaximumHistoryCount', + '$_', + '$PSItem', + '$args', + '$input', + '$this', + '$null', + '$true', + '$false', +]) + +/** + * Sensitive variable name patterns — if the variable name contains any of these, + * suggest password type and mark sensitive. + */ +const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i + +/** + * Convert a PowerShell variable name to a snake_case key. + * "$OUPath" → "ou_path", "$ServerName" → "server_name" + */ +function toSnakeCase(varName: string): string { + const name = varName.replace(/^\$/, '') + return name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase() +} + +/** + * Convert a snake_case key to a human-readable label. + * "ou_path" → "OU Path", "server_name" → "Server Name" + */ +function toLabel(key: string): string { + return key + .split('_') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +/** + * Infer the ScriptParameter type from a PowerShell type annotation and/or value. + */ +function inferType( + typeAnnotation: string | null, + value: string | null, + varName: string +): { type: ScriptParameter['type']; sensitive: boolean; reason: string } { + if (typeAnnotation) { + const t = typeAnnotation.toLowerCase() + if (t === 'switch') { + return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' } + } + if (t === 'securestring') { + return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' } + } + if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') { + return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } + } + if (t === 'bool' || t === 'boolean') { + return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } + } + } + + if (SENSITIVE_PATTERNS.test(varName)) { + return { type: 'password', sensitive: true, reason: 'Variable name suggests sensitive data — marked as sensitive' } + } + + if (value !== null) { + const trimmed = value.trim() + if (trimmed === '$true' || trimmed === '$false') { + return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' } + } + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return { type: 'number', sensitive: false, reason: 'Detected numeric value' } + } + } + + const reason = typeAnnotation + ? `Detected [${typeAnnotation}] type declaration` + : 'Defaulting to text (no type annotation detected)' + return { type: 'text', sensitive: false, reason } +} + +/** + * Parse the default value into the correct JS type. + */ +function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null { + if (value === null) return null + const trimmed = value.trim() + + if (type === 'boolean') { + if (trimmed === '$true') return true + if (trimmed === '$false') return false + return null + } + if (type === 'number') { + const n = Number(trimmed) + return isNaN(n) ? null : n + } + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"'))) { + return trimmed.slice(1, -1) + } + return trimmed +} + +/** + * Find the script-level param() block (not inside any function). + */ +function findScriptLevelParamBlock(script: string): { start: number; end: number } | null { + const lines = script.split('\n') + let inFunction = false + let paramStart = -1 + let parenDepth = 0 + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + + if (/^function\s+/i.test(trimmed)) { + inFunction = true + continue + } + + if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) { + paramStart = i + for (let j = i; j < lines.length; j++) { + for (const ch of lines[j]) { + if (ch === '(') parenDepth++ + if (ch === ')') parenDepth-- + if (parenDepth === 0 && paramStart !== -1) { + return { start: paramStart, end: j } + } + } + } + } + + if (inFunction && trimmed === '}') { + inFunction = false + } + } + + return null +} + +/** + * Extract parameter candidates from a script-level param() block. + */ +function extractParamBlockCandidates( + script: string, + block: { start: number; end: number } +): ParameterCandidate[] { + const lines = script.split('\n') + const blockText = lines.slice(block.start, block.end + 1).join('\n') + const candidates: ParameterCandidate[] = [] + + const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm + let match: RegExpExecArray | null + + while ((match = paramRegex.exec(blockText)) !== null) { + const typeAnnotation = match[1] || null + const varName = match[2] + const rawDefault = match[3]?.trim() ?? null + + if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue + + const key = toSnakeCase(varName) + const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName) + const defaultValue = parseDefault(rawDefault, type) + + const lineIndex = lines.findIndex((line, idx) => + idx >= block.start && idx <= block.end && line.includes(`$${varName}`) + ) + + candidates.push({ + variableName: `$${varName}`, + suggestedKey: key, + suggestedLabel: toLabel(key), + suggestedType: type, + sensitive, + defaultValue, + source: 'param_block', + lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1, + matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`, + inferenceReason: reason, + }) + } + + return candidates +} + +/** + * Extract parameter candidates from variable assignments ($Var = 'value'). + */ +function extractAssignmentCandidates( + script: string, + existingVarNames: Set +): ParameterCandidate[] { + const lines = script.split('\n') + const candidates: ParameterCandidate[] = [] + const seenVars = new Set() + + const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/ + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(assignRegex) + if (!match) continue + + const fullVar = match[1] + const rawValue = match[2].trim() + + if (SKIP_VARIABLES.has(fullVar)) continue + if (existingVarNames.has(fullVar)) continue + if (seenVars.has(fullVar)) continue + + if (!/^['"].*['"]$/.test(rawValue) && + !/^-?\d+(\.\d+)?$/.test(rawValue) && + !/^\$(true|false)$/i.test(rawValue)) { + continue + } + + if (/\{\{.*?\}\}/.test(rawValue)) continue + + seenVars.add(fullVar) + + const varName = fullVar.replace(/^\$/, '') + const key = toSnakeCase(varName) + const { type, sensitive, reason } = inferType(null, rawValue, varName) + const defaultValue = parseDefault(rawValue, type) + + candidates.push({ + variableName: fullVar, + suggestedKey: key, + suggestedLabel: toLabel(key), + suggestedType: type, + sensitive, + defaultValue, + source: 'assignment', + lineNumber: i + 1, + matchedLine: lines[i].trim(), + inferenceReason: reason, + }) + } + + return candidates +} + +/** + * Detect parameter candidates in a PowerShell script body. + */ +export function detectParameterCandidates(script: string): ParameterCandidate[] { + if (!script.trim()) return [] + + const paramBlock = findScriptLevelParamBlock(script) + const paramCandidates = paramBlock + ? extractParamBlockCandidates(script, paramBlock) + : [] + + const paramVarNames = new Set(paramCandidates.map(c => c.variableName)) + const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames) + + return [...paramCandidates, ...assignmentCandidates] +}