refactor: migrate hardcode detector from regex to AST-based analysis

- Replace regex-based matchers with tree-sitter AST traversal
- Add duplicate value tracking across files
- Implement boolean literal detection
- Add value type classification (email, url, ip, api_key, etc.)
- Improve context awareness with AST node analysis
- Reduce false positives with better constant detection

Breaking changes removed:
- BraceTracker.ts
- ExportConstantAnalyzer.ts
- MagicNumberMatcher.ts
- MagicStringMatcher.ts

New components added:
- AstTreeTraverser for AST walking
- DuplicateValueTracker for cross-file tracking
- AstContextChecker for node context analysis
- AstNumberAnalyzer, AstStringAnalyzer, AstBooleanAnalyzer
- ValuePatternMatcher for type detection

Test coverage: 87.97% statements, 96.75% functions
This commit is contained in:
imfozilbek
2025-11-26 17:38:30 +05:00
parent 656571860e
commit af094eb54a
24 changed files with 2641 additions and 648 deletions

View File

@@ -14,6 +14,7 @@ import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternD
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector" import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
import { ISecretDetector } from "./domain/services/ISecretDetector" import { ISecretDetector } from "./domain/services/ISecretDetector"
import { IAnemicModelDetector } from "./domain/services/IAnemicModelDetector" import { IAnemicModelDetector } from "./domain/services/IAnemicModelDetector"
import { IDuplicateValueTracker } from "./domain/services/IDuplicateValueTracker"
import { FileScanner } from "./infrastructure/scanners/FileScanner" import { FileScanner } from "./infrastructure/scanners/FileScanner"
import { CodeParser } from "./infrastructure/parsers/CodeParser" import { CodeParser } from "./infrastructure/parsers/CodeParser"
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector" import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
@@ -25,6 +26,7 @@ import { RepositoryPatternDetector } from "./infrastructure/analyzers/Repository
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector" import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
import { SecretDetector } from "./infrastructure/analyzers/SecretDetector" import { SecretDetector } from "./infrastructure/analyzers/SecretDetector"
import { AnemicModelDetector } from "./infrastructure/analyzers/AnemicModelDetector" import { AnemicModelDetector } from "./infrastructure/analyzers/AnemicModelDetector"
import { DuplicateValueTracker } from "./infrastructure/analyzers/DuplicateValueTracker"
import { ERROR_MESSAGES } from "./shared/constants" import { ERROR_MESSAGES } from "./shared/constants"
/** /**
@@ -85,6 +87,7 @@ export async function analyzeProject(
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector() const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
const secretDetector: ISecretDetector = new SecretDetector() const secretDetector: ISecretDetector = new SecretDetector()
const anemicModelDetector: IAnemicModelDetector = new AnemicModelDetector() const anemicModelDetector: IAnemicModelDetector = new AnemicModelDetector()
const duplicateValueTracker: IDuplicateValueTracker = new DuplicateValueTracker()
const useCase = new AnalyzeProject( const useCase = new AnalyzeProject(
fileScanner, fileScanner,
codeParser, codeParser,
@@ -97,6 +100,7 @@ export async function analyzeProject(
aggregateBoundaryDetector, aggregateBoundaryDetector,
secretDetector, secretDetector,
anemicModelDetector, anemicModelDetector,
duplicateValueTracker,
) )
const result = await useCase.execute(options) const result = await useCase.execute(options)

View File

@@ -11,6 +11,7 @@ import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatt
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector" import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
import { ISecretDetector } from "../../domain/services/ISecretDetector" import { ISecretDetector } from "../../domain/services/ISecretDetector"
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector" import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
import { IDuplicateValueTracker } from "../../domain/services/IDuplicateValueTracker"
import { SourceFile } from "../../domain/entities/SourceFile" import { SourceFile } from "../../domain/entities/SourceFile"
import { DependencyGraph } from "../../domain/entities/DependencyGraph" import { DependencyGraph } from "../../domain/entities/DependencyGraph"
import { CollectFiles } from "./pipeline/CollectFiles" import { CollectFiles } from "./pipeline/CollectFiles"
@@ -62,8 +63,9 @@ export interface HardcodeViolation {
type: type:
| typeof HARDCODE_TYPES.MAGIC_NUMBER | typeof HARDCODE_TYPES.MAGIC_NUMBER
| typeof HARDCODE_TYPES.MAGIC_STRING | typeof HARDCODE_TYPES.MAGIC_STRING
| typeof HARDCODE_TYPES.MAGIC_BOOLEAN
| typeof HARDCODE_TYPES.MAGIC_CONFIG | typeof HARDCODE_TYPES.MAGIC_CONFIG
value: string | number value: string | number | boolean
file: string file: string
line: number line: number
column: number column: number
@@ -225,6 +227,7 @@ export class AnalyzeProject extends UseCase<
aggregateBoundaryDetector: IAggregateBoundaryDetector, aggregateBoundaryDetector: IAggregateBoundaryDetector,
secretDetector: ISecretDetector, secretDetector: ISecretDetector,
anemicModelDetector: IAnemicModelDetector, anemicModelDetector: IAnemicModelDetector,
duplicateValueTracker: IDuplicateValueTracker,
) { ) {
super() super()
this.fileCollectionStep = new CollectFiles(fileScanner) this.fileCollectionStep = new CollectFiles(fileScanner)
@@ -239,6 +242,7 @@ export class AnalyzeProject extends UseCase<
aggregateBoundaryDetector, aggregateBoundaryDetector,
secretDetector, secretDetector,
anemicModelDetector, anemicModelDetector,
duplicateValueTracker,
) )
this.resultAggregator = new AggregateResults() this.resultAggregator = new AggregateResults()
} }

View File

@@ -7,8 +7,10 @@ import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryP
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector" import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
import { ISecretDetector } from "../../../domain/services/ISecretDetector" import { ISecretDetector } from "../../../domain/services/ISecretDetector"
import { IAnemicModelDetector } from "../../../domain/services/IAnemicModelDetector" import { IAnemicModelDetector } from "../../../domain/services/IAnemicModelDetector"
import { IDuplicateValueTracker } from "../../../domain/services/IDuplicateValueTracker"
import { SourceFile } from "../../../domain/entities/SourceFile" import { SourceFile } from "../../../domain/entities/SourceFile"
import { DependencyGraph } from "../../../domain/entities/DependencyGraph" import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
import { HardcodedValue } from "../../../domain/value-objects/HardcodedValue"
import { import {
LAYERS, LAYERS,
REPOSITORY_VIOLATION_TYPES, REPOSITORY_VIOLATION_TYPES,
@@ -64,6 +66,7 @@ export class ExecuteDetection {
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector, private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
private readonly secretDetector: ISecretDetector, private readonly secretDetector: ISecretDetector,
private readonly anemicModelDetector: IAnemicModelDetector, private readonly anemicModelDetector: IAnemicModelDetector,
private readonly duplicateValueTracker: IDuplicateValueTracker,
) {} ) {}
public async execute(request: DetectionRequest): Promise<DetectionResult> { public async execute(request: DetectionRequest): Promise<DetectionResult> {
@@ -151,7 +154,10 @@ export class ExecuteDetection {
} }
private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] { private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] {
const violations: HardcodeViolation[] = [] const allHardcodedValues: {
value: HardcodedValue
file: SourceFile
}[] = []
for (const file of sourceFiles) { for (const file of sourceFiles) {
const hardcodedValues = this.hardcodeDetector.detectAll( const hardcodedValues = this.hardcodeDetector.detectAll(
@@ -160,23 +166,53 @@ export class ExecuteDetection {
) )
for (const hardcoded of hardcodedValues) { for (const hardcoded of hardcodedValues) {
violations.push({ allHardcodedValues.push({ value: hardcoded, file })
rule: RULES.HARDCODED_VALUE,
type: hardcoded.type,
value: hardcoded.value,
file: file.path.relative,
line: hardcoded.line,
column: hardcoded.column,
context: hardcoded.context,
suggestion: {
constantName: hardcoded.suggestConstantName(),
location: hardcoded.suggestLocation(file.layer),
},
severity: VIOLATION_SEVERITY_MAP.HARDCODE,
})
} }
} }
this.duplicateValueTracker.clear()
for (const { value, file } of allHardcodedValues) {
this.duplicateValueTracker.track(value, file.path.relative)
}
const violations: HardcodeViolation[] = []
for (const { value, file } of allHardcodedValues) {
const duplicateLocations = this.duplicateValueTracker.getDuplicateLocations(
value.value,
value.type,
)
const enrichedValue = duplicateLocations
? HardcodedValue.create(
value.value,
value.type,
value.line,
value.column,
value.context,
value.valueType,
duplicateLocations.filter((loc) => loc.file !== file.path.relative),
)
: value
if (enrichedValue.shouldSkip(file.layer)) {
continue
}
violations.push({
rule: RULES.HARDCODED_VALUE,
type: enrichedValue.type,
value: enrichedValue.value,
file: file.path.relative,
line: enrichedValue.line,
column: enrichedValue.column,
context: enrichedValue.context,
suggestion: {
constantName: enrichedValue.suggestConstantName(),
location: enrichedValue.suggestLocation(file.layer),
},
severity: VIOLATION_SEVERITY_MAP.HARDCODE,
})
}
return violations return violations
} }

View File

@@ -0,0 +1,55 @@
import { HardcodedValue } from "../value-objects/HardcodedValue"
export interface ValueLocation {
file: string
line: number
context: string
}
export interface DuplicateInfo {
value: string | number | boolean
locations: ValueLocation[]
count: number
}
/**
* Interface for tracking duplicate hardcoded values across files
*
* Helps identify values that are used in multiple places
* and should be extracted to a shared constant.
*/
export interface IDuplicateValueTracker {
/**
* Adds a hardcoded value to tracking
*/
track(violation: HardcodedValue, filePath: string): void
/**
* Gets all duplicate values (values used in 2+ places)
*/
getDuplicates(): DuplicateInfo[]
/**
* Gets duplicate locations for a specific value
*/
getDuplicateLocations(value: string | number | boolean, type: string): ValueLocation[] | null
/**
* Checks if a value is duplicated
*/
isDuplicate(value: string | number | boolean, type: string): boolean
/**
* Gets statistics about duplicates
*/
getStats(): {
totalValues: number
duplicateValues: number
duplicatePercentage: number
}
/**
* Clears all tracked values
*/
clear(): void
}

View File

