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 { ISecretDetector } from "./domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "./domain/services/IAnemicModelDetector"
|
||||
import { IDuplicateValueTracker } from "./domain/services/IDuplicateValueTracker"
|
||||
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
||||
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||
@@ -25,6 +26,7 @@ import { RepositoryPatternDetector } from "./infrastructure/analyzers/Repository
|
||||
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
|
||||
import { SecretDetector } from "./infrastructure/analyzers/SecretDetector"
|
||||
import { AnemicModelDetector } from "./infrastructure/analyzers/AnemicModelDetector"
|
||||
import { DuplicateValueTracker } from "./infrastructure/analyzers/DuplicateValueTracker"
|
||||
import { ERROR_MESSAGES } from "./shared/constants"
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,7 @@ export async function analyzeProject(
|
||||
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
|
||||
const secretDetector: ISecretDetector = new SecretDetector()
|
||||
const anemicModelDetector: IAnemicModelDetector = new AnemicModelDetector()
|
||||
const duplicateValueTracker: IDuplicateValueTracker = new DuplicateValueTracker()
|
||||
const useCase = new AnalyzeProject(
|
||||
fileScanner,
|
||||
codeParser,
|
||||
@@ -97,6 +100,7 @@ export async function analyzeProject(
|
||||
aggregateBoundaryDetector,
|
||||
secretDetector,
|
||||
anemicModelDetector,
|
||||
duplicateValueTracker,
|
||||
)
|
||||
|
||||
const result = await useCase.execute(options)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatt
|
||||
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
||||
import { ISecretDetector } from "../../domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
|
||||
import { IDuplicateValueTracker } from "../../domain/services/IDuplicateValueTracker"
|
||||
import { SourceFile } from "../../domain/entities/SourceFile"
|
||||
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||
import { CollectFiles } from "./pipeline/CollectFiles"
|
||||
@@ -62,8 +63,9 @@ export interface HardcodeViolation {
|
||||
type:
|
||||
| typeof HARDCODE_TYPES.MAGIC_NUMBER
|
||||
| typeof HARDCODE_TYPES.MAGIC_STRING
|
||||
| typeof HARDCODE_TYPES.MAGIC_BOOLEAN
|
||||
| typeof HARDCODE_TYPES.MAGIC_CONFIG
|
||||
value: string | number
|
||||
value: string | number | boolean
|
||||
file: string
|
||||
line: number
|
||||
column: number
|
||||
@@ -225,6 +227,7 @@ export class AnalyzeProject extends UseCase<
|
||||
aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||
secretDetector: ISecretDetector,
|
||||
anemicModelDetector: IAnemicModelDetector,
|
||||
duplicateValueTracker: IDuplicateValueTracker,
|
||||
) {
|
||||
super()
|
||||
this.fileCollectionStep = new CollectFiles(fileScanner)
|
||||
@@ -239,6 +242,7 @@ export class AnalyzeProject extends UseCase<
|
||||
aggregateBoundaryDetector,
|
||||
secretDetector,
|
||||
anemicModelDetector,
|
||||
duplicateValueTracker,
|
||||
)
|
||||
this.resultAggregator = new AggregateResults()
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryP
|
||||
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
|
||||
import { ISecretDetector } from "../../../domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "../../../domain/services/IAnemicModelDetector"
|
||||
import { IDuplicateValueTracker } from "../../../domain/services/IDuplicateValueTracker"
|
||||
import { SourceFile } from "../../../domain/entities/SourceFile"
|
||||
import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
|
||||
import { HardcodedValue } from "../../../domain/value-objects/HardcodedValue"
|
||||
import {
|
||||
LAYERS,
|
||||
REPOSITORY_VIOLATION_TYPES,
|
||||
@@ -64,6 +66,7 @@ export class ExecuteDetection {
|
||||
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||
private readonly secretDetector: ISecretDetector,
|
||||
private readonly anemicModelDetector: IAnemicModelDetector,
|
||||
private readonly duplicateValueTracker: IDuplicateValueTracker,
|
||||
) {}
|
||||
|
||||
public async execute(request: DetectionRequest): Promise<DetectionResult> {
|
||||
@@ -151,7 +154,10 @@ export class ExecuteDetection {
|
||||
}
|
||||
|
||||
private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] {
|
||||
const violations: HardcodeViolation[] = []
|
||||
const allHardcodedValues: {
|
||||
value: HardcodedValue
|
||||
file: SourceFile
|
||||
}[] = []
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const hardcodedValues = this.hardcodeDetector.detectAll(
|
||||
@@ -160,23 +166,53 @@ export class ExecuteDetection {
|
||||
)
|
||||
|
||||
for (const hardcoded of hardcodedValues) {
|
||||
violations.push({
|
||||
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,
|
||||
})
|
||||
allHardcodedValues.push({ value: hardcoded, file })
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { HARDCODE_TYPES } from "../../shared/constants/rules"
|
||||
import { DETECTION_PATTERNS, HARDCODE_TYPES } from "../../shared/constants/rules"
|
||||
import { CONSTANT_NAMES, LOCATIONS, SUGGESTION_KEYWORDS } from "../constants/Suggestions"
|
||||
|
||||
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 {
|
||||
readonly value: string | number
|
||||
readonly value: string | number | boolean
|
||||
readonly type: HardcodeType
|
||||
readonly valueType?: ValueType
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
readonly context: string
|
||||
readonly duplicateLocations?: DuplicateLocation[]
|
||||
readonly withinFileUsageCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,22 +46,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
|
||||
}
|
||||
|
||||
public static create(
|
||||
value: string | number,
|
||||
value: string | number | boolean,
|
||||
type: HardcodeType,
|
||||
line: number,
|
||||
column: number,
|
||||
context: string,
|
||||
valueType?: ValueType,
|
||||
duplicateLocations?: DuplicateLocation[],
|
||||
withinFileUsageCount?: number,
|
||||
): HardcodedValue {
|
||||
return new HardcodedValue({
|
||||
value,
|
||||
type,
|
||||
valueType,
|
||||
line,
|
||||
column,
|
||||
context,
|
||||
duplicateLocations,
|
||||
withinFileUsageCount,
|
||||
})
|
||||
}
|
||||
|
||||
public get value(): string | number {
|
||||
public get value(): string | number | boolean {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
@@ -56,6 +87,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
|
||||
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 {
|
||||
return this.props.type === HARDCODE_TYPES.MAGIC_NUMBER
|
||||
}
|
||||
@@ -106,6 +159,154 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
|
||||
private suggestStringConstantName(): string {
|
||||
const value = String(this.props.value)
|
||||
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)) {
|
||||
return CONSTANT_NAMES.API_BASE_URL
|
||||
@@ -135,6 +336,23 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
|
||||
}
|
||||
|
||||
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 (
|
||||
context.includes(SUGGESTION_KEYWORDS.ENTITY) ||
|
||||
@@ -153,4 +371,122 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
|
||||
|
||||
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 { AnemicModelViolation } from "../../domain/value-objects/AnemicModelViolation"
|
||||
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
|
||||
@@ -224,8 +224,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
|
||||
lineNumber,
|
||||
methodCount,
|
||||
propertyCount,
|
||||
false,
|
||||
true,
|
||||
ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_FALSE,
|
||||
ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_TRUE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,8 +237,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
|
||||
lineNumber,
|
||||
methodCount,
|
||||
propertyCount,
|
||||
true,
|
||||
false,
|
||||
ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_TRUE,
|
||||
ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_FALSE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,8 +256,8 @@ export class AnemicModelDetector implements IAnemicModelDetector {
|
||||
lineNumber,
|
||||
methodCount,
|
||||
propertyCount,
|
||||
false,
|
||||
false,
|
||||
ANALYZER_DEFAULTS.HAS_ONLY_GETTERS_SETTERS,
|
||||
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 { 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 { ExportConstantAnalyzer } from "../strategies/ExportConstantAnalyzer"
|
||||
import { MagicNumberMatcher } from "../strategies/MagicNumberMatcher"
|
||||
import { MagicStringMatcher } from "../strategies/MagicStringMatcher"
|
||||
import { AstTreeTraverser } from "./AstTreeTraverser"
|
||||
|
||||
/**
|
||||
* Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code
|
||||
*
|
||||
* This detector identifies configuration values, URLs, timeouts, ports, and other
|
||||
* constants that should be extracted to configuration files. It uses pattern matching
|
||||
* and context analysis to reduce false positives.
|
||||
* This detector uses Abstract Syntax Tree (AST) analysis via tree-sitter to identify
|
||||
* configuration values, URLs, timeouts, ports, and other constants that should be
|
||||
* 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
|
||||
* ```typescript
|
||||
@@ -26,17 +37,25 @@ import { MagicStringMatcher } from "../strategies/MagicStringMatcher"
|
||||
*/
|
||||
export class HardcodeDetector implements IHardcodeDetector {
|
||||
private readonly constantsChecker: ConstantsFileChecker
|
||||
private readonly braceTracker: BraceTracker
|
||||
private readonly exportAnalyzer: ExportConstantAnalyzer
|
||||
private readonly numberMatcher: MagicNumberMatcher
|
||||
private readonly stringMatcher: MagicStringMatcher
|
||||
private readonly parser: CodeParser
|
||||
private readonly traverser: AstTreeTraverser
|
||||
|
||||
constructor() {
|
||||
this.constantsChecker = new ConstantsFileChecker()
|
||||
this.braceTracker = new BraceTracker()
|
||||
this.exportAnalyzer = new ExportConstantAnalyzer(this.braceTracker)
|
||||
this.numberMatcher = new MagicNumberMatcher(this.exportAnalyzer)
|
||||
this.stringMatcher = new MagicStringMatcher(this.exportAnalyzer)
|
||||
this.parser = new CodeParser()
|
||||
|
||||
const contextChecker = new AstContextChecker()
|
||||
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 []
|
||||
}
|
||||
|
||||
const magicNumbers = this.numberMatcher.detect(code)
|
||||
const magicStrings = this.stringMatcher.detect(code)
|
||||
|
||||
return [...magicNumbers, ...magicStrings]
|
||||
const tree = this.parseCode(code, filePath)
|
||||
return this.traverser.traverse(tree, code)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +86,9 @@ export class HardcodeDetector implements IHardcodeDetector {
|
||||
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 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 { SecretViolation } from "../../domain/value-objects/SecretViolation"
|
||||
import { SECRET_KEYWORDS, SECRET_TYPE_NAMES } from "../../domain/constants/SecretExamples"
|
||||
import { EXTERNAL_PACKAGES } from "../../shared/constants/rules"
|
||||
|
||||
/**
|
||||
* Detects hardcoded secrets in TypeScript/JavaScript code
|
||||
@@ -25,7 +26,7 @@ export class SecretDetector implements ISecretDetector {
|
||||
private readonly secretlintConfig: SecretLintConfigDescriptor = {
|
||||
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 = {
|
||||
MAGIC_NUMBER: "magic-number",
|
||||
MAGIC_STRING: "magic-string",
|
||||
MAGIC_BOOLEAN: "magic-boolean",
|
||||
MAGIC_CONFIG: "magic-config",
|
||||
} as const
|
||||
|
||||
@@ -416,3 +417,83 @@ export const REPOSITORY_VIOLATION_TYPES = {
|
||||
NEW_REPOSITORY_IN_USE_CASE: "new-repository-in-use-case",
|
||||
NON_DOMAIN_METHOD_NAME: "non-domain-method-name",
|
||||
} 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)
|
||||
})
|
||||
})
|
||||
|
||||
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