mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}...`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
358
packages/guardian/tests/unit/domain/EntityExposure.test.ts
Normal file
358
packages/guardian/tests/unit/domain/EntityExposure.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user