@@ -1,15 +1,40 @@
import { ValueObject } from "./ValueObject" import { ValueObject } from "./ValueObject"
import { HARDCODE_TYPES } from "../../shared/constants/rules" import { DETECTION_PATTERNS, HARDCODE_TYPES } from "../../shared/constants/rules"
import { CONSTANT_NAMES, LOCATIONS, SUGGESTION_KEYWORDS } from "../constants/Suggestions" import { CONSTANT_NAMES, LOCATIONS, SUGGESTION_KEYWORDS } from "../constants/Suggestions"
export type HardcodeType = (typeof HARDCODE_TYPES)[keyof typeof HARDCODE_TYPES] export type HardcodeType = (typeof HARDCODE_TYPES)[keyof typeof HARDCODE_TYPES]
export type ValueType =
| "email"
| "url"
| "ip_address"
| "file_path"
| "date"
| "api_key"
| "uuid"
| "version"
| "color"
| "mac_address"
| "base64"
| "config"
| "generic"
export type ValueImportance = "critical" | "high" | "medium" | "low"
export interface DuplicateLocation {
file: string
line: number
}
interface HardcodedValueProps { interface HardcodedValueProps {
readonly value: string | number readonly value: string | number | boolean
readonly type: HardcodeType readonly type: HardcodeType
readonly valueType?: ValueType
readonly line: number readonly line: number
readonly column: number readonly column: number
readonly context: string readonly context: string
readonly duplicateLocations?: DuplicateLocation[]
readonly withinFileUsageCount?: number
} }
/** /**
@@ -21,22 +46,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
} }
public static create( public static create(
value: string | number, value: string | number | boolean,
type: HardcodeType, type: HardcodeType,
line: number, line: number,
column: number, column: number,
context: string, context: string,
valueType?: ValueType,
duplicateLocations?: DuplicateLocation[],
withinFileUsageCount?: number,
): HardcodedValue { ): HardcodedValue {
return new HardcodedValue({ return new HardcodedValue({
value, value,
type, type,
valueType,
line, line,
column, column,
context, context,
duplicateLocations,
withinFileUsageCount,
}) })
} }
public get value(): string | number { public get value(): string | number | boolean {
return this.props.value return this.props.value
} }
@@ -56,6 +87,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
return this.props.context return this.props.context
} }
public get valueType(): ValueType | undefined {
return this.props.valueType
}
public get duplicateLocations(): DuplicateLocation[] | undefined {
return this.props.duplicateLocations
}
public get withinFileUsageCount(): number | undefined {
return this.props.withinFileUsageCount
}
public hasDuplicates(): boolean {
return (
this.props.duplicateLocations !== undefined && this.props.duplicateLocations.length > 0
)
}
public isAlmostConstant(): boolean {
return this.props.withinFileUsageCount !== undefined && this.props.withinFileUsageCount >= 2
}
public isMagicNumber(): boolean { public isMagicNumber(): boolean {
return this.props.type === HARDCODE_TYPES.MAGIC_NUMBER return this.props.type === HARDCODE_TYPES.MAGIC_NUMBER
} }
@@ -106,6 +159,154 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
private suggestStringConstantName(): string { private suggestStringConstantName(): string {
const value = String(this.props.value) const value = String(this.props.value)
const context = this.props.context.toLowerCase() const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "email") {
if (context.includes("admin")) {
return "ADMIN_EMAIL"
}
if (context.includes("support")) {
return "SUPPORT_EMAIL"
}
if (context.includes("noreply") || context.includes("no-reply")) {
return "NOREPLY_EMAIL"
}
return "DEFAULT_EMAIL"
}
if (valueType === "api_key") {
if (context.includes("secret")) {
return "API_SECRET_KEY"
}
if (context.includes("public")) {
return "API_PUBLIC_KEY"
}
return "API_KEY"
}
if (valueType === "url") {
if (context.includes("api")) {
return "API_BASE_URL"
}
if (context.includes("database") || context.includes("db")) {
return "DATABASE_URL"
}
if (context.includes("mongo")) {
return "MONGODB_CONNECTION_STRING"
}
if (context.includes("postgres") || context.includes("pg")) {
return "POSTGRES_URL"
}
return "BASE_URL"
}
if (valueType === "ip_address") {
if (context.includes("server")) {
return "SERVER_IP"
}
if (context.includes("database") || context.includes("db")) {
return "DATABASE_HOST"
}
if (context.includes("redis")) {
return "REDIS_HOST"
}
return "HOST_IP"
}
if (valueType === "file_path") {
if (context.includes("log")) {
return "LOG_FILE_PATH"
}
if (context.includes("config")) {
return "CONFIG_FILE_PATH"
}
if (context.includes("data")) {
return "DATA_DIR_PATH"
}
if (context.includes("temp")) {
return "TEMP_DIR_PATH"
}
return "FILE_PATH"
}
if (valueType === "date") {
if (context.includes("deadline")) {
return "DEADLINE"
}
if (context.includes("start")) {
return "START_DATE"
}
if (context.includes("end")) {
return "END_DATE"
}
if (context.includes("expir")) {
return "EXPIRATION_DATE"
}
return "DEFAULT_DATE"
}
if (valueType === "uuid") {
if (context.includes("id") || context.includes("identifier")) {
return "DEFAULT_ID"
}
if (context.includes("request")) {
return "REQUEST_ID"
}
if (context.includes("session")) {
return "SESSION_ID"
}
return "UUID_CONSTANT"
}
if (valueType === "version") {
if (context.includes("api")) {
return "API_VERSION"
}
if (context.includes("app")) {
return "APP_VERSION"
}
return "VERSION"
}
if (valueType === "color") {
if (context.includes("primary")) {
return "PRIMARY_COLOR"
}
if (context.includes("secondary")) {
return "SECONDARY_COLOR"
}
if (context.includes("background")) {
return "BACKGROUND_COLOR"
}
return "COLOR_CONSTANT"
}
if (valueType === "mac_address") {
return "MAC_ADDRESS"
}
if (valueType === "base64") {
if (context.includes("token")) {
return "ENCODED_TOKEN"
}
if (context.includes("key")) {
return "ENCODED_KEY"
}
return "BASE64_VALUE"
}
if (valueType === "config") {
if (context.includes("endpoint")) {
return "API_ENDPOINT"
}
if (context.includes("route")) {
return "ROUTE_PATH"
}
if (context.includes("connection")) {
return "CONNECTION_STRING"
}
return "CONFIG_VALUE"
}
if (value.includes(SUGGESTION_KEYWORDS.HTTP)) { if (value.includes(SUGGESTION_KEYWORDS.HTTP)) {
return CONSTANT_NAMES.API_BASE_URL return CONSTANT_NAMES.API_BASE_URL
@@ -135,6 +336,23 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
} }
const context = this.props.context.toLowerCase() const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "api_key" || valueType === "url" || valueType === "ip_address") {
return "src/config/environment.ts"
}
if (valueType === "email") {
return "src/config/contacts.ts"
}
if (valueType === "file_path") {
return "src/config/paths.ts"
}
if (valueType === "date") {
return "src/config/dates.ts"
}
if ( if (
context.includes(SUGGESTION_KEYWORDS.ENTITY) || context.includes(SUGGESTION_KEYWORDS.ENTITY) ||
@@ -153,4 +371,122 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
return LOCATIONS.SHARED_CONSTANTS return LOCATIONS.SHARED_CONSTANTS
} }
public getDetailedSuggestion(currentLayer?: string): string {
const constantName = this.suggestConstantName()
const location = this.suggestLocation(currentLayer)
const valueTypeLabel = this.valueType ? ` (${this.valueType})` : ""
let suggestion = `Extract${valueTypeLabel} to constant ${constantName} in ${location}`
if (this.isAlmostConstant() && this.withinFileUsageCount) {
suggestion += `. This value appears ${String(this.withinFileUsageCount)} times in this file`
}
if (this.hasDuplicates() && this.duplicateLocations) {
const count = this.duplicateLocations.length
const fileList = this.duplicateLocations
.slice(0, 3)
.map((loc) => `${loc.file}:${String(loc.line)}`)
.join(", ")
const more = count > 3 ? ` and ${String(count - 3)} more` : ""
suggestion += `. Also duplicated in ${String(count)} other file(s): ${fileList}${more}`
}
return suggestion
}
/**
* Analyzes variable name and context to determine importance
*/
public getImportance(): ValueImportance {
const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "api_key") {
return "critical"
}
const criticalKeywords = [
...DETECTION_PATTERNS.SENSITIVE_KEYWORDS,
...DETECTION_PATTERNS.BUSINESS_KEYWORDS,
"key",
"age",
]
if (criticalKeywords.some((keyword) => context.includes(keyword))) {
return "critical"
}
const highKeywords = [...DETECTION_PATTERNS.TECHNICAL_KEYWORDS, "db", "api"]
if (highKeywords.some((keyword) => context.includes(keyword))) {
return "high"
}
if (valueType === "url" || valueType === "ip_address" || valueType === "email") {
return "high"
}
const mediumKeywords = DETECTION_PATTERNS.MEDIUM_KEYWORDS
if (mediumKeywords.some((keyword) => context.includes(keyword))) {
return "medium"
}
const lowKeywords = DETECTION_PATTERNS.UI_KEYWORDS
if (lowKeywords.some((keyword) => context.includes(keyword))) {
return "low"
}
return "medium"
}
/**
* Checks if this violation should be skipped based on layer strictness
*
* Different layers have different tolerance levels:
* - domain: strictest (no hardcoded values allowed)
* - application: strict (only low importance allowed)
* - infrastructure: moderate (low and some medium allowed)
* - cli: lenient (UI constants allowed)
*/
public shouldSkip(layer?: string): boolean {
if (!layer) {
return false
}
const importance = this.getImportance()
if (layer === "domain") {
return false
}
if (layer === "application") {
return false
}
if (layer === "infrastructure") {
return importance === "low" && this.isUIConstant()
}
if (layer === "cli") {
return importance === "low" && this.isUIConstant()
}
return false
}
/**
* Checks if this value is a UI-related constant
*/
private isUIConstant(): boolean {
const context = this.props.context.toLowerCase()
const uiKeywords = DETECTION_PATTERNS.UI_KEYWORDS
return uiKeywords.some((keyword) => context.includes(keyword))
}
} }

View File

