mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
- Remove unused SEVERITY_LEVELS import from AnalyzeProject.ts - Prefix unused fileName variable with underscore in HardcodeDetector.ts - Replace || with ?? for nullish coalescing
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
|
|
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
|
|
import { ALLOWED_NUMBERS, CODE_PATTERNS, DETECTION_KEYWORDS } from "../constants/defaults"
|
|
import { HARDCODE_TYPES } from "../../shared/constants"
|
|
|
|
/**
|
|
* Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code
|
|
*
|
|
* This detector identifies configuration values, URLs, timeouts, ports, and other
|
|
* constants that should be extracted to configuration files. It uses pattern matching
|
|
* and context analysis to reduce false positives.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const detector = new HardcodeDetector()
|
|
* const code = `
|
|
* const timeout = 5000
|
|
* const url = "http://localhost:8080"
|
|
* `
|
|
* const violations = detector.detectAll(code, 'config.ts')
|
|
* // Returns array of HardcodedValue objects
|
|
* ```
|
|
*/
|
|
export class HardcodeDetector implements IHardcodeDetector {
|
|
private readonly ALLOWED_NUMBERS = ALLOWED_NUMBERS
|
|
|
|
private readonly ALLOWED_STRING_PATTERNS = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/]
|
|
|
|
/**
|
|
* Detects all hardcoded values (both numbers and strings) in the given code
|
|
*
|
|
* @param code - Source code to analyze
|
|
* @param filePath - File path for context (used in violation reports)
|
|
* @returns Array of detected hardcoded values with suggestions
|
|
*/
|
|
public detectAll(code: string, filePath: string): HardcodedValue[] {
|
|
if (this.isConstantsFile(filePath)) {
|
|
return []
|
|
}
|
|
const magicNumbers = this.detectMagicNumbers(code, filePath)
|
|
const magicStrings = this.detectMagicStrings(code, filePath)
|
|
return [...magicNumbers, ...magicStrings]
|
|
}
|
|
|
|
/**
|
|
* Check if a file is a constants definition file
|
|
*/
|
|
private isConstantsFile(filePath: string): boolean {
|
|
const _fileName = filePath.split("/").pop() ?? ""
|
|
const constantsPatterns = [
|
|
/^constants?\.(ts|js)$/i,
|
|
/constants?\/.*\.(ts|js)$/i,
|
|
/\/(constants|config|settings|defaults)\.ts$/i,
|
|
]
|
|
return constantsPatterns.some((pattern) => pattern.test(filePath))
|
|
}
|
|
|
|
/**
|
|
* Check if a line is inside an exported constant definition
|
|
*/
|
|
private isInExportedConstant(lines: string[], lineIndex: number): boolean {
|
|
const currentLineTrimmed = lines[lineIndex].trim()
|
|
|
|
if (this.isSingleLineExportConst(currentLineTrimmed)) {
|
|
return true
|
|
}
|
|
|
|
const exportConstStart = this.findExportConstStart(lines, lineIndex)
|
|
if (exportConstStart === -1) {
|
|
return false
|
|
}
|
|
|
|
const { braces, brackets } = this.countUnclosedBraces(lines, exportConstStart, lineIndex)
|
|
return braces > 0 || brackets > 0
|
|
}
|
|
|
|
/**
|
|
* Check if a line is a single-line export const declaration
|
|
*/
|
|
private isSingleLineExportConst(line: string): boolean {
|
|
if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) {
|
|
return false
|
|
}
|
|
|
|
const hasObjectOrArray =
|
|
line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START)
|
|
|
|
if (hasObjectOrArray) {
|
|
const hasAsConstEnding =
|
|
line.includes(CODE_PATTERNS.AS_CONST_OBJECT) ||
|
|
line.includes(CODE_PATTERNS.AS_CONST_ARRAY) ||
|
|
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_OBJECT) ||
|
|
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_ARRAY)
|
|
|
|
return hasAsConstEnding
|
|
}
|
|
|
|
return line.includes(CODE_PATTERNS.AS_CONST)
|
|
}
|
|
|
|
/**
|
|
* Find the starting line of an export const declaration
|
|
*/
|
|
private findExportConstStart(lines: string[], lineIndex: number): number {
|
|
for (let currentLine = lineIndex; currentLine >= 0; currentLine--) {
|
|
const trimmed = lines[currentLine].trim()
|
|
|
|
const isExportConst =
|
|
trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) &&
|
|
(trimmed.includes(CODE_PATTERNS.OBJECT_START) ||
|
|
trimmed.includes(CODE_PATTERNS.ARRAY_START))
|
|
|
|
if (isExportConst) {
|
|
return currentLine
|
|
}
|
|
|
|
const isTopLevelStatement =
|
|
currentLine < lineIndex &&
|
|
(trimmed.startsWith(CODE_PATTERNS.EXPORT) ||
|
|
trimmed.startsWith(CODE_PATTERNS.IMPORT))
|
|
|
|
if (isTopLevelStatement) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
/**
|
|
* Count unclosed braces and brackets between two line indices
|
|
*/
|
|
private countUnclosedBraces(
|
|
lines: string[],
|
|
startLine: number,
|
|
endLine: number,
|
|
): { braces: number; brackets: number } {
|
|
let braces = 0
|
|
let brackets = 0
|
|
|
|
for (let i = startLine; i <= endLine; i++) {
|
|
const line = lines[i]
|
|
let inString = false
|
|
let stringChar = ""
|
|
|
|
for (let j = 0; j < line.length; j++) {
|
|
const char = line[j]
|
|
const prevChar = j > 0 ? line[j - 1] : ""
|
|
|
|
if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") {
|
|
if (!inString) {
|
|
inString = true
|
|
stringChar = char
|
|
} else if (char === stringChar) {
|
|
inString = false
|
|
stringChar = ""
|
|
}
|
|
}
|
|
|
|
if (!inString) {
|
|
if (char === "{") {
|
|
braces++
|
|
} else if (char === "}") {
|
|
braces--
|
|
} else if (char === "[") {
|
|
brackets++
|
|
} else if (char === "]") {
|
|
brackets--
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { braces, brackets }
|
|
}
|
|
|
|
/**
|
|
* Detects magic numbers in code (timeouts, ports, limits, retries, etc.)
|
|
*
|
|
* Skips allowed numbers (-1, 0, 1, 2, 10, 100, 1000) and values in exported constants
|
|
*
|
|
* @param code - Source code to analyze
|
|
* @param _filePath - File path (currently unused, reserved for future use)
|
|
* @returns Array of detected magic numbers
|
|
*/
|
|
public detectMagicNumbers(code: string, _filePath: string): HardcodedValue[] {
|
|
const results: HardcodedValue[] = []
|
|
const lines = code.split("\n")
|
|
|
|
const numberPatterns = [
|
|
/(?:setTimeout|setInterval)\s*\(\s*[^,]+,\s*(\d+)/g,
|
|
/(?:maxRetries|retries|attempts)\s*[=:]\s*(\d+)/gi,
|
|
/(?:limit|max|min)\s*[=:]\s*(\d+)/gi,
|
|
/(?:port|PORT)\s*[=:]\s*(\d+)/g,
|
|
/(?:delay|timeout|TIMEOUT)\s*[=:]\s*(\d+)/gi,
|
|
]
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
if (line.trim().startsWith("//") || line.trim().startsWith("*")) {
|
|
return
|
|
}
|
|
|
|
// Skip lines inside exported constants
|
|
if (this.isInExportedConstant(lines, lineIndex)) {
|
|
return
|
|
}
|
|
|
|
numberPatterns.forEach((pattern) => {
|
|
let match
|
|
const regex = new RegExp(pattern)
|
|
|
|
while ((match = regex.exec(line)) !== null) {
|
|
const value = parseInt(match[1], 10)
|
|
|
|
if (!this.ALLOWED_NUMBERS.has(value)) {
|
|
results.push(
|
|
HardcodedValue.create(
|
|
value,
|
|
HARDCODE_TYPES.MAGIC_NUMBER,
|
|
lineIndex + 1,
|
|
match.index,
|
|
line.trim(),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
const genericNumberRegex = /\b(\d{3,})\b/g
|
|
let match
|
|
|
|
while ((match = genericNumberRegex.exec(line)) !== null) {
|
|
const value = parseInt(match[1], 10)
|
|
|
|
if (
|
|
!this.ALLOWED_NUMBERS.has(value) &&
|
|
!this.isInComment(line, match.index) &&
|
|
!this.isInString(line, match.index)
|
|
) {
|
|
const context = this.extractContext(line, match.index)
|
|
if (this.looksLikeMagicNumber(context)) {
|
|
results.push(
|
|
HardcodedValue.create(
|
|
value,
|
|
HARDCODE_TYPES.MAGIC_NUMBER,
|
|
lineIndex + 1,
|
|
match.index,
|
|
line.trim(),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Detects magic strings in code (URLs, connection strings, error messages, etc.)
|
|
*
|
|
* Skips short strings (≤3 chars), console logs, test descriptions, imports,
|
|
* and values in exported constants
|
|
*
|
|
* @param code - Source code to analyze
|
|
* @param _filePath - File path (currently unused, reserved for future use)
|
|
* @returns Array of detected magic strings
|
|
*/
|
|
public detectMagicStrings(code: string, _filePath: string): HardcodedValue[] {
|
|
const results: HardcodedValue[] = []
|
|
const lines = code.split("\n")
|
|
|
|
const stringRegex = /(['"`])(?:(?!\1).)+\1/g
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
if (
|
|
line.trim().startsWith("//") ||
|
|
line.trim().startsWith("*") ||
|
|
line.includes("import ") ||
|
|
line.includes("from ")
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Skip lines inside exported constants
|
|
if (this.isInExportedConstant(lines, lineIndex)) {
|
|
return
|
|
}
|
|
|
|
let match
|
|
const regex = new RegExp(stringRegex)
|
|
|
|
while ((match = regex.exec(line)) !== null) {
|
|
const fullMatch = match[0]
|
|
const value = fullMatch.slice(1, -1)
|
|
|
|
// Skip template literals (backtick strings with ${} interpolation)
|
|
if (fullMatch.startsWith("`") || value.includes("${")) {
|
|
continue
|
|
}
|
|
|
|
if (!this.isAllowedString(value) && this.looksLikeMagicString(line, value)) {
|
|
results.push(
|
|
HardcodedValue.create(
|
|
value,
|
|
HARDCODE_TYPES.MAGIC_STRING,
|
|
lineIndex + 1,
|
|
match.index,
|
|
line.trim(),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
return results
|
|
}
|
|
|
|
private isAllowedString(str: string): boolean {
|
|
if (str.length <= 1) {
|
|
return true
|
|
}
|
|
|
|
return this.ALLOWED_STRING_PATTERNS.some((pattern) => pattern.test(str))
|
|
}
|
|
|
|
private looksLikeMagicString(line: string, value: string): boolean {
|
|
const lowerLine = line.toLowerCase()
|
|
|
|
if (
|
|
lowerLine.includes(DETECTION_KEYWORDS.TEST) ||
|
|
lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
if (
|
|
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) ||
|
|
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
if (value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)) {
|
|
return true
|
|
}
|
|
|
|
if (/^\d{2,}$/.test(value)) {
|
|
return false
|
|
}
|
|
|
|
return value.length > 3
|
|
}
|
|
|
|
private looksLikeMagicNumber(context: string): boolean {
|
|
const lowerContext = context.toLowerCase()
|
|
|
|
const configKeywords = [
|
|
DETECTION_KEYWORDS.TIMEOUT,
|
|
DETECTION_KEYWORDS.DELAY,
|
|
DETECTION_KEYWORDS.RETRY,
|
|
DETECTION_KEYWORDS.LIMIT,
|
|
DETECTION_KEYWORDS.MAX,
|
|
DETECTION_KEYWORDS.MIN,
|
|
DETECTION_KEYWORDS.PORT,
|
|
DETECTION_KEYWORDS.INTERVAL,
|
|
]
|
|
|
|
return configKeywords.some((keyword) => lowerContext.includes(keyword))
|
|
}
|
|
|
|
private isInComment(line: string, index: number): boolean {
|
|
const beforeIndex = line.substring(0, index)
|
|
return beforeIndex.includes("//") || beforeIndex.includes("/*")
|
|
}
|
|
|
|
private isInString(line: string, index: number): boolean {
|
|
const beforeIndex = line.substring(0, index)
|
|
const singleQuotes = (beforeIndex.match(/'/g) ?? []).length
|
|
const doubleQuotes = (beforeIndex.match(/"/g) ?? []).length
|
|
const backticks = (beforeIndex.match(/`/g) ?? []).length
|
|
|
|
return singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0 || backticks % 2 !== 0
|
|
}
|
|
|
|
private extractContext(line: string, index: number): string {
|
|
const start = Math.max(0, index - 30)
|
|
const end = Math.min(line.length, index + 30)
|
|
return line.substring(start, end)
|
|
}
|
|
}
|