@@ -1,7 +1,7 @@
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector" import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
import { AnemicModelViolation } from "../../domain/value-objects/AnemicModelViolation" import { AnemicModelViolation } from "../../domain/value-objects/AnemicModelViolation"
import { CLASS_KEYWORDS } from "../../shared/constants" import { CLASS_KEYWORDS } from "../../shared/constants"
import { LAYERS } from "../../shared/constants/rules" import { ANALYZER_DEFAULTS, ANEMIC_MODEL_FLAGS, LAYERS } from "../../shared/constants/rules"
/** /**
* Detects anemic domain model violations * Detects anemic domain model violations
@@ -224,8 +224,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
lineNumber, lineNumber,
methodCount, methodCount,
propertyCount, propertyCount,
false, ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_FALSE,
true, ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_TRUE,
) )
} }
@@ -237,8 +237,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
lineNumber, lineNumber,
methodCount, methodCount,
propertyCount, propertyCount,
true, ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_TRUE,
false, ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_FALSE,
) )
} }
@@ -256,8 +256,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
lineNumber, lineNumber,
methodCount, methodCount,
propertyCount, propertyCount,
false, ANALYZER_DEFAULTS.HAS_ONLY_GETTERS_SETTERS,
false, ANALYZER_DEFAULTS.HAS_PUBLIC_SETTERS,
) )
} }

View File

@@ -0,0 +1,104 @@
import Parser from "tree-sitter"
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { AstBooleanAnalyzer } from "../strategies/AstBooleanAnalyzer"
import { AstConfigObjectAnalyzer } from "../strategies/AstConfigObjectAnalyzer"
import { AstNumberAnalyzer } from "../strategies/AstNumberAnalyzer"
import { AstStringAnalyzer } from "../strategies/AstStringAnalyzer"
/**
* AST tree traverser for detecting hardcoded values
*
* Walks through the Abstract Syntax Tree and uses analyzers
* to detect hardcoded numbers, strings, booleans, and configuration objects.
* Also tracks value usage to identify "almost constants" - values used 2+ times.
*/
export class AstTreeTraverser {
constructor(
private readonly numberAnalyzer: AstNumberAnalyzer,
private readonly stringAnalyzer: AstStringAnalyzer,
private readonly booleanAnalyzer: AstBooleanAnalyzer,
private readonly configObjectAnalyzer: AstConfigObjectAnalyzer,
) {}
/**
* Traverses the AST tree and collects hardcoded values
*/
public traverse(tree: Parser.Tree, sourceCode: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = sourceCode.split("\n")
const cursor = tree.walk()
this.visit(cursor, lines, results)
this.markAlmostConstants(results)
return results
}
/**
* Marks values that appear multiple times in the same file
*/
private markAlmostConstants(results: HardcodedValue[]): void {
const valueUsage = new Map<string, number>()
for (const result of results) {
const key = `${result.type}:${String(result.value)}`
valueUsage.set(key, (valueUsage.get(key) || 0) + 1)
}
for (let i = 0; i < results.length; i++) {
const result = results[i]
const key = `${result.type}:${String(result.value)}`
const count = valueUsage.get(key) || 0
if (count >= 2 && !result.withinFileUsageCount) {
results[i] = HardcodedValue.create(
result.value,
result.type,
result.line,
result.column,
result.context,
result.valueType,
result.duplicateLocations,
count,
)
}
}
}
/**
* Recursively visits AST nodes
*/
private visit(cursor: Parser.TreeCursor, lines: string[], results: HardcodedValue[]): void {
const node = cursor.currentNode
if (node.type === "object") {
const violation = this.configObjectAnalyzer.analyze(node, lines)
if (violation) {
results.push(violation)
}
} else if (node.type === "number") {
const violation = this.numberAnalyzer.analyze(node, lines)
if (violation) {
results.push(violation)
}
} else if (node.type === "string") {
const violation = this.stringAnalyzer.analyze(node, lines)
if (violation) {
results.push(violation)
}
} else if (node.type === "true" || node.type === "false") {
const violation = this.booleanAnalyzer.analyze(node, lines)
if (violation) {
results.push(violation)
}
}
if (cursor.gotoFirstChild()) {
do {
this.visit(cursor, lines, results)
} while (cursor.gotoNextSibling())
cursor.gotoParent()
}
}
}

View File

@@ -0,0 +1,122 @@
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import type {
DuplicateInfo,
IDuplicateValueTracker,
ValueLocation,
} from "../../domain/services/IDuplicateValueTracker"
/**
* Tracks duplicate hardcoded values across files
*
* Helps identify values that are used in multiple places
* and should be extracted to a shared constant.
*/
export class DuplicateValueTracker implements IDuplicateValueTracker {
private readonly valueMap = new Map<string, ValueLocation[]>()
/**
* Adds a hardcoded value to tracking
*/
public track(violation: HardcodedValue, filePath: string): void {
const key = this.createKey(violation.value, violation.type)
const location: ValueLocation = {
file: filePath,
line: violation.line,
context: violation.context,
}
const locations = this.valueMap.get(key)
if (!locations) {
this.valueMap.set(key, [location])
} else {
locations.push(location)
}
}
/**
* Gets all duplicate values (values used in 2+ places)
*/
public getDuplicates(): DuplicateInfo[] {
const duplicates: DuplicateInfo[] = []
for (const [key, locations] of this.valueMap.entries()) {
if (locations.length >= 2) {
const { value } = this.parseKey(key)
duplicates.push({
value,
locations,
count: locations.length,
})
}
}
return duplicates.sort((a, b) => b.count - a.count)
}
/**
* Gets duplicate locations for a specific value
*/
public getDuplicateLocations(
value: string | number | boolean,
type: string,
): ValueLocation[] | null {
const key = this.createKey(value, type)
const locations = this.valueMap.get(key)
if (!locations || locations.length < 2) {
return null
}
return locations
}
/**
* Checks if a value is duplicated
*/
public isDuplicate(value: string | number | boolean, type: string): boolean {
const key = this.createKey(value, type)
const locations = this.valueMap.get(key)
return locations ? locations.length >= 2 : false
}
/**
* Creates a unique key for a value
*/
private createKey(value: string | number | boolean, type: string): string {
return `${type}:${String(value)}`
}
/**
* Parses a key back to value and type
*/
private parseKey(key: string): { value: string; type: string } {
const [type, ...valueParts] = key.split(":")
return { value: valueParts.join(":"), type }
}
/**
* Gets statistics about duplicates
*/
public getStats(): {
totalValues: number
duplicateValues: number
duplicatePercentage: number
} {
const totalValues = this.valueMap.size
const duplicateValues = this.getDuplicates().length
const duplicatePercentage = totalValues > 0 ? (duplicateValues / totalValues) * 100 : 0
return {
totalValues,
duplicateValues,
duplicatePercentage,
}
}
/**
* Clears all tracked values
*/
public clear(): void {
this.valueMap.clear()
}
}

View File

@@ -1,17 +1,28 @@
import Parser from "tree-sitter"
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector" import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue" import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { BraceTracker } from "../strategies/BraceTracker" import { CodeParser } from "../parsers/CodeParser"
import { AstBooleanAnalyzer } from "../strategies/AstBooleanAnalyzer"
import { AstConfigObjectAnalyzer } from "../strategies/AstConfigObjectAnalyzer"
import { AstContextChecker } from "../strategies/AstContextChecker"
import { AstNumberAnalyzer } from "../strategies/AstNumberAnalyzer"
import { AstStringAnalyzer } from "../strategies/AstStringAnalyzer"
import { ConstantsFileChecker } from "../strategies/ConstantsFileChecker" import { ConstantsFileChecker } from "../strategies/ConstantsFileChecker"
import { ExportConstantAnalyzer } from "../strategies/ExportConstantAnalyzer" import { AstTreeTraverser } from "./AstTreeTraverser"
import { MagicNumberMatcher } from "../strategies/MagicNumberMatcher"
import { MagicStringMatcher } from "../strategies/MagicStringMatcher"
/** /**
* Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code * Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code
* *
* This detector identifies configuration values, URLs, timeouts, ports, and other * This detector uses Abstract Syntax Tree (AST) analysis via tree-sitter to identify
* constants that should be extracted to configuration files. It uses pattern matching * configuration values, URLs, timeouts, ports, and other constants that should be
* and context analysis to reduce false positives. * extracted to configuration files. AST-based detection provides more accurate context
* understanding and reduces false positives compared to regex-based approaches.
*
* The detector uses a modular architecture with specialized components:
* - AstContextChecker: Checks if nodes are in specific contexts (exports, types, etc.)
* - AstNumberAnalyzer: Analyzes number literals to detect magic numbers
* - AstStringAnalyzer: Analyzes string literals to detect magic strings
* - AstTreeTraverser: Traverses the AST and coordinates analyzers
* *
* @example * @example
* ```typescript * ```typescript
@@ -26,17 +37,25 @@ import { MagicStringMatcher } from "../strategies/MagicStringMatcher"
*/ */
export class HardcodeDetector implements IHardcodeDetector { export class HardcodeDetector implements IHardcodeDetector {
private readonly constantsChecker: ConstantsFileChecker private readonly constantsChecker: ConstantsFileChecker
private readonly braceTracker: BraceTracker private readonly parser: CodeParser
private readonly exportAnalyzer: ExportConstantAnalyzer private readonly traverser: AstTreeTraverser
private readonly numberMatcher: MagicNumberMatcher
private readonly stringMatcher: MagicStringMatcher
constructor() { constructor() {
this.constantsChecker = new ConstantsFileChecker() this.constantsChecker = new ConstantsFileChecker()
this.braceTracker = new BraceTracker() this.parser = new CodeParser()
this.exportAnalyzer = new ExportConstantAnalyzer(this.braceTracker)
this.numberMatcher = new MagicNumberMatcher(this.exportAnalyzer) const contextChecker = new AstContextChecker()
this.stringMatcher = new MagicStringMatcher(this.exportAnalyzer) const numberAnalyzer = new AstNumberAnalyzer(contextChecker)
const stringAnalyzer = new AstStringAnalyzer(contextChecker)
const booleanAnalyzer = new AstBooleanAnalyzer(contextChecker)
const configObjectAnalyzer = new AstConfigObjectAnalyzer(contextChecker)
this.traverser = new AstTreeTraverser(
numberAnalyzer,
stringAnalyzer,
booleanAnalyzer,
configObjectAnalyzer,
)
} }
/** /**
@@ -51,10 +70,8 @@ export class HardcodeDetector implements IHardcodeDetector {
return [] return []
} }
const magicNumbers = this.numberMatcher.detect(code) const tree = this.parseCode(code, filePath)
const magicStrings = this.stringMatcher.detect(code) return this.traverser.traverse(tree, code)
return [...magicNumbers, ...magicStrings]
} }
/** /**
@@ -69,7 +86,9 @@ export class HardcodeDetector implements IHardcodeDetector {
return [] return []
} }
return this.numberMatcher.detect(code) const tree = this.parseCode(code, filePath)
const allViolations = this.traverser.traverse(tree, code)
return allViolations.filter((v) => v.isMagicNumber())
} }
/** /**
@@ -84,6 +103,20 @@ export class HardcodeDetector implements IHardcodeDetector {
return [] return []
} }
return this.stringMatcher.detect(code) const tree = this.parseCode(code, filePath)
const allViolations = this.traverser.traverse(tree, code)
return allViolations.filter((v) => v.isMagicString())
}
/**
* Parses code based on file extension
*/
private parseCode(code: string, filePath: string): Parser.Tree {
if (filePath.endsWith(".tsx")) {
return this.parser.parseTsx(code)
} else if (filePath.endsWith(".ts")) {
return this.parser.parseTypeScript(code)
}
return this.parser.parseJavaScript(code)
} }
} }

View File

@@ -3,6 +3,7 @@ import type { SecretLintConfigDescriptor } from "@secretlint/types"
import { ISecretDetector } from "../../domain/services/ISecretDetector" import { ISecretDetector } from "../../domain/services/ISecretDetector"
import { SecretViolation } from "../../domain/value-objects/SecretViolation" import { SecretViolation } from "../../domain/value-objects/SecretViolation"
import { SECRET_KEYWORDS, SECRET_TYPE_NAMES } from "../../domain/constants/SecretExamples" import { SECRET_KEYWORDS, SECRET_TYPE_NAMES } from "../../domain/constants/SecretExamples"
import { EXTERNAL_PACKAGES } from "../../shared/constants/rules"
/** /**
* Detects hardcoded secrets in TypeScript/JavaScript code * Detects hardcoded secrets in TypeScript/JavaScript code
@@ -25,7 +26,7 @@ export class SecretDetector implements ISecretDetector {
private readonly secretlintConfig: SecretLintConfigDescriptor = { private readonly secretlintConfig: SecretLintConfigDescriptor = {
rules: [ rules: [
{ {
id: "@secretlint/secretlint-rule-preset-recommend", id: EXTERNAL_PACKAGES.SECRETLINT_PRESET,
}, },
], ],
} }

View File

@@ -0,0 +1,92 @@
import Parser from "tree-sitter"
import { HardcodedValue, HardcodeType } from "../../domain/value-objects/HardcodedValue"
import { DETECTION_VALUES } from "../../shared/constants/rules"
import { AstContextChecker } from "./AstContextChecker"
/**
* AST-based analyzer for detecting magic booleans
*
* Detects boolean literals used as arguments without clear meaning.
* Example: doSomething(true, false, true) - hard to understand
* Better: doSomething({ sync: true, validate: false, cache: true })
*/
export class AstBooleanAnalyzer {
constructor(private readonly contextChecker: AstContextChecker) {}
/**
* Analyzes a boolean node and returns a violation if it's a magic boolean
*/
public analyze(node: Parser.SyntaxNode, lines: string[]): HardcodedValue | null {
if (!this.shouldDetect(node)) {
return null
}
const value = node.text === DETECTION_VALUES.BOOLEAN_TRUE
return this.createViolation(node, value, lines)
}
/**
* Checks if boolean should be detected
*/
private shouldDetect(node: Parser.SyntaxNode): boolean {
if (this.contextChecker.isInExportedConstant(node)) {
return false
}
if (this.contextChecker.isInTypeContext(node)) {
return false
}
if (this.contextChecker.isInTestDescription(node)) {
return false
}
const parent = node.parent
if (!parent) {
return false
}
if (parent.type === "arguments") {
return this.isInFunctionCallWithMultipleBooleans(parent)
}
return false
}
/**
* Checks if function call has multiple boolean arguments
*/
private isInFunctionCallWithMultipleBooleans(argsNode: Parser.SyntaxNode): boolean {
let booleanCount = 0
for (const child of argsNode.children) {
if (child.type === "true" || child.type === "false") {
booleanCount++
}
}
return booleanCount >= 2
}
/**
* Creates a HardcodedValue violation from a boolean node
*/
private createViolation(
node: Parser.SyntaxNode,
value: boolean,
lines: string[],
): HardcodedValue {
const lineNumber = node.startPosition.row + 1
const column = node.startPosition.column
const context = lines[node.startPosition.row]?.trim() ?? ""
return HardcodedValue.create(
value,
"MAGIC_BOOLEAN" as HardcodeType,
lineNumber,
column,
context,
)
}
}

View File

@@ -0,0 +1,114 @@
import Parser from "tree-sitter"
import { HardcodedValue, HardcodeType } from "../../domain/value-objects/HardcodedValue"
import { HARDCODE_TYPES } from "../../shared/constants/rules"
import { ALLOWED_NUMBERS } from "../constants/defaults"
import { AstContextChecker } from "./AstContextChecker"
/**
* AST-based analyzer for detecting configuration objects with hardcoded values
*
* Detects objects that contain multiple hardcoded values that should be
* extracted to a configuration file.
*
* Example:
* const config = { timeout: 5000, retries: 3, url: "http://..." }
*/
export class AstConfigObjectAnalyzer {
private readonly MIN_HARDCODED_VALUES = 2
constructor(private readonly contextChecker: AstContextChecker) {}
/**
* Analyzes an object expression and returns a violation if it contains many hardcoded values
*/
public analyze(node: Parser.SyntaxNode, lines: string[]): HardcodedValue | null {
if (node.type !== "object") {
return null
}
if (this.contextChecker.isInExportedConstant(node)) {
return null
}
if (this.contextChecker.isInTypeContext(node)) {
return null
}
const hardcodedCount = this.countHardcodedValues(node)
if (hardcodedCount < this.MIN_HARDCODED_VALUES) {
return null
}
return this.createViolation(node, hardcodedCount, lines)
}
/**
* Counts hardcoded values in an object
*/
private countHardcodedValues(objectNode: Parser.SyntaxNode): number {
let count = 0
for (const child of objectNode.children) {
if (child.type === "pair") {
const value = child.childForFieldName("value")
if (value && this.isHardcodedValue(value)) {
count++
}
}
}
return count
}
/**
* Checks if a node is a hardcoded value
*/
private isHardcodedValue(node: Parser.SyntaxNode): boolean {
if (node.type === "number") {
const value = parseInt(node.text, 10)
return !ALLOWED_NUMBERS.has(value) && value >= 100
}
if (node.type === "string") {
const stringFragment = node.children.find((c) => c.type === "string_fragment")
return stringFragment !== undefined && stringFragment.text.length > 3
}
return false
}
/**
* Creates a HardcodedValue violation for a config object
*/
private createViolation(
node: Parser.SyntaxNode,
hardcodedCount: number,
lines: string[],
): HardcodedValue {
const lineNumber = node.startPosition.row + 1
const column = node.startPosition.column
const context = lines[node.startPosition.row]?.trim() ?? ""
const objectPreview = this.getObjectPreview(node)
return HardcodedValue.create(
`Configuration object with ${String(hardcodedCount)} hardcoded values: ${objectPreview}`,
HARDCODE_TYPES.MAGIC_CONFIG as HardcodeType,
lineNumber,
column,
context,
)
}
/**
* Gets a preview of the object for the violation message
*/
private getObjectPreview(node: Parser.SyntaxNode): string {
const text = node.text
if (text.length <= 50) {
return text
}
return `${text.substring(0, 47)}...`
}
}

View File

@@ -0,0 +1,256 @@
import Parser from "tree-sitter"
/**
* AST context checker for analyzing node contexts
*
* Provides reusable methods to check if a node is in specific contexts
* like exports, type declarations, function calls, etc.
*/
export class AstContextChecker {
/**
* Checks if node is in an exported constant with "as const"
*/
public isInExportedConstant(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "export_statement") {
if (this.checkExportedConstant(current)) {
return true
}
}
current = current.parent
}
return false
}
/**
* Helper to check if export statement contains "as const"
*/
private checkExportedConstant(exportNode: Parser.SyntaxNode): boolean {
const declaration = exportNode.childForFieldName("declaration")
if (!declaration) {
return false
}
const declarator = this.findDescendant(declaration, "variable_declarator")
if (!declarator) {
return false
}
const value = declarator.childForFieldName("value")
if (value?.type !== "as_expression") {
return false
}
const asType = value.children.find((c) => c.type === "const")
return asType !== undefined
}
/**
* Checks if node is in a type context (union type, type alias, interface)
*/
public isInTypeContext(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (
current.type === "type_alias_declaration" ||
current.type === "union_type" ||
current.type === "literal_type" ||
current.type === "interface_declaration" ||
current.type === "type_annotation"
) {
return true
}
current = current.parent
}
return false
}
/**
* Checks if node is in an import statement or import() call
*/
public isInImportStatement(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "import_statement") {
return true
}
if (current.type === "call_expression") {
const functionNode =
current.childForFieldName("function") ||
current.children.find((c) => c.type === "identifier" || c.type === "import")
if (
functionNode &&
(functionNode.text === "import" || functionNode.type === "import")
) {
return true
}
}
current = current.parent
}
return false
}
/**
* Checks if node is in a test description (test(), describe(), it())
*/
public isInTestDescription(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "call_expression") {
const callee = current.childForFieldName("function")
if (callee?.type === "identifier") {
const funcName = callee.text
if (
funcName === "test" ||
funcName === "describe" ||
funcName === "it" ||
funcName === "expect"
) {
return true
}
}
}
current = current.parent
}
return false
}
/**
* Checks if node is in a console.log or console.error call
*/
public isInConsoleCall(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "call_expression") {
const callee = current.childForFieldName("function")
if (callee?.type === "member_expression") {
const object = callee.childForFieldName("object")
const property = callee.childForFieldName("property")
if (
object?.text === "console" &&
property &&
(property.text === "log" ||
property.text === "error" ||
property.text === "warn")
) {
return true
}
}
}
current = current.parent
}
return false
}
/**
* Checks if node is in a Symbol() call
*/
public isInSymbolCall(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "call_expression") {
const callee = current.childForFieldName("function")
if (callee?.type === "identifier" && callee.text === "Symbol") {
return true
}
}
current = current.parent
}
return false
}
/**
* Checks if node is in a typeof check
*/
public isInTypeofCheck(node: Parser.SyntaxNode): boolean {
let current = node.parent
while (current) {
if (current.type === "binary_expression") {
const left = current.childForFieldName("left")
const right = current.childForFieldName("right")
if (left?.type === "unary_expression") {
const operator = left.childForFieldName("operator")
if (operator?.text === "typeof") {
return true
}
}
if (right?.type === "unary_expression") {
const operator = right.childForFieldName("operator")
if (operator?.text === "typeof") {
return true
}
}
}
current = current.parent
}
return false
}
/**
* Checks if parent is a call expression with specific function names
*/
public isInCallExpression(parent: Parser.SyntaxNode, functionNames: string[]): boolean {
if (parent.type === "arguments") {
const callExpr = parent.parent
if (callExpr?.type === "call_expression") {
const callee = callExpr.childForFieldName("function")
if (callee?.type === "identifier") {
return functionNames.includes(callee.text)
}
}
}
return false
}
/**
* Gets context text around a node
*/
public getNodeContext(node: Parser.SyntaxNode): string {
let current: Parser.SyntaxNode | null = node
while (current && current.type !== "lexical_declaration" && current.type !== "pair") {
current = current.parent
}
return current ? current.text.toLowerCase() : ""
}
/**
* Finds a descendant node by type
*/
private findDescendant(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null {
if (node.type === type) {
return node
}
for (const child of node.children) {
const result = this.findDescendant(child, type)
if (result) {
return result
}
}
return null
}
}

View File

@@ -0,0 +1,132 @@
import Parser from "tree-sitter"
import { HardcodedValue, HardcodeType } from "../../domain/value-objects/HardcodedValue"
import { HARDCODE_TYPES } from "../../shared/constants/rules"
import { ALLOWED_NUMBERS, DETECTION_KEYWORDS } from "../constants/defaults"
import { AstContextChecker } from "./AstContextChecker"
/**
* AST-based analyzer for detecting magic numbers
*
* Analyzes number literal nodes in the AST to determine if they are
* hardcoded values that should be extracted to constants.
*/
export class AstNumberAnalyzer {
constructor(private readonly contextChecker: AstContextChecker) {}
/**
* Analyzes a number node and returns a violation if it's a magic number
*/
public analyze(node: Parser.SyntaxNode, lines: string[]): HardcodedValue | null {
const value = parseInt(node.text, 10)
if (ALLOWED_NUMBERS.has(value)) {
return null
}
if (this.contextChecker.isInExportedConstant(node)) {
return null
}
if (!this.shouldDetect(node, value)) {
return null
}
return this.createViolation(node, value, lines)
}
/**
* Checks if number should be detected based on context
*/
private shouldDetect(node: Parser.SyntaxNode, value: number): boolean {
const parent = node.parent
if (!parent) {
return false
}
if (this.contextChecker.isInCallExpression(parent, ["setTimeout", "setInterval"])) {
return true
}
if (parent.type === "variable_declarator") {
const identifier = parent.childForFieldName("name")
if (identifier && this.hasConfigKeyword(identifier.text.toLowerCase())) {
return true
}
}
if (parent.type === "pair") {
const key = parent.childForFieldName("key")
if (key && this.hasConfigKeyword(key.text.toLowerCase())) {
return true
}
}
if (value >= 100) {
const context = this.contextChecker.getNodeContext(node)
return this.looksLikeMagicNumber(context)
}
return false
}
/**
* Checks if name contains configuration keywords
*/
private hasConfigKeyword(name: string): boolean {
const keywords = [
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 (
keywords.some((keyword) => name.includes(keyword)) ||
name.includes("retries") ||
name.includes("attempts")
)
}
/**
* Checks if context suggests a magic number
*/
private looksLikeMagicNumber(context: string): boolean {
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) => context.includes(keyword))
}
/**
* Creates a HardcodedValue violation from a number node
*/
private createViolation(
node: Parser.SyntaxNode,
value: number,
lines: string[],
): HardcodedValue {
const lineNumber = node.startPosition.row + 1
const column = node.startPosition.column
const context = lines[node.startPosition.row]?.trim() ?? ""
return HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER as HardcodeType,
lineNumber,
column,
context,
)
}
}

View File

@@ -0,0 +1,144 @@
import Parser from "tree-sitter"
import { HardcodedValue, HardcodeType } from "../../domain/value-objects/HardcodedValue"
import { CONFIG_KEYWORDS, DETECTION_VALUES, HARDCODE_TYPES } from "../../shared/constants/rules"
import { AstContextChecker } from "./AstContextChecker"
import { ValuePatternMatcher } from "./ValuePatternMatcher"
/**
* AST-based analyzer for detecting magic strings
*
* Analyzes string literal nodes in the AST to determine if they are
* hardcoded values that should be extracted to constants.
*
* Detects various types of hardcoded strings:
* - URLs and connection strings
* - Email addresses
* - IP addresses
* - File paths
* - Dates
* - API keys
*/
export class AstStringAnalyzer {
private readonly patternMatcher: ValuePatternMatcher
constructor(private readonly contextChecker: AstContextChecker) {
this.patternMatcher = new ValuePatternMatcher()
}
/**
* Analyzes a string node and returns a violation if it's a magic string
*/
public analyze(node: Parser.SyntaxNode, lines: string[]): HardcodedValue | null {
const stringFragment = node.children.find((child) => child.type === "string_fragment")
if (!stringFragment) {
return null
}
const value = stringFragment.text
if (value.length <= 3) {
return null
}
if (this.contextChecker.isInExportedConstant(node)) {
return null
}
if (this.contextChecker.isInTypeContext(node)) {
return null
}
if (this.contextChecker.isInImportStatement(node)) {
return null
}
if (this.contextChecker.isInTestDescription(node)) {
return null
}
if (this.contextChecker.isInConsoleCall(node)) {
return null
}
if (this.contextChecker.isInSymbolCall(node)) {
return null
}
if (this.contextChecker.isInTypeofCheck(node)) {
return null
}
if (this.shouldDetect(node, value)) {
return this.createViolation(node, value, lines)
}
return null
}
/**
* Checks if string value should be detected
*/
private shouldDetect(node: Parser.SyntaxNode, value: string): boolean {
if (this.patternMatcher.shouldDetect(value)) {
return true
}
if (this.hasConfigurationContext(node)) {
return true
}
return false
}
/**
* Checks if string is in a configuration-related context
*/
private hasConfigurationContext(node: Parser.SyntaxNode): boolean {
const context = this.contextChecker.getNodeContext(node).toLowerCase()
const configKeywords = [
"url",
"uri",
...CONFIG_KEYWORDS.NETWORK,
"api",
...CONFIG_KEYWORDS.DATABASE,
"db",
"env",
...CONFIG_KEYWORDS.SECURITY,
"key",
...CONFIG_KEYWORDS.MESSAGES,
"label",
]
return configKeywords.some((keyword) => context.includes(keyword))
}
/**
* Creates a HardcodedValue violation from a string node
*/
private createViolation(
node: Parser.SyntaxNode,
value: string,
lines: string[],
): HardcodedValue {
const lineNumber = node.startPosition.row + 1
const column = node.startPosition.column
const context = lines[node.startPosition.row]?.trim() ?? ""
const detectedType = this.patternMatcher.detectType(value)
const valueType =
detectedType ||
(this.hasConfigurationContext(node)
? DETECTION_VALUES.TYPE_CONFIG
: DETECTION_VALUES.TYPE_GENERIC)
return HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_STRING as HardcodeType,
lineNumber,
column,
context,
valueType,
)
}
}

View File

@@ -1,96 +0,0 @@
/**
* Tracks braces and brackets in code for context analysis
*
* Used to determine if a line is inside an exported constant
* by counting unclosed braces and brackets.
*/
export class BraceTracker {
/**
* Counts unclosed braces and brackets between two line indices
*/
public countUnclosed(
lines: string[],
startLine: number,
endLine: number,
): { braces: number; brackets: number } {
let braces = 0
let brackets = 0
for (let i = startLine; i <= endLine; i++) {
const counts = this.countInLine(lines[i])
braces += counts.braces
brackets += counts.brackets
}
return { braces, brackets }
}
/**
* Counts braces and brackets in a single line
*/
private countInLine(line: string): { braces: number; brackets: number } {
let braces = 0
let brackets = 0
let inString = false
let stringChar = ""
for (let j = 0; j < line.length; j++) {
const char = line[j]
const prevChar = j > 0 ? line[j - 1] : ""
this.updateStringState(
char,
prevChar,
inString,
stringChar,
(newInString, newStringChar) => {
inString = newInString
stringChar = newStringChar
},
)
if (!inString) {
const counts = this.countChar(char)
braces += counts.braces
brackets += counts.brackets
}
}
return { braces, brackets }
}
/**
* Updates string tracking state
*/
private updateStringState(
char: string,
prevChar: string,
inString: boolean,
stringChar: string,
callback: (inString: boolean, stringChar: string) => void,
): void {
if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") {
if (!inString) {
callback(true, char)
} else if (char === stringChar) {
callback(false, "")
}
}
}
/**
* Counts a single character
*/
private countChar(char: string): { braces: number; brackets: number } {
if (char === "{") {
return { braces: 1, brackets: 0 }
} else if (char === "}") {
return { braces: -1, brackets: 0 }
} else if (char === "[") {
return { braces: 0, brackets: 1 }
} else if (char === "]") {
return { braces: 0, brackets: -1 }
}
return { braces: 0, brackets: 0 }
}
}

View File

@@ -1,112 +0,0 @@
import { CODE_PATTERNS } from "../constants/defaults"
import { BraceTracker } from "./BraceTracker"
/**
* Analyzes export const declarations in code
*
* Determines if a line is inside an exported constant declaration
* to skip hardcode detection in constant definitions.
*/
export class ExportConstantAnalyzer {
constructor(private readonly braceTracker: BraceTracker) {}
/**
* Checks if a line is inside an exported constant definition
*/
public 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.braceTracker.countUnclosed(
lines,
exportConstStart,
lineIndex,
)
return braces > 0 || brackets > 0
}
/**
* Checks if a line is a single-line export const declaration
*/
public isSingleLineExportConst(line: string): boolean {
if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) {
return false
}
const hasObjectOrArray = this.hasObjectOrArray(line)
if (hasObjectOrArray) {
return this.hasAsConstEnding(line)
}
return line.includes(CODE_PATTERNS.AS_CONST)
}
/**
* Finds the starting line of an export const declaration
*/
public findExportConstStart(lines: string[], lineIndex: number): number {
for (let currentLine = lineIndex; currentLine >= 0; currentLine--) {
const trimmed = lines[currentLine].trim()
if (this.isExportConstWithStructure(trimmed)) {
return currentLine
}
if (this.isTopLevelStatement(trimmed, currentLine, lineIndex)) {
break
}
}
return -1
}
/**
* Checks if line has object or array structure
*/
private hasObjectOrArray(line: string): boolean {
return line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START)
}
/**
* Checks if line has 'as const' ending
*/
private hasAsConstEnding(line: string): boolean {
return (
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)
)
}
/**
* Checks if line is export const with object or array
*/
private isExportConstWithStructure(trimmed: string): boolean {
return (
trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) &&
(trimmed.includes(CODE_PATTERNS.OBJECT_START) ||
trimmed.includes(CODE_PATTERNS.ARRAY_START))
)
}
/**
* Checks if line is a top-level statement
*/
private isTopLevelStatement(trimmed: string, currentLine: number, lineIndex: number): boolean {
return (
currentLine < lineIndex &&
(trimmed.startsWith(CODE_PATTERNS.EXPORT) || trimmed.startsWith(CODE_PATTERNS.IMPORT))
)
}
}

View File

@@ -1,171 +0,0 @@
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { ALLOWED_NUMBERS, DETECTION_KEYWORDS } from "../constants/defaults"
import { HARDCODE_TYPES } from "../../shared/constants"
import { ExportConstantAnalyzer } from "./ExportConstantAnalyzer"
/**
* Detects magic numbers in code
*
* Identifies hardcoded numeric values that should be extracted
* to constants, excluding allowed values and exported constants.
*/
export class MagicNumberMatcher {
private readonly 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,
]
constructor(private readonly exportAnalyzer: ExportConstantAnalyzer) {}
/**
* Detects magic numbers in code
*/
public detect(code: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
lines.forEach((line, lineIndex) => {
if (this.shouldSkipLine(line, lines, lineIndex)) {
return
}
this.detectInPatterns(line, lineIndex, results)
this.detectGenericNumbers(line, lineIndex, results)
})
return results
}
/**
* Checks if line should be skipped
*/
private shouldSkipLine(line: string, lines: string[], lineIndex: number): boolean {
if (line.trim().startsWith("//") || line.trim().startsWith("*")) {
return true
}
return this.exportAnalyzer.isInExportedConstant(lines, lineIndex)
}
/**
* Detects numbers in specific patterns
*/
private detectInPatterns(line: string, lineIndex: number, results: HardcodedValue[]): void {
this.numberPatterns.forEach((pattern) => {
let match
const regex = new RegExp(pattern)
while ((match = regex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (!ALLOWED_NUMBERS.has(value)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
})
}
/**
* Detects generic 3+ digit numbers
*/
private detectGenericNumbers(line: string, lineIndex: number, results: HardcodedValue[]): void {
const genericNumberRegex = /\b(\d{3,})\b/g
let match
while ((match = genericNumberRegex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (this.shouldDetectNumber(value, line, match.index)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
}
/**
* Checks if number should be detected
*/
private shouldDetectNumber(value: number, line: string, index: number): boolean {
if (ALLOWED_NUMBERS.has(value)) {
return false
}
if (this.isInComment(line, index)) {
return false
}
if (this.isInString(line, index)) {
return false
}
const context = this.extractContext(line, index)
return this.looksLikeMagicNumber(context)
}
/**
* Checks if position is in a comment
*/
private isInComment(line: string, index: number): boolean {
const beforeIndex = line.substring(0, index)
return beforeIndex.includes("//") || beforeIndex.includes("/*")
}
/**
* Checks if position is in a string
*/
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
}
/**
* Extracts context around a position
*/
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)
}
/**
* Checks if context suggests a magic number
*/
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))
}
}

View File

@@ -1,220 +0,0 @@
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { DETECTION_KEYWORDS } from "../constants/defaults"
import { HARDCODE_TYPES } from "../../shared/constants"
import { ExportConstantAnalyzer } from "./ExportConstantAnalyzer"
import {
DYNAMIC_IMPORT_PATTERN_PARTS,
REGEX_ESCAPE_PATTERN,
} from "../../domain/constants/SecretExamples"
/**
* Detects magic strings in code
*
* Identifies hardcoded string values that should be extracted
* to constants, excluding test code, console logs, and type contexts.
*/
export class MagicStringMatcher {
private readonly stringRegex = /(['"`])(?:(?!\1).)+\1/g
private readonly allowedPatterns = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/]
private readonly typeContextPatterns = [
/^\s*type\s+\w+\s*=/i,
/^\s*interface\s+\w+/i,
/^\s*\w+\s*:\s*['"`]/,
/\s+as\s+['"`]/,
/Record<.*,\s*import\(/,
/typeof\s+\w+\s*===\s*['"`]/,
/['"`]\s*===\s*typeof\s+\w+/,
]
constructor(private readonly exportAnalyzer: ExportConstantAnalyzer) {}
/**
* Detects magic strings in code
*/
public detect(code: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
lines.forEach((line, lineIndex) => {
if (this.shouldSkipLine(line, lines, lineIndex)) {
return
}
this.detectStringsInLine(line, lineIndex, results)
})
return results
}
/**
* Checks if line should be skipped
*/
private shouldSkipLine(line: string, lines: string[], lineIndex: number): boolean {
if (
line.trim().startsWith("//") ||
line.trim().startsWith("*") ||
line.includes("import ") ||
line.includes("from ")
) {
return true
}
return this.exportAnalyzer.isInExportedConstant(lines, lineIndex)
}
/**
* Detects strings in a single line
*/
private detectStringsInLine(line: string, lineIndex: number, results: HardcodedValue[]): void {
let match
const regex = new RegExp(this.stringRegex)
while ((match = regex.exec(line)) !== null) {
const fullMatch = match[0]
const value = fullMatch.slice(1, -1)
if (this.shouldDetectString(fullMatch, value, line)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_STRING,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
}
/**
* Checks if string should be detected
*/
private shouldDetectString(fullMatch: string, value: string, line: string): boolean {
if (fullMatch.startsWith("`") || value.includes("${")) {
return false
}
if (this.isAllowedString(value)) {
return false
}
return this.looksLikeMagicString(line, value)
}
/**
* Checks if string is allowed (short strings, single chars, etc.)
*/
private isAllowedString(str: string): boolean {
if (str.length <= 1) {
return true
}
return this.allowedPatterns.some((pattern) => pattern.test(str))
}
/**
* Checks if line context suggests a magic string
*/
private looksLikeMagicString(line: string, value: string): boolean {
const lowerLine = line.toLowerCase()
if (this.isTestCode(lowerLine)) {
return false
}
if (this.isConsoleLog(lowerLine)) {
return false
}
if (this.isInTypeContext(line)) {
return false
}
if (this.isInSymbolCall(line, value)) {
return false
}
if (this.isInImportCall(line, value)) {
return false
}
if (this.isUrlOrApi(value)) {
return true
}
if (/^\d{2,}$/.test(value)) {
return false
}
return value.length > 3
}
/**
* Checks if line is test code
*/
private isTestCode(lowerLine: string): boolean {
return (
lowerLine.includes(DETECTION_KEYWORDS.TEST) ||
lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE)
)
}
/**
* Checks if line is console log
*/
private isConsoleLog(lowerLine: string): boolean {
return (
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) ||
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR)
)
}
/**
* Checks if line is in type context
*/
private isInTypeContext(line: string): boolean {
const trimmedLine = line.trim()
if (this.typeContextPatterns.some((pattern) => pattern.test(trimmedLine))) {
return true
}
if (trimmedLine.includes("|") && /['"`][^'"`]+['"`]\s*\|/.test(trimmedLine)) {
return true
}
return false
}
/**
* Checks if string is inside Symbol() call
*/
private isInSymbolCall(line: string, stringValue: string): boolean {
const escapedValue = stringValue.replace(
/[.*+?^${}()|[\]\\]/g,
REGEX_ESCAPE_PATTERN.DOLLAR_AMPERSAND,
)
const symbolPattern = new RegExp(`Symbol\\s*\\(\\s*['"\`]${escapedValue}['"\`]\\s*\\)`)
return symbolPattern.test(line)
}
/**
* Checks if string is inside import() call
*/
private isInImportCall(line: string, stringValue: string): boolean {
const importPattern = new RegExp(
`import\\s*\\(\\s*['${DYNAMIC_IMPORT_PATTERN_PARTS.QUOTE_START}'${DYNAMIC_IMPORT_PATTERN_PARTS.QUOTE_END}"]\\s*\\)`,
)
return importPattern.test(line) && line.includes(stringValue)
}
/**
* Checks if string contains URL or API reference
*/
private isUrlOrApi(value: string): boolean {
return value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)
}
}

View File

@@ -0,0 +1,191 @@
/**
* Pattern matcher for detecting specific value types
*
* Provides pattern matching for emails, IPs, paths, dates, UUIDs, versions, and other common hardcoded values
*/
export class ValuePatternMatcher {
private static readonly EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
private static readonly IP_V4_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$/
private static readonly IP_V6_PATTERN =
/^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$/
private static readonly DATE_ISO_PATTERN = /^\d{4}-\d{2}-\d{2}$/
private static readonly URL_PATTERN = /^https?:\/\/|^mongodb:\/\/|^postgresql:\/\//
private static readonly UNIX_PATH_PATTERN = /^\/[a-zA-Z0-9/_-]+/
private static readonly WINDOWS_PATH_PATTERN = /^[a-zA-Z]:\\[a-zA-Z0-9\\/_-]+/
private static readonly API_KEY_PATTERN = /^(sk_|pk_|api_|key_)[a-zA-Z0-9_-]{20,}$/
private static readonly UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
private static readonly SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/
private static readonly HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
private static readonly MAC_ADDRESS_PATTERN = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/
private static readonly BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
private static readonly JWT_PATTERN = /^eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/
/**
* Checks if value is an email address
*/
public isEmail(value: string): boolean {
return ValuePatternMatcher.EMAIL_PATTERN.test(value)
}
/**
* Checks if value is an IP address (v4 or v6)
*/
public isIpAddress(value: string): boolean {
return (
ValuePatternMatcher.IP_V4_PATTERN.test(value) ||
ValuePatternMatcher.IP_V6_PATTERN.test(value)
)
}
/**
* Checks if value is a date in ISO format
*/
public isDate(value: string): boolean {
return ValuePatternMatcher.DATE_ISO_PATTERN.test(value)
}
/**
* Checks if value is a URL
*/
public isUrl(value: string): boolean {
return ValuePatternMatcher.URL_PATTERN.test(value)
}
/**
* Checks if value is a file path (Unix or Windows)
*/
public isFilePath(value: string): boolean {
return (
ValuePatternMatcher.UNIX_PATH_PATTERN.test(value) ||
ValuePatternMatcher.WINDOWS_PATH_PATTERN.test(value)
)
}
/**
* Checks if value looks like an API key
*/
public isApiKey(value: string): boolean {
return ValuePatternMatcher.API_KEY_PATTERN.test(value)
}
/**
* Checks if value is a UUID
*/
public isUuid(value: string): boolean {
return ValuePatternMatcher.UUID_PATTERN.test(value)
}
/**
* Checks if value is a semantic version
*/
public isSemver(value: string): boolean {
return ValuePatternMatcher.SEMVER_PATTERN.test(value)
}
/**
* Checks if value is a hex color
*/
public isHexColor(value: string): boolean {
return ValuePatternMatcher.HEX_COLOR_PATTERN.test(value)
}
/**
* Checks if value is a MAC address
*/
public isMacAddress(value: string): boolean {
return ValuePatternMatcher.MAC_ADDRESS_PATTERN.test(value)
}
/**
* Checks if value is Base64 encoded (min length 20 to avoid false positives)
*/
public isBase64(value: string): boolean {
return value.length >= 20 && ValuePatternMatcher.BASE64_PATTERN.test(value)
}
/**
* Checks if value is a JWT token
*/
public isJwt(value: string): boolean {
return ValuePatternMatcher.JWT_PATTERN.test(value)
}
/**
* Detects the type of value
*/
public detectType(
value: string,
):
| "email"
| "url"
| "ip_address"
| "file_path"
| "date"
| "api_key"
| "uuid"
| "version"
| "color"
| "mac_address"
| "base64"
| null {
if (this.isEmail(value)) {
return "email"
}
if (this.isJwt(value)) {
return "api_key"
}
if (this.isApiKey(value)) {
return "api_key"
}
if (this.isUrl(value)) {
return "url"
}
if (this.isIpAddress(value)) {
return "ip_address"
}
if (this.isFilePath(value)) {
return "file_path"
}
if (this.isDate(value)) {
return "date"
}
if (this.isUuid(value)) {
return "uuid"
}
if (this.isSemver(value)) {
return "version"
}
if (this.isHexColor(value)) {
return "color"
}
if (this.isMacAddress(value)) {
return "mac_address"
}
if (this.isBase64(value)) {
return "base64"
}
return null
}
/**
* Checks if value should be detected as hardcoded
*/
public shouldDetect(value: string): boolean {
return (
this.isEmail(value) ||
this.isUrl(value) ||
this.isIpAddress(value) ||
this.isFilePath(value) ||
this.isDate(value) ||
this.isApiKey(value) ||
this.isUuid(value) ||
this.isSemver(value) ||
this.isHexColor(value) ||
this.isMacAddress(value) ||
this.isBase64(value) ||
this.isJwt(value)
)
}
}

View File

@@ -21,6 +21,7 @@ export const RULES = {
export const HARDCODE_TYPES = { export const HARDCODE_TYPES = {
MAGIC_NUMBER: "magic-number", MAGIC_NUMBER: "magic-number",
MAGIC_STRING: "magic-string", MAGIC_STRING: "magic-string",
MAGIC_BOOLEAN: "magic-boolean",
MAGIC_CONFIG: "magic-config", MAGIC_CONFIG: "magic-config",
} as const } as const
@@ -416,3 +417,83 @@ export const REPOSITORY_VIOLATION_TYPES = {
NEW_REPOSITORY_IN_USE_CASE: "new-repository-in-use-case", NEW_REPOSITORY_IN_USE_CASE: "new-repository-in-use-case",
NON_DOMAIN_METHOD_NAME: "non-domain-method-name", NON_DOMAIN_METHOD_NAME: "non-domain-method-name",
} as const } as const
/**
* Detection patterns for sensitive keywords
*/
export const DETECTION_PATTERNS = {
SENSITIVE_KEYWORDS: ["password", "secret", "token", "auth", "credential"],
BUSINESS_KEYWORDS: ["price", "salary", "balance", "amount", "limit", "threshold", "quota"],
TECHNICAL_KEYWORDS: [
"timeout",
"retry",
"attempt",
"maxretries",
"database",
"connection",
"host",
"port",
"endpoint",
],
MEDIUM_KEYWORDS: ["delay", "interval", "duration", "size", "count", "max", "min"],
UI_KEYWORDS: [
"padding",
"margin",
"width",
"height",
"color",
"style",
"label",
"title",
"placeholder",
"icon",
"text",
"display",
],
} as const
/**
* Configuration detection keywords
*/
export const CONFIG_KEYWORDS = {
NETWORK: ["endpoint", "host", "domain", "path", "route"],
DATABASE: ["connection", "database"],
SECURITY: ["config", "secret", "token", "password", "credential"],
MESSAGES: ["message", "error", "warning", "text"],
} as const
/**
* Detection comparison values
*/
export const DETECTION_VALUES = {
BOOLEAN_TRUE: "true",
BOOLEAN_FALSE: "false",
TYPE_CONFIG: "config",
TYPE_GENERIC: "generic",
} as const
/**
* Boolean constants for analyzers
*/
export const ANALYZER_DEFAULTS = {
HAS_ONLY_GETTERS_SETTERS: false,
HAS_PUBLIC_SETTERS: false,
HAS_BUSINESS_LOGIC: false,
} as const
/**
* Anemic model detection flags
*/
export const ANEMIC_MODEL_FLAGS = {
HAS_ONLY_GETTERS_SETTERS_TRUE: true,
HAS_ONLY_GETTERS_SETTERS_FALSE: false,
HAS_PUBLIC_SETTERS_TRUE: true,
HAS_PUBLIC_SETTERS_FALSE: false,
} as const
/**
* External package constants
*/
export const EXTERNAL_PACKAGES = {
SECRETLINT_PRESET: "@secretlint/secretlint-rule-preset-recommend",
} as const

View File

@@ -0,0 +1,358 @@
import { describe, it, expect } from "vitest"
import { EntityExposure } from "../../../src/domain/value-objects/EntityExposure"
describe("EntityExposure", () => {
describe("create", () => {
it("should create entity exposure with all properties", () => {
const exposure = EntityExposure.create(
"User",
"User",
"src/controllers/UserController.ts",
"infrastructure",
25,
"getUser",
)
expect(exposure.entityName).toBe("User")
expect(exposure.returnType).toBe("User")
expect(exposure.filePath).toBe("src/controllers/UserController.ts")
expect(exposure.layer).toBe("infrastructure")
expect(exposure.line).toBe(25)
expect(exposure.methodName).toBe("getUser")
})
it("should create entity exposure without optional properties", () => {
const exposure = EntityExposure.create(
"Order",
"Order",
"src/controllers/OrderController.ts",
"infrastructure",
)
expect(exposure.entityName).toBe("Order")
expect(exposure.line).toBeUndefined()
expect(exposure.methodName).toBeUndefined()
})
it("should create entity exposure with line but without method name", () => {
const exposure = EntityExposure.create(
"Product",
"Product",
"src/api/ProductApi.ts",
"infrastructure",
15,
)
expect(exposure.line).toBe(15)
expect(exposure.methodName).toBeUndefined()
})
})
describe("getMessage", () => {
it("should return message with method name", () => {
const exposure = EntityExposure.create(
"User",
"User",
"src/controllers/UserController.ts",
"infrastructure",
25,
"getUser",
)
const message = exposure.getMessage()
expect(message).toContain("Method 'getUser'")
expect(message).toContain("returns domain entity 'User'")
expect(message).toContain("instead of DTO")
})
it("should return message without method name", () => {
const exposure = EntityExposure.create(
"Order",
"Order",
"src/controllers/OrderController.ts",
"infrastructure",
30,
)
const message = exposure.getMessage()
expect(message).toContain("returns domain entity 'Order'")
expect(message).toContain("instead of DTO")
expect(message).not.toContain("undefined")
})
it("should handle different entity names", () => {
const exposures = [
EntityExposure.create(
"Customer",
"Customer",
"file.ts",
"infrastructure",
1,
"getCustomer",
),
EntityExposure.create(
"Invoice",
"Invoice",
"file.ts",
"infrastructure",
2,
"findInvoice",
),
EntityExposure.create(
"Payment",
"Payment",
"file.ts",
"infrastructure",
3,
"processPayment",
),
]
exposures.forEach((exposure) => {
const message = exposure.getMessage()
expect(message).toContain(exposure.entityName)
expect(message).toContain("instead of DTO")
})
})
})
describe("getSuggestion", () => {
it("should return multi-line suggestion", () => {
const exposure = EntityExposure.create(
"User",
"User",
"src/controllers/UserController.ts",
"infrastructure",
25,
"getUser",
)
const suggestion = exposure.getSuggestion()
expect(suggestion).toContain("Create a DTO class")
expect(suggestion).toContain("UserResponseDto")
expect(suggestion).toContain("Create a mapper")
expect(suggestion).toContain("Update the method")
})
it("should suggest appropriate DTO name based on entity", () => {
const exposure = EntityExposure.create(
"Order",
"Order",
"src/controllers/OrderController.ts",
"infrastructure",
)
const suggestion = exposure.getSuggestion()
expect(suggestion).toContain("OrderResponseDto")
expect(suggestion).toContain("convert Order to OrderResponseDto")
})
it("should provide step-by-step suggestions", () => {
const exposure = EntityExposure.create(
"Product",
"Product",
"src/api/ProductApi.ts",
"infrastructure",
10,
)
const suggestion = exposure.getSuggestion()
const lines = suggestion.split("\n")
expect(lines.length).toBeGreaterThan(1)
expect(lines.some((line) => line.includes("Create a DTO"))).toBe(true)
expect(lines.some((line) => line.includes("mapper"))).toBe(true)
expect(lines.some((line) => line.includes("Update the method"))).toBe(true)
})
})
describe("getExampleFix", () => {
it("should return example with method name", () => {
const exposure = EntityExposure.create(
"User",
"User",
"src/controllers/UserController.ts",
"infrastructure",
25,
"getUser",
)
const example = exposure.getExampleFix()
expect(example).toContain("Bad: Exposing domain entity")
expect(example).toContain("Good: Using DTO")
expect(example).toContain("getUser()")
expect(example).toContain("Promise<User>")
expect(example).toContain("Promise<UserResponseDto>")
expect(example).toContain("UserMapper.toDto")
})
it("should return example without method name", () => {
const exposure = EntityExposure.create(
"Order",
"Order",
"src/controllers/OrderController.ts",
"infrastructure",
30,
)
const example = exposure.getExampleFix()
expect(example).toContain("Promise<Order>")
expect(example).toContain("Promise<OrderResponseDto>")
expect(example).toContain("OrderMapper.toDto")
expect(example).not.toContain("undefined")
})
it("should show both bad and good examples", () => {
const exposure = EntityExposure.create(
"Product",
"Product",
"src/api/ProductApi.ts",
"infrastructure",
15,
"findProduct",
)
const example = exposure.getExampleFix()
expect(example).toContain("❌ Bad")
expect(example).toContain("✅ Good")
})
it("should include async/await pattern", () => {
const exposure = EntityExposure.create(
"Customer",
"Customer",
"src/api/CustomerApi.ts",
"infrastructure",
20,
"getCustomer",
)
const example = exposure.getExampleFix()
expect(example).toContain("async")
expect(example).toContain("await")
})
})
describe("value object behavior", () => {
it("should be equal to another instance with same values", () => {
const exposure1 = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"getUser",
)
const exposure2 = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"getUser",
)
expect(exposure1.equals(exposure2)).toBe(true)
})
it("should not be equal to instance with different values", () => {
const exposure1 = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"getUser",
)
const exposure2 = EntityExposure.create(
"Order",
"Order",
"file.ts",
"infrastructure",
10,
"getUser",
)
expect(exposure1.equals(exposure2)).toBe(false)
})
it("should not be equal to instance with different method name", () => {
const exposure1 = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"getUser",
)
const exposure2 = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"findUser",
)
expect(exposure1.equals(exposure2)).toBe(false)
})
})
describe("edge cases", () => {
it("should handle empty entity name", () => {
const exposure = EntityExposure.create("", "", "file.ts", "infrastructure")
expect(exposure.entityName).toBe("")
expect(exposure.getMessage()).toBeTruthy()
})
it("should handle very long entity names", () => {
const longName = "VeryLongEntityNameThatIsUnusuallyLong"
const exposure = EntityExposure.create(longName, longName, "file.ts", "infrastructure")
expect(exposure.entityName).toBe(longName)
const suggestion = exposure.getSuggestion()
expect(suggestion).toContain(`${longName}ResponseDto`)
})
it("should handle special characters in method name", () => {
const exposure = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
10,
"get$User",
)
const message = exposure.getMessage()
expect(message).toContain("get$User")
})
it("should handle line number 0", () => {
const exposure = EntityExposure.create("User", "User", "file.ts", "infrastructure", 0)
expect(exposure.line).toBe(0)
})
it("should handle very large line numbers", () => {
const exposure = EntityExposure.create(
"User",
"User",
"file.ts",
"infrastructure",
999999,
)
expect(exposure.line).toBe(999999)
})
})
})

View File

@@ -0,0 +1,465 @@
import { describe, it, expect, beforeEach } from "vitest"
import { DuplicateValueTracker } from "../../../src/infrastructure/analyzers/DuplicateValueTracker"
import { HardcodedValue } from "../../../src/domain/value-objects/HardcodedValue"
describe("DuplicateValueTracker", () => {
let tracker: DuplicateValueTracker
beforeEach(() => {
tracker = new DuplicateValueTracker()
})
describe("track", () => {
it("should track a single hardcoded value", () => {
const value = HardcodedValue.create(
"test-value",
"magic-string",
10,
5,
"const x = 'test-value'",
)
tracker.track(value, "file1.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(0)
})
it("should track multiple occurrences of the same value", () => {
const value1 = HardcodedValue.create(
"test-value",
"magic-string",
10,
5,
"const x = 'test-value'",
)
const value2 = HardcodedValue.create(
"test-value",
"magic-string",
20,
5,
"const y = 'test-value'",
)
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(1)
expect(duplicates[0].value).toBe("test-value")
expect(duplicates[0].count).toBe(2)
})
it("should track values with different types separately", () => {
const stringValue = HardcodedValue.create(
"100",
"magic-string",
10,
5,
"const x = '100'",
)
const numberValue = HardcodedValue.create(100, "magic-number", 20, 5, "const y = 100")
tracker.track(stringValue, "file1.ts")
tracker.track(numberValue, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(0)
})
it("should track boolean values", () => {
const value1 = HardcodedValue.create(true, "MAGIC_BOOLEAN", 10, 5, "const x = true")
const value2 = HardcodedValue.create(true, "MAGIC_BOOLEAN", 20, 5, "const y = true")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(1)
expect(duplicates[0].value).toBe("true")
})
})
describe("getDuplicates", () => {
it("should return empty array when no duplicates exist", () => {
const value1 = HardcodedValue.create(
"value1",
"magic-string",
10,
5,
"const x = 'value1'",
)
const value2 = HardcodedValue.create(
"value2",
"magic-string",
20,
5,
"const y = 'value2'",
)
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(0)
})
it("should return duplicates sorted by count in descending order", () => {
const value1a = HardcodedValue.create(
"value1",
"magic-string",
10,
5,
"const x = 'value1'",
)
const value1b = HardcodedValue.create(
"value1",
"magic-string",
20,
5,
"const y = 'value1'",
)
const value2a = HardcodedValue.create(
"value2",
"magic-string",
30,
5,
"const z = 'value2'",
)
const value2b = HardcodedValue.create(
"value2",
"magic-string",
40,
5,
"const a = 'value2'",
)
const value2c = HardcodedValue.create(
"value2",
"magic-string",
50,
5,
"const b = 'value2'",
)
tracker.track(value1a, "file1.ts")
tracker.track(value1b, "file2.ts")
tracker.track(value2a, "file3.ts")
tracker.track(value2b, "file4.ts")
tracker.track(value2c, "file5.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(2)
expect(duplicates[0].value).toBe("value2")
expect(duplicates[0].count).toBe(3)
expect(duplicates[1].value).toBe("value1")
expect(duplicates[1].count).toBe(2)
})
it("should include location information for duplicates", () => {
const value1 = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
const value2 = HardcodedValue.create("test", "magic-string", 20, 10, "const y = 'test'")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates[0].locations).toHaveLength(2)
expect(duplicates[0].locations[0]).toEqual({
file: "file1.ts",
line: 10,
context: "const x = 'test'",
})
expect(duplicates[0].locations[1]).toEqual({
file: "file2.ts",
line: 20,
context: "const y = 'test'",
})
})
})
describe("getDuplicateLocations", () => {
it("should return null when value is not duplicated", () => {
const value = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
tracker.track(value, "file1.ts")
const locations = tracker.getDuplicateLocations("test", "magic-string")
expect(locations).toBeNull()
})
it("should return locations when value is duplicated", () => {
const value1 = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
const value2 = HardcodedValue.create("test", "magic-string", 20, 10, "const y = 'test'")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const locations = tracker.getDuplicateLocations("test", "magic-string")
expect(locations).toHaveLength(2)
expect(locations).toEqual([
{ file: "file1.ts", line: 10, context: "const x = 'test'" },
{ file: "file2.ts", line: 20, context: "const y = 'test'" },
])
})
it("should return null for non-existent value", () => {
const locations = tracker.getDuplicateLocations("non-existent", "magic-string")
expect(locations).toBeNull()
})
it("should handle numeric values", () => {
const value1 = HardcodedValue.create(100, "magic-number", 10, 5, "const x = 100")
const value2 = HardcodedValue.create(100, "magic-number", 20, 5, "const y = 100")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const locations = tracker.getDuplicateLocations(100, "magic-number")
expect(locations).toHaveLength(2)
})
})
describe("isDuplicate", () => {
it("should return false for non-duplicated value", () => {
const value = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
tracker.track(value, "file1.ts")
expect(tracker.isDuplicate("test", "magic-string")).toBe(false)
})
it("should return true for duplicated value", () => {
const value1 = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
const value2 = HardcodedValue.create("test", "magic-string", 20, 10, "const y = 'test'")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
expect(tracker.isDuplicate("test", "magic-string")).toBe(true)
})
it("should return false for non-existent value", () => {
expect(tracker.isDuplicate("non-existent", "magic-string")).toBe(false)
})
it("should handle boolean values", () => {
const value1 = HardcodedValue.create(true, "MAGIC_BOOLEAN", 10, 5, "const x = true")
const value2 = HardcodedValue.create(true, "MAGIC_BOOLEAN", 20, 5, "const y = true")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
expect(tracker.isDuplicate(true, "MAGIC_BOOLEAN")).toBe(true)
})
})
describe("getStats", () => {
it("should return zero stats for empty tracker", () => {
const stats = tracker.getStats()
expect(stats.totalValues).toBe(0)
expect(stats.duplicateValues).toBe(0)
expect(stats.duplicatePercentage).toBe(0)
})
it("should calculate stats correctly with no duplicates", () => {
const value1 = HardcodedValue.create(
"value1",
"magic-string",
10,
5,
"const x = 'value1'",
)
const value2 = HardcodedValue.create(
"value2",
"magic-string",
20,
5,
"const y = 'value2'",
)
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const stats = tracker.getStats()
expect(stats.totalValues).toBe(2)
expect(stats.duplicateValues).toBe(0)
expect(stats.duplicatePercentage).toBe(0)
})
it("should calculate stats correctly with duplicates", () => {
const value1a = HardcodedValue.create(
"value1",
"magic-string",
10,
5,
"const x = 'value1'",
)
const value1b = HardcodedValue.create(
"value1",
"magic-string",
20,
5,
"const y = 'value1'",
)
const value2 = HardcodedValue.create(
"value2",
"magic-string",
30,
5,
"const z = 'value2'",
)
tracker.track(value1a, "file1.ts")
tracker.track(value1b, "file2.ts")
tracker.track(value2, "file3.ts")
const stats = tracker.getStats()
expect(stats.totalValues).toBe(2)
expect(stats.duplicateValues).toBe(1)
expect(stats.duplicatePercentage).toBe(50)
})
it("should handle multiple duplicates", () => {
const value1a = HardcodedValue.create(
"value1",
"magic-string",
10,
5,
"const x = 'value1'",
)
const value1b = HardcodedValue.create(
"value1",
"magic-string",
20,
5,
"const y = 'value1'",
)
const value2a = HardcodedValue.create(
"value2",
"magic-string",
30,
5,
"const z = 'value2'",
)
const value2b = HardcodedValue.create(
"value2",
"magic-string",
40,
5,
"const a = 'value2'",
)
tracker.track(value1a, "file1.ts")
tracker.track(value1b, "file2.ts")
tracker.track(value2a, "file3.ts")
tracker.track(value2b, "file4.ts")
const stats = tracker.getStats()
expect(stats.totalValues).toBe(2)
expect(stats.duplicateValues).toBe(2)
expect(stats.duplicatePercentage).toBe(100)
})
})
describe("clear", () => {
it("should clear all tracked values", () => {
const value1 = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
const value2 = HardcodedValue.create("test", "magic-string", 20, 10, "const y = 'test'")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
expect(tracker.getDuplicates()).toHaveLength(1)
tracker.clear()
expect(tracker.getDuplicates()).toHaveLength(0)
expect(tracker.getStats().totalValues).toBe(0)
})
it("should allow tracking new values after clear", () => {
const value1 = HardcodedValue.create(
"test1",
"magic-string",
10,
5,
"const x = 'test1'",
)
tracker.track(value1, "file1.ts")
tracker.clear()
const value2 = HardcodedValue.create(
"test2",
"magic-string",
20,
5,
"const y = 'test2'",
)
tracker.track(value2, "file2.ts")
const stats = tracker.getStats()
expect(stats.totalValues).toBe(1)
})
})
describe("edge cases", () => {
it("should handle values with colons in them", () => {
const value1 = HardcodedValue.create(
"url:http://example.com",
"magic-string",
10,
5,
"const x = 'url:http://example.com'",
)
const value2 = HardcodedValue.create(
"url:http://example.com",
"magic-string",
20,
5,
"const y = 'url:http://example.com'",
)
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
const duplicates = tracker.getDuplicates()
expect(duplicates).toHaveLength(1)
expect(duplicates[0].value).toBe("url:http://example.com")
})
it("should handle empty string values", () => {
const value1 = HardcodedValue.create("", "magic-string", 10, 5, "const x = ''")
const value2 = HardcodedValue.create("", "magic-string", 20, 5, "const y = ''")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
expect(tracker.isDuplicate("", "magic-string")).toBe(true)
})
it("should handle zero as a number", () => {
const value1 = HardcodedValue.create(0, "magic-number", 10, 5, "const x = 0")
const value2 = HardcodedValue.create(0, "magic-number", 20, 5, "const y = 0")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file2.ts")
expect(tracker.isDuplicate(0, "magic-number")).toBe(true)
})
it("should track same file multiple times", () => {
const value1 = HardcodedValue.create("test", "magic-string", 10, 5, "const x = 'test'")
const value2 = HardcodedValue.create("test", "magic-string", 20, 5, "const y = 'test'")
tracker.track(value1, "file1.ts")
tracker.track(value2, "file1.ts")
const locations = tracker.getDuplicateLocations("test", "magic-string")
expect(locations).toHaveLength(2)
expect(locations?.[0].file).toBe("file1.ts")
expect(locations?.[1].file).toBe("file1.ts")
})
})
})

View File

@@ -274,4 +274,68 @@ describe("SecretDetector", () => {
expect(violations).toBeInstanceOf(Array) expect(violations).toBeInstanceOf(Array)
}) })
}) })
describe("real secret detection", () => {
it("should detect AWS access key pattern", async () => {
const code = `const awsKey = "AKIAIOSFODNN7EXAMPLE"`
const violations = await detector.detectAll(code, "aws.ts")
if (violations.length > 0) {
expect(violations[0].secretType).toContain("AWS")
}
})
it("should detect basic auth credentials", async () => {
const code = `const auth = "https://user:password@example.com"`
const violations = await detector.detectAll(code, "auth.ts")
if (violations.length > 0) {
expect(violations[0].file).toBe("auth.ts")
expect(violations[0].line).toBeGreaterThan(0)
expect(violations[0].column).toBeGreaterThan(0)
}
})
it("should detect private SSH key", async () => {
const code = `
const privateKey = \`-----BEGIN RSA PRIVATE KEY-----
MIIBogIBAAJBALRiMLAA...
-----END RSA PRIVATE KEY-----\`
`
const violations = await detector.detectAll(code, "ssh.ts")
if (violations.length > 0) {
expect(violations[0].secretType).toBeTruthy()
}
})
it("should return violation objects with required properties", async () => {
const code = `const key = "AKIAIOSFODNN7EXAMPLE"`
const violations = await detector.detectAll(code, "test.ts")
violations.forEach((v) => {
expect(v).toHaveProperty("file")
expect(v).toHaveProperty("line")
expect(v).toHaveProperty("column")
expect(v).toHaveProperty("secretType")
expect(v.getMessage).toBeDefined()
expect(v.getSuggestion).toBeDefined()
})
})
it("should handle files with multiple secrets", async () => {
const code = `
const key1 = "AKIAIOSFODNN7EXAMPLE"
const key2 = "AKIAIOSFODNN8EXAMPLE"
`
const violations = await detector.detectAll(code, "multiple.ts")
expect(violations).toBeInstanceOf(Array)
})
})
}